From f2bba2914e19fcb4cd6791bce6f7da7e3fc0e38f Mon Sep 17 00:00:00 2001 From: ayushya Date: Mon, 21 Sep 2026 18:06:55 -0400 Subject: [PATCH] Offline tab as Files view, automatic background sync, sync notifications - Offline tab is now FilesView(offline: true) over a FolderBrowser interface (FilesController / OfflineController) sharing controls, tiles, thumbnails - Synced files follow the Files Cache rule (default: refresh every 15 min): per-account WorkManager jobs, in-app timer, resume and pull triggers; root-etag shortcut skips full walks when nothing changed - Single sync at a time (shared lock); logging out stops that account's sync - Sync safety: PROPFIND failures skip the path instead of deleting local files - Mirror empty folders and remove deleted ones; drop synced paths deleted on the server; missing local files are re-downloaded - Notifications: silent per-account sync notifications, "Background sync notifications" setting, audible upload/download completion - Files refresh in place (no spinner flash); retry after network returns - Fix sync badges not updating (shared native status stream), "Sync off" on launch (eager providers), Files Cache section moved under Device Sync Co-Authored-By: Claude Sonnet 5 --- .claude/context/architecture.md | 61 ++- .claude/context/server.md | 294 +++++++++++- .claude/context/standards.md | 10 +- .claude/context/styling.md | 21 +- android/app/src/main/AndroidManifest.xml | 4 + .../dev/ayushya/noo/ConflictResolveWorker.kt | 10 +- .../kotlin/dev/ayushya/noo/DownloadService.kt | 10 +- .../kotlin/dev/ayushya/noo/MainActivity.kt | 106 ++++- .../ayushya/noo/NooNotificationChannels.kt | 10 + .../dev/ayushya/noo/ShareUploadService.kt | 7 +- .../main/kotlin/dev/ayushya/noo/SyncEngine.kt | 176 ++++++- .../main/kotlin/dev/ayushya/noo/SyncWorker.kt | 343 +++++++++++--- lib/main.dart | 35 +- lib/models/app_tab.dart | 15 +- lib/providers/connectivity_controller.dart | 53 +++ lib/providers/files_controller.dart | 284 +++++++++--- lib/providers/folder_browser.dart | 31 ++ lib/providers/offline_controller.dart | 246 ++++++++++ lib/providers/photos_controller.dart | 33 +- lib/providers/session_controller.dart | 152 +++++- lib/providers/sync_status_controller.dart | 384 ++++++++++++++-- lib/services/account_store.dart | 20 + lib/services/sync_service.dart | 201 ++++++-- lib/views/account_view.dart | 129 +++--- lib/views/file_viewer_screen.dart | 300 +++++++++--- lib/views/files_view.dart | 434 ++++++++---------- lib/widgets/app_tab_view_builder.dart | 13 +- lib/widgets/files_controls_row.dart | 136 ++++++ lib/widgets/item_icon.dart | 26 +- lib/widgets/manage_synced_folders_sheet.dart | 108 +++++ lib/widgets/media_grid_tile.dart | 98 ++++ lib/widgets/more_tabs_button.dart | 8 +- lib/widgets/synced_header_scaffold.dart | 51 +- pubspec.lock | 24 + pubspec.yaml | 1 + test/widget_test.dart | 36 +- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 38 files changed, 3133 insertions(+), 741 deletions(-) create mode 100644 lib/providers/connectivity_controller.dart create mode 100644 lib/providers/folder_browser.dart create mode 100644 lib/providers/offline_controller.dart create mode 100644 lib/widgets/files_controls_row.dart create mode 100644 lib/widgets/manage_synced_folders_sheet.dart create mode 100644 lib/widgets/media_grid_tile.dart diff --git a/.claude/context/architecture.md b/.claude/context/architecture.md index 3f20809..907d60b 100644 --- a/.claude/context/architecture.md +++ b/.claude/context/architecture.md @@ -60,7 +60,13 @@ new provider instance): (grid/list, hidden files, storage scope, sort field/order, per-folder sort map), and the shared `applyCommonFilters`/`applyFilesDisplayPrefs` helpers Photos/Favorites reuse rather than duplicating the same - filter/sort logic. + filter/sort logic. `refreshData` refreshes **in place** when a listing is + already on screen (pull-to-refresh, periodic refresh, post-mutation): no + spinner, the list is swapped once the server answers, and a failed + refresh keeps the old list. The spinner/error page only appear when there + is nothing to show (first load, `_navigateTo` clears the outgoing folder + first, retry after an error). Keep that invariant - blanking the list to + a spinner made refreshes look like files vanishing and reappearing. - [`PhotosController`](../../lib/providers/photos_controller.dart) / [`FavoritesController`](../../lib/providers/favorites_controller.dart) — each depends on `FilesController` for the shared storage-scope toggle and @@ -78,10 +84,47 @@ new provider instance): — device-sync settings and live status (`syncStatusFor(item)`, `syncHeaderStatus`, conflicts), subscribed to `SyncService`'s status stream directly rather than living inside the same object as the item - lists it decorates. + lists it decorates. Depends on `FilesController` (declared before it in + `main()`) because synced files refresh by the Files Cache rule - it owns + the foreground timer/resume/pull triggers and background scheduling; see + `server.md`'s "Keeping synced files current automatically". - [`PickController`](../../lib/providers/pick_controller.dart) — "being picked by another app" state (`pickRequest`/`isPicking`/`confirmPick`/ `itemMatchesPickFilter`) — see `server.md`. +- [`FolderBrowser`](../../lib/providers/folder_browser.dart) — the small + interface (`pathStack`/`currentFolderPath`/`items`/`isLoading`/ + `errorMessage`/navigation/`reload`) `FilesView` needs from whatever + supplies its folder. `FilesController` (server) and `OfflineController` + (local mirror) both implement it, which is what lets the Offline tab be + `FilesView(offline: true)` - literally the Files tab over the device-sync + mirror, not a second view. `offline` only swaps the data source, reads + images from the local file (`localFileFor`) instead of a server preview, + and turns off what needs the server: selection and bulk actions, swipe + actions, sync badges, "+" (replaced by "Manage synced folders"), pick + mode. The two `FilesView`s are keyed in `buildAppTabView` so the + `IndexedStack` never hands one's State to the other. +- [`OfflineController`](../../lib/providers/offline_controller.dart) — the + Files tab's folder listing read from local storage instead of the + server. Owns no display prefs: `items` runs the + listing through `FilesController.applyFilesDisplayPrefs` (hidden files, + files/folders filter, per-folder sort keyed by the same remote path, + minus the cloud/external scope) and `FilesView` reads grid/list from + `FilesController`, so both tabs share one set of controls + (`FilesControlsRow`). `FilesController` restores those prefs on the + account-*ready* event too (memoized `_restoreDisplayPrefs`) so they apply + offline. Refreshed (see `server.md`'s "Device sync" section) automatically whenever a sync pass + finishes. Listens for `SessionController.addAccountReadyListener`, not + `addAccountActivatedListener` - its work is local-only, so it's safe to + run even on a provisional/offline login (see `ConnectivityController` + below). Registered with `lazy: false` in `main.dart` (as is + `SyncStatusController`) - the ready event fires once at startup, before + any widget reads a lazily-created provider, so a lazy one would register + its listener too late and never load its persisted state. +- [`ConnectivityController`](../../lib/providers/connectivity_controller.dart) + — wraps `connectivity_plus`, exposing just `isOffline`. The one thing + every network-touching controller (indirectly, via `SessionController`) + and `MainShellView` consult before making a request or deciding what to + show - see `server.md`'s "Working offline" section. - [`ItemOperations`](../../lib/providers/item_operations.dart) — not a `ChangeNotifier`; a plain, `const`-constructible class holding direct references to `SessionController`/`FilesController`/`PhotosController`/ @@ -132,8 +175,8 @@ directly, which call `NextcloudService`/`LoginFlowService`/`AccountStore`/ - else `session.needsUnlock` → `LockScreenView` (login lock — see above) - else `MainShellView` -`MainShellView` is a bottom-nav `IndexedStack` over up to 7 tabs — Files, -Photos, Favorites, Activity, Trash, Shares, Recent — user-configurable +`MainShellView` is a bottom-nav `IndexedStack` over up to 8 tabs — Files, +Photos, Favorites, Activity, Trash, Shares, Recent, Offline — user-configurable (order, visibility up to `maxVisibleTabs`, default tab) via `AppTab`/`SettingsController` and rendered through `buildAppTabView` (`widgets/app_tab_view_builder.dart`). `maxVisibleTabs` (5) is less than the @@ -159,7 +202,13 @@ another app" story) that feeds `PickController.pickRequest`; while `isPicking`, the visible tab list is overridden to just Files and Photos regardless of the user's own hidden/reordered tab settings, since those are the only two views that know how to handle a picking-mode tap and the -only two that make sense as external "choose a file" sources. +only two that make sense as external "choose a file" sources. The same +override mechanism restricts the visible tabs to just Offline while +`ConnectivityController.isOffline` - see `server.md`'s "Working offline" +section for the full story, including why session restore itself has to +avoid making a network request in that case. `MoreTabsButton` hides +entirely in both cases (picking, offline) for the same reason - there's +nothing it could usefully open. `MainShellView` also fires a one-time notification-permission prompt on its first mount (`_maybeRequestNotificationPermission`, gated by a plain `shared_preferences` flag so it only ever asks once, not on every @@ -174,7 +223,7 @@ pill's item row in a horizontal `SingleChildScrollView` rather than a plain `Row`, so a wide selected-item label plus several icon-only tabs scrolls instead of overflowing on narrower screens. -All six tabs, plus `ShareUploadView` (the share-to-upload destination +All eight tabs, plus `ShareUploadView` (the share-to-upload destination picker, pushed rather than a tab - see below), share [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — a `CustomScrollView` with a pull-down "sync status" header (Google diff --git a/.claude/context/server.md b/.claude/context/server.md index ab44d70..5887f71 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -274,9 +274,13 @@ the whole engine is plain Kotlin using Android's WorkManager directly. `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). + server-side, unchanged locally), re-download a file whose local copy is + missing even though state says it was mirrored (stale state from an + earlier sync of the path - treating that as "user deleted it" used to + skip the download and report phantom removals), or flag a + **conflict** (both changed since the last recorded state). The + "removed" count in the summary notification only counts local files that + actually existed and were deleted. - [`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 @@ -286,7 +290,16 @@ the whole engine is plain Kotlin using Android's WorkManager directly. 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. + any conflicts, one notification per file with two actions. A progress + notification (`notifyProgress`) is shown lazily - only once the first + actual download/upload starts, not unconditionally at the top of every + run, so a periodic pass that finds nothing to transfer never flashes a + notification at all. It shares the summary's notification ID + (`SUMMARY_NOTIFICATION_ID`) on purpose: the final `notifySummary` call + naturally replaces the ongoing progress notification in place once the + run finishes (no flicker of two separate notifications), and if nothing + ended up changing, `doWork` explicitly cancels that ID since there's no + summary to replace it with. - **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 @@ -299,8 +312,19 @@ the whole engine is plain Kotlin using Android's WorkManager directly. 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 `SyncStatusController` - right after login/account-switch) and `SyncService.statusStream` (live) + drift - which is also why `SyncWorker.doWork()` saves state *before* + its final `SyncStatusBus.setSyncing(accountId, false)` publish, not + after: that publish is what tells Dart's live listener to recompute + `syncedFileIds`, so publishing first would hand back a snapshot still + missing every file the run just downloaded, with no further event ever + arriving afterward to correct it - newly-synced files would sit with no + badge until the app restarted and force-refreshed via `getStatus()`. + `SyncService.getStatus(accountId)` (one-shot, seeds `SyncStatusController` + right after login/account-switch - the account id is passed explicitly + because `SyncStatusBus` only learns an account once a sync pass has run + in this process, so on a fresh app start it'd report empty + `syncedFileIds`; `SyncStatusController._applySnapshot` likewise ignores + the bus's initial null-account emission so it can't wipe that seed) and `SyncService.statusStream` (live) both return the same snapshot shape. `SyncStatusController.syncHeaderStatus` (off/syncing/done/alert - drives `SyncedHeaderScaffold`'s persistent chip/panel, replacing what used to be the WebDAV-refresh-loading @@ -333,6 +357,37 @@ the whole engine is plain Kotlin using Android's WorkManager directly. [`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. +- **Keeping synced files current automatically - no "Sync now" needed.** + Synced paths follow the Files Cache rule (Settings, right under Device + Sync; default *Refresh periodically, 15 min*), driven by + `SyncStatusController` (see its doc comment): *periodically* = a + WorkManager `PeriodicWorkRequest` at `max(interval, 15)` min (Android's + floor) via `reschedule(intervalMinutes:)`, plus an in-app `Timer` at the + exact interval and a pass on app resume if one is due; *never cache* = no + background job, a pass on each app open/resume; *manual* = only + pull-to-refresh (`SyncedHeaderScaffold` calls `syncOnPull`, on any tab) and + "Sync now". **There's no server push:** Nextcloud's push options + (`notify_push` needs a server app plus a persistent WebSocket, which + Android kills in the background without a foreground service; Nextcloud's + FCM/UnifiedPush proxy needs a registered app identity) aren't viable for + a self-hosted-server client, so it polls - and polling is made cheap by the + **root-etag shortcut**: Nextcloud propagates any descendant change up + through every ancestor folder's `getetag`, so each run first does one + Depth-0 PROPFIND per synced root and skips the recursive walk if the etag + matches the stored `SyncEngine.RootMarker` *and* every local file still + matches its recorded size/mtime (`localMatchesState` - nothing to upload or + re-download). A root is only marked after a fully clean pass (no failed + transfer, no conflict), a full walk is forced at least every 6 h + (`FULL_WALK_MAX_AGE_MS`; etag propagation is unreliable on external + storage), and "Sync now" (`KEY_FORCE`) always walks. Automatic/pull passes + are `force: false`: silent (progress notification only once a transfer + starts), `ExistingWorkPolicy.KEEP` (never cancel a run in progress), and + automatic ones also carry the Wi-Fi-only constraint. **Safety:** PROPFIND + failures (network error, 5xx) throw `RemoteUnavailableException` and the + path is skipped that run - previously they returned an empty listing, + which made every synced file look deleted server-side and the diff deleted + the local copies (a real risk once passes run every few minutes). Only a + clean 404 means "gone". - `MainActivity.kt`'s `dev.ayushya.noo/sync_service` channel (`reschedule`/`cancel`/`syncNow`/`removeLocalSync`) is the only bridge from Dart: a periodic `WorkRequest`'s input `Data` and `Constraints` are @@ -341,8 +396,46 @@ the whole engine is plain Kotlin using Android's WorkManager directly. not updating in place. [`SyncService`](../../lib/services/sync_service.dart) (Dart) wraps this - `SyncStatusController` calls `SyncService.reschedule` after every successful login/account switch and every synced-folder/ - `syncOnCellular` change, and `SyncService.cancel` on logout/last-account- - removed. The network constraint is `NetworkType.UNMETERED` by default + cache-rule/`syncOnCellular` change. `reschedule` covers **every saved + account**, not just the active one: each account with sync enabled gets its + own periodic job (`SyncWorker.periodicNameFor(accountId)`) carrying that + account's own credentials (read from `AccountStore`), paths and interval - + the active account's from live `SyncStatusController` state, the others' + from their persisted per-account prefs (`_SyncConfig.fromPrefs`). Logging + out stops that account's background sync: `SessionController.logout` + records it in `AccountStore`'s signed-out set (so a later `reschedule` for + another account doesn't resurrect its job) and cancels it via + `SyncService.cancelAccount`; activating the account again clears the flag. + Removing an account cancels its job too. One-off runs are per-account + (`oneOffNameFor`). +- **Folders, not just files, are mirrored.** After applying a path's diff, + `SyncWorker` calls `SyncEngine.mirrorFolders` (a local directory for every + remote folder, so an empty folder created on the web appears on device) and + `pruneRemovedFolders` (removes local directories the server no longer has, + but only *empty* ones - the diff has already deleted the files that were in + them - and never the sync root). Root-etag markers carry a version + (`ROOTS_VERSION`) so bumping it forces one full walk after a change like this. + A configured path the server 404s (deleted on the web) is recorded via + `setRootMissing`; the snapshot's `missingRoots` lets + `SyncStatusController._applySnapshot` drop it from the synced list. +- **Notifications.** Sync notifications are silent (channel `device_sync_v2`, + `IMPORTANCE_LOW` + `setSilent`), with ids and titles scoped per account + (`summaryNotificationId`/`conflictNotificationId`) so accounts don't + overwrite each other. "Background sync notifications" (Settings, global, + default on) gates progress/summary for automatic runs (`KEY_NOTIFY`, + baked into the periodic job input so it needs a reschedule); conflicts and + user-initiated "Sync now" always notify. Manual download/upload + notifications use `file_downloads_v2`/`share_upload_v2` at default importance + (progress silent, completion audible). Channel importance can't be changed + once created, so changing it means a new channel id plus + `NooNotificationChannels.ensure(legacyIds = ...)` deleting the old one. +- **One sync at a time.** `SyncEngine.syncLock` (a process-wide coroutine + `Mutex`) wraps both `SyncWorker` and `ConflictResolveWorker`. WorkManager + runs differently-named jobs concurrently, and both workers load the whole + sync-state map, mutate it and save it back - so overlapping runs (periodic + + a pull, two accounts due at once) silently discarded each other's + updates, and `SyncStatusBus` only tracks one account at a time. A run that + has to wait simply starts when the current one finishes. The network constraint is `NetworkType.UNMETERED` by default (`!syncOnCellular`, Wi-Fi only) or `NetworkType.CONNECTED` if the user's opted into cellular sync. Turning sync off for a path (`SyncStatusController.removeSyncedPath`) also calls @@ -362,20 +455,89 @@ the whole engine is plain Kotlin using Android's WorkManager directly. tracks which of those paths are folders vs individual files for the sync header's folder/item counts, defaulting missing entries to folder (the common case, and what any path added before this map existed will look - like). `syncEverything` (`ui_sync_everything`, per account) works the + like). **`addSyncedPaths`/`removeSyncedPaths` gate on + `_accountLoadedGate`** (a `Completer`, deliberately starting + *incomplete* - not pre-completed - completed once + `_onAccountActivated`'s async per-account prefs load actually finishes, + re-armed on `_onAccountCleared`) before touching `_syncedPaths` at all - + without this, syncing a folder soon enough after opening the app (or an + account switch) could race that load: the mutator spreads the *current + in-memory* `_syncedPaths` (still `[]`, the pre-load default) and + immediately persists the result, silently overwriting the + previously-saved list and losing every other folder that had been + synced before. Starting the gate pre-completed was an actual bug here - + it meant only account *switches* (which call `_onAccountCleared`, + re-arming it) were protected, leaving the very first cold-start load + completely exposed to the race. A second, related guard, + `_hasLoadedSyncedPathsForAccount`, stops `_onAccountActivated` from + re-reading `_syncedPaths` from storage more than once per account - + activation can fire again for the same account (e.g. + `SessionController` re-verifying a provisional/offline login once + connectivity returns), and a second read could clobber an in-memory + mutation made between the first load and that one if its own persist + hadn't landed yet. `syncEverything` + (`ui_sync_everything`, per account) works the same way - 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", - works over the whole selection at once - either item type, folders or - files - not just a single item; the action reads as "stop syncing" only - once every selected item is already synced, otherwise it syncs whichever - ones aren't yet, and either direction ends with a confirmation - SnackBar). + per-item opt-in. `syncOnCellular` is a plain global pref. `syncEverything` + and `syncOnCellular` are managed from Settings → Device Sync (a "Sync + everything" switch, the cellular toggle, a manual "Sync now", and a + "View offline files" row that pushes the Offline tab - see below); the + configured path list itself, with its remove buttons, lives on the + Offline tab now, not Settings. Individual files or folders are toggled + from Files' selection toolbar ("Sync to device", works over the whole + selection at once - either item type, folders or files - not just a + single item; the action reads as "stop syncing" only once every selected + item is already synced, otherwise it syncs whichever ones aren't yet, + and either direction ends with a confirmation SnackBar). Multi-item + add/remove goes through `SyncStatusController.addSyncedPaths`/ + `removeSyncedPaths` (batched), never a per-item loop of + `addSyncedPath`/`removeSyncedPath` - looping was an actual bug: each + `addSyncedPath` call fires its own `SyncService.syncNow`, and + `syncNow`'s native side enqueues via `WorkManager.enqueueUniqueWork(..., + ExistingWorkPolicy.REPLACE, ...)`, so a second item's call cancelled the + first item's still-in-flight sync pass instead of letting it finish - + only ever syncing the last item enqueued. The batched methods mutate + `_syncedPaths` for the whole set and call `reschedule`/`syncNow` exactly + once, with the complete folder list, so `SyncWorker` handles every item + in one run (it already loops its whole `folders` list sequentially + within a single `doWork()` call - see above). +- **The Offline tab** (`FilesView(offline: true)` over + [`OfflineController`](../../lib/providers/offline_controller.dart), see + `architecture.md`'s `FolderBrowser`) is the Files tab itself - same + breadcrumbs, controls, tiles and thumbnails - over whatever device-sync + has actually landed on disk - deliberately no selection/multi-select + toolbar, since delete/share/move don't make sense for an already-synced + local mirror. + Reads straight off `/sync//` + (non-recursive `Directory.list()` per folder) via `dart:io`, not fetched + from the server (mirrors `SyncEngine.kt#syncRoot`'s layout exactly), so + it works with no connection and never round-trips through a + MethodChannel just to list files. `OfflineController` refetches the + current folder automatically whenever a `SyncService.statusStream` + snapshot shows syncing just stopped, so newly-downloaded files show up + without a manual pull-to-refresh. Tapping an item opens + `FileViewerScreen` with `localPath` set (see below) - same in-app viewer + Files uses, just reading from disk instead of the server; tapping a + folder navigates into it, same as Files. Resolves the local path via + `OfflineController.localPathFor(item)`, a pure function of the item's + path - deliberately *not* + `SyncStatusController.localSyncedFilePath`, which additionally + re-verifies the item falls under a configured sync target + (`_isPathInSyncScope`). That check is redundant and was actually a bug + here: every item this controller ever hands out already came from + listing this exact directory tree, so it's definitionally already + local, and re-deriving "is this still in scope" from `syncedPaths` + could disagree with what's genuinely sitting on disk (e.g. nested + paths, timing right after a scope change) and report a visibly-listed + file as "no longer available". Pull-to-refresh triggers + `SyncService.syncNow` followed by a re-list. The + configured sync targets themselves (with "stop syncing" per target, what + used to be Settings' Device Sync card's own inline list) live in a + bottom sheet behind the app bar's sync icon (`_ManageSyncedFoldersSheet`) + rather than inline in the main view, so the browser itself stays a plain + Files-style listing; Settings' own "View offline files" row just pushes + this whole tab. - **`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 @@ -419,6 +581,98 @@ the whole engine is plain Kotlin using Android's WorkManager directly. selected file is already synced - a mixed selection still goes through the normal `DownloadService` batch) both check it first. +## Working offline + +[`ConnectivityController`](../../lib/providers/connectivity_controller.dart) +wraps `connectivity_plus` (OS-level route detection - Wi-Fi/mobile/none, +not a guarantee the Nextcloud server itself is reachable) and is the +single source of truth every offline-aware decision in the app consults. +**Its very first `checkConnectivity()` call is re-verified once more, +~2 seconds later** - that first check can spuriously report "no network" +while Android's connectivity stack is still attaching callbacks to a +just-started process (a real cold-start quirk, not a genuine transition), +and since `onConnectivityChanged` only fires on actual transitions, a +false initial "offline" read would otherwise stick for the rest of the +session with no further event ever correcting it - `SessionController` +would keep treating the login as provisional indefinitely, and every +network-fetching controller (Files/Photos/Favorites/Trash/Shares/Recent) +would simply never receive its real activation signal, leaving every tab +permanently empty despite the device being online the whole time. This +was a real, previously-shipped regression, not a hypothetical. + +- **Session restore never makes a doomed HTTP request.** + `SessionController._applyCredentialsForAccount` checks + `connectivity.isOffline` *before* calling `NextcloudService.testConnection()` + - if there's no route at all, it skips the request entirely rather than + letting it fail (fast or slow) and parsing the exception. Either way (no + route, or a network-level exception once a request is attempted - + timeout, DNS, unreachable host, a transient 5xx), the session logs in + *provisionally*: `_isLoggedIn = true` with the already-constructed + `_service` kept around, `_isProvisionalLogin = true`. Only an actual 401 + (`_errorMessage` contains "401") is treated as a real rejection - drops + the stored password and logs out for real. Provisional login exists + specifically so a genuinely offline cold start still lands on + `MainShellView` (restricted to the Offline tab - see below) instead of + bouncing to `LoginView`, which would strand the user with no way back in + short of Login Flow v2 again (`switchAccount` no-ops when "switching" to + the account that's already nominally active, and `activeAccountId` is + never cleared by a network failure). +- **Two account-activation signals, not one**, precisely so a provisional + login doesn't cascade into a pile of doomed requests from every other + controller: `addAccountActivatedListener` (fires *only* on a real, + verified login - what `FilesController`/`PhotosController`/ + `FavoritesController`/`TrashController`/`SharesController`/ + `RecentController` all register for, since their own activation work is + a network fetch) vs. `addAccountReadyListener` (fires on *either* a + verified or a provisional login - what `SyncStatusController`/ + `OfflineController` register for instead, since their own activation + work - loading prefs, listing local files, calling the native sync + MethodChannel - is local/native-only and safe with no connection at + all). A provisional login fires only `ready`, never `activated`; a + verified login fires both. +- **Reconnecting re-verifies automatically.** `SessionController` listens + to `connectivity` itself; the moment it flips from offline to online + while `_isProvisionalLogin` is still true, it re-runs + `_applyCredentialsForAccount` with the same cached account/password. On + success this is what finally fires `_notifyAccountActivated` for real, + so Files/Photos/etc. get their first actual fetch without the user + having to force-quit/restart the app. +- **`MainShellView` collapses the bottom nav to just the Offline tab** + while `connectivity.isOffline`, the same override mechanism already used + for picking mode (see `architecture.md`) - every other tab would just + show its own loading spinner or error state with no connection, so + there's nothing useful to switch to. `MoreTabsButton` hides entirely in + this state too, for the same reason it hides while picking - there's no + hidden tab it could usefully open either. +- **The sync header's "Offline" label wins over everything else.** + `_syncHeaderDisplay`/`_syncSummary` (`synced_header_scaffold.dart`) both + take a `bool isOffline` and check it first, ahead of conflicts/syncing/ + configured-targets - with no connection, *why* nothing's syncing right + now matters more than what would otherwise be shown, so "Sync off" + (nothing configured) and "Offline" (nothing *can* sync right now, + regardless of configuration) stay distinct messages. +- **`FileViewerScreen` can read a file straight from disk.** Its optional + `localPathResolver` param (`Future Function(NextcloudItem)`, + only ever set by the Offline tab, passing `OfflineController. + localPathFor`) swaps every preview widget's data source - + `_ImagePreview`/`_VideoPreview` use `Image.file`/ + `VideoPlayerController.file` instead of the `.network`/`.networkUrl` + variants, `_PdfPreview`/`_TextPreview` read via + `File(path).readAsBytes()` instead of `NextcloudService.fetchBytes` - + same in-app viewer either way, no separate "offline preview" screen. + Unlike a single up-front path, a *resolver* is what makes `siblings` + (swipe-between-media) behave identically to Files/Photos while offline: + the swipeable `PageView.builder` calls it again for whichever sibling + you've swiped to (wrapped in a `FutureBuilder`, since each resolution + is an async disk check), not just the item the viewer opened on - the + Offline tab passes its whole current folder's `items` as `siblings`, + same as Files does with its own. The action bar hides Favorite/Delete/ + Download-to-device (`showServerActions: false`) since those need a live + server - Share and Open-externally still work (`_openExternally` calls + the resolver directly instead of downloading to a temp file first; + Share already prefers a local copy when one exists, see + `ShareSheet._shareFileDirectly`). + ## Multi-account storage & session persistence [`AccountStore`](../../lib/services/account_store.dart) owns everything diff --git a/.claude/context/standards.md b/.claude/context/standards.md index 0d22ae5..01d8b21 100644 --- a/.claude/context/standards.md +++ b/.claude/context/standards.md @@ -62,9 +62,13 @@ class/method already makes obvious. ## Testing - Widget tests must mock platform channels that the app touches on startup - — `SharedPreferences.setMockInitialValues({})` and a mock - `MethodChannel('plugins.it_nomads.com/flutter_secure_storage')` handler — - and disable Google Fonts network fetching + — `SharedPreferences.setMockInitialValues({})`, a mock + `MethodChannel('plugins.it_nomads.com/flutter_secure_storage')` handler, + and `connectivity_plus`'s `MethodChannel('dev.fluttercommunity.plus/ + connectivity')` (`'check'` → a result list, e.g. `['wifi']`) plus its + `EventChannel('dev.fluttercommunity.plus/connectivity_status')` (a + `MockStreamHandler.inline` with a no-op `onListen` is enough) — and + disable Google Fonts network fetching (`GoogleFonts.config.allowRuntimeFetching = false`) in `setUpAll`. Without these, `SessionController`'s session restore never resolves in the test sandbox (no plugin implementation is registered, so the read future just diff --git a/.claude/context/styling.md b/.claude/context/styling.md index f7f484b..ca0bd3b 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -36,6 +36,15 @@ widgets. Key points: ## Reusable chrome +- `MediaGridTile` (`lib/widgets/media_grid_tile.dart`): the full-bleed image/video + grid card with name/size scrim, shared by Files (server preview) and Offline + (local `FileImage`, images only - no video frame-extraction plugin, so offline + videos use the plain icon card). `ItemThumbnail` takes an optional `localFile` + for the same offline-image case in list tiles. +- `FilesControlsRow` (`lib/widgets/files_controls_row.dart`): the + sort/hidden/scope/type-filter/view-mode row, shared by the Files and + Offline tabs (`showStorageScope: false` for Offline). + - [`getItemIcon`/`getIconColor`/`ItemThumbnail`](../../lib/widgets/item_icon.dart) — the icon/color/thumbnail treatment for a file or folder, shared by any screen that lists `NextcloudItem`s the way the Files tab does (currently @@ -61,7 +70,7 @@ widgets. Key points: pull new adjustable visual knobs from `SettingsController` the same way rather than hardcoding them. - [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — - the pull-to-sync `CustomScrollView` header shared by 5 of the 6 tabs (see + the pull-to-sync `CustomScrollView` header shared by all 8 tabs (see `architecture.md`); also where the pull-to-refresh gesture thresholds and the classic Material refresh spinner live. Its persistent compact chip (icon + "Sync off"/"Syncing…"/"Synced"/"Sync issue") reflects device-sync @@ -70,10 +79,12 @@ widgets. Key points: separate floating spinner bubble, so nothing was lost by handing the persistent text/icon over. The expanded panel's headline is a separate, more detailed string (`_syncSummary` in `synced_header_scaffold.dart`) - - actual folder/item counts ("2 folders & 5 items synced") rather than - just repeating the chip's generic label, which would otherwise read - "Synced" even when a folder's just been added and nothing's downloaded - yet. + counts of what's actually configured to sync ("2 folders & 1 file + synced") rather than just repeating the chip's generic label. + Deliberately doesn't add up the individual files inside a synced folder + ("1 folder synced", not "1 folder & 4 items synced") - once a folder's + synced, its file count is an implementation detail, not something the + user picked. - [`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' diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ed692ca..63789ee 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,10 @@ + + diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/ConflictResolveWorker.kt b/android/app/src/main/kotlin/dev/ayushya/noo/ConflictResolveWorker.kt index 95d4ac4..6e62482 100644 --- a/android/app/src/main/kotlin/dev/ayushya/noo/ConflictResolveWorker.kt +++ b/android/app/src/main/kotlin/dev/ayushya/noo/ConflictResolveWorker.kt @@ -7,6 +7,7 @@ import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.workDataOf import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.io.File @@ -62,6 +63,11 @@ class ConflictResolveWorker(appContext: Context, params: WorkerParameters) : } override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + // Same lock as SyncWorker: both load/mutate/save the whole state map. + SyncEngine.syncLock.withLock { performResolve() } + } + + private suspend fun performResolve(): Result = withContext(Dispatchers.IO) { val accountId = inputData.getString(KEY_ACCOUNT_ID) ?: return@withContext Result.failure() val serverUrl = inputData.getString(KEY_SERVER_URL) ?: return@withContext Result.failure() val username = inputData.getString(KEY_USERNAME) ?: return@withContext Result.failure() @@ -84,7 +90,9 @@ class ConflictResolveWorker(appContext: Context, params: WorkerParameters) : // Refresh the recorded state from the server's post-resolution // etag, so this file isn't immediately re-flagged as a conflict on // the next sync pass. - val fresh = SyncEngine.propfindSelf(serverUrl, username, authHeader, remotePath) + val fresh = runCatching { + SyncEngine.propfindSelf(serverUrl, username, authHeader, remotePath) + }.getOrNull() val state = SyncEngine.loadState(applicationContext, accountId).toMutableMap() state[fileId] = SyncEngine.FileState( relPath = relPath, diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/DownloadService.kt b/android/app/src/main/kotlin/dev/ayushya/noo/DownloadService.kt index a6660ae..0622a9b 100644 --- a/android/app/src/main/kotlin/dev/ayushya/noo/DownloadService.kt +++ b/android/app/src/main/kotlin/dev/ayushya/noo/DownloadService.kt @@ -42,7 +42,7 @@ class DownloadService : Service() { const val EXTRA_USERNAME = "username" const val EXTRA_AUTH_HEADER = "authHeader" - private const val CHANNEL_ID = "file_downloads" + private const val CHANNEL_ID = "file_downloads_v2" private const val NOTIFICATION_ID = 4301 } @@ -86,8 +86,11 @@ class DownloadService : Service() { this, CHANNEL_ID, "File downloads", - NotificationManager.IMPORTANCE_LOW, - "Progress for files downloaded from Noo", + // Default (not low) importance so a finished download actually + // alerts; the progress updates themselves stay silent below. + NotificationManager.IMPORTANCE_DEFAULT, + "Progress and completion of files downloaded from Noo", + legacyIds = listOf("file_downloads"), ) ServiceCompat.startForeground( this, @@ -250,6 +253,7 @@ class DownloadService : Service() { .setContentTitle("Downloading from Noo") .setContentText(text) .setOnlyAlertOnce(true) + .setSilent(true) .setOngoing(true) .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", cancelPendingIntent) when { diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/MainActivity.kt b/android/app/src/main/kotlin/dev/ayushya/noo/MainActivity.kt index 95fefbd..cd51686 100644 --- a/android/app/src/main/kotlin/dev/ayushya/noo/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/ayushya/noo/MainActivity.kt @@ -132,9 +132,11 @@ class MainActivity : FlutterFragmentActivity() { .setMethodCallHandler { call, result -> when (call.method) { "reschedule" -> rescheduleSyncWork(call, result) - "cancel" -> cancelSyncWork(result) + "cancel" -> cancelSyncWork(call, result) "syncNow" -> syncNow(call, result) - "getSyncStatus" -> result.success(syncStatusMap(SyncStatusBus.snapshot())) + "getSyncStatus" -> result.success( + syncStatusMap(SyncStatusBus.snapshot(), call.argument("accountId")), + ) "resolveConflict" -> resolveConflict(call, result) "removeLocalSync" -> removeLocalSync(call, result) else -> result.notImplemented() @@ -157,16 +159,31 @@ class MainActivity : FlutterFragmentActivity() { }) } - private fun syncStatusMap(status: SyncStatusBus.Status): Map { - val syncedFileIds = status.accountId?.let { + /// [accountIdOverride] is the account Dart is asking about - needed + /// because [SyncStatusBus] only learns an account once a sync pass has + /// run in this process, so on a fresh app start its `accountId` is null + /// and the durable per-file synced state (which is what actually says + /// "this file is mirrored") would otherwise be reported as empty. + private fun syncStatusMap( + status: SyncStatusBus.Status, + accountIdOverride: String? = null, + ): Map { + val accountId = accountIdOverride ?: status.accountId + // Live syncing/conflict state on the bus belongs to whichever + // account last synced - don't attribute it to a different one. + val busMatches = status.accountId == null || status.accountId == accountId + val syncedFileIds = accountId?.let { SyncEngine.loadState(applicationContext, it).keys.toList() } ?: emptyList() return mapOf( - "accountId" to status.accountId, - "syncing" to status.syncing, - "syncingFileIds" to status.syncingFileIds.toList(), + "accountId" to accountId, + "syncing" to (busMatches && status.syncing), + "syncingFileIds" to if (busMatches) status.syncingFileIds.toList() else emptyList(), "syncedFileIds" to syncedFileIds, - "conflicts" to status.conflicts.map { + "missingRoots" to (accountId?.let { + SyncEngine.loadMissingRoots(applicationContext, it).toList() + } ?: emptyList()), + "conflicts" to (if (busMatches) status.conflicts else emptyList()).map { mapOf( "accountId" to it.accountId, "fileId" to it.fileId, @@ -346,7 +363,7 @@ class MainActivity : FlutterFragmentActivity() { /// Shared arg-parsing for `reschedule`/`syncNow` - both need the same /// account/credentials/folder-list shape, just enqueue differently. - private fun syncWorkData(call: MethodCall): androidx.work.Data? { + private fun syncWorkData(call: MethodCall, force: Boolean = false): androidx.work.Data? { val accountId = call.argument("accountId") val serverUrl = call.argument("serverUrl") val username = call.argument("username") @@ -363,6 +380,8 @@ class MainActivity : FlutterFragmentActivity() { SyncWorker.KEY_USERNAME to username, SyncWorker.KEY_AUTH_HEADER to authHeader, SyncWorker.KEY_FOLDERS to foldersJson, + SyncWorker.KEY_FORCE to force, + SyncWorker.KEY_NOTIFY to (call.argument("notify") ?: true), ) } @@ -372,13 +391,26 @@ class MainActivity : FlutterFragmentActivity() { /// periodic WorkRequest's input Data/constraints are fixed at enqueue /// time and can only be changed by cancelling and re-enqueueing. private fun rescheduleSyncWork(call: MethodCall, result: MethodChannel.Result) { + val accountId = call.argument("accountId") + if (accountId == null) { + result.error("bad_args", "Missing accountId", null) + return + } val data = syncWorkData(call) val foldersJson = call.argument("folders") val wifiOnly = call.argument("wifiOnly") ?: true + // Null = periodic background sync is off (Files Cache set to + // "never"/"manual") - only foreground triggers and pull-to-refresh + // sync then. See SyncStatusController's doc comment. + val intervalMinutes = call.argument("intervalMinutes") val workManager = WorkManager.getInstance(this) + // The single shared job from before per-account scheduling. + workManager.cancelUniqueWork(SyncWorker.UNIQUE_PERIODIC_NAME) - if (data == null || foldersJson == null || JSONArray(foldersJson).length() == 0) { - workManager.cancelUniqueWork(SyncWorker.UNIQUE_PERIODIC_NAME) + if (data == null || foldersJson == null || JSONArray(foldersJson).length() == 0 || + intervalMinutes == null + ) { + workManager.cancelUniqueWork(SyncWorker.periodicNameFor(accountId)) result.success(null) return } @@ -386,23 +418,33 @@ class MainActivity : FlutterFragmentActivity() { val constraints = Constraints.Builder() .setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) .build() - // 1 hour is WorkManager's own practical floor for a "battery- - // friendly" cadence well above its hard 15-minute minimum; there's - // no per-user interval setting for this in v1. - val request = PeriodicWorkRequestBuilder(1, TimeUnit.HOURS) + // Follows the Files Cache "refresh periodically" interval. Android + // won't run periodic work more often than every 15 minutes, so + // shorter intervals are clamped up (the app's own in-foreground + // timer covers the shorter cadence while it's open). + val request = PeriodicWorkRequestBuilder( + maxOf(intervalMinutes, 15).toLong(), + TimeUnit.MINUTES, + ) .setInputData(data) .setConstraints(constraints) .build() workManager.enqueueUniquePeriodicWork( - SyncWorker.UNIQUE_PERIODIC_NAME, + SyncWorker.periodicNameFor(accountId), ExistingPeriodicWorkPolicy.UPDATE, request, ) result.success(null) } - private fun cancelSyncWork(result: MethodChannel.Result) { - WorkManager.getInstance(this).cancelUniqueWork(SyncWorker.UNIQUE_PERIODIC_NAME) + /// Cancels one account's periodic job (e.g. when it's removed). + private fun cancelSyncWork(call: MethodCall, result: MethodChannel.Result) { + val accountId = call.argument("accountId") + val workManager = WorkManager.getInstance(this) + if (accountId != null) { + workManager.cancelUniqueWork(SyncWorker.periodicNameFor(accountId)) + } + workManager.cancelUniqueWork(SyncWorker.UNIQUE_PERIODIC_NAME) result.success(null) } @@ -427,16 +469,34 @@ class MainActivity : FlutterFragmentActivity() { /// One-off immediate run (Settings' "Sync now"), independent of the /// periodic schedule. private fun syncNow(call: MethodCall, result: MethodChannel.Result) { - val data = syncWorkData(call) + // `force` = an explicit "Sync now" / newly-added path: full walk, + // visible progress, and it supersedes any in-flight run. Otherwise + // (pull-to-refresh, foreground timer, app resume) it's a quiet + // check that never interrupts a run already in progress; those may + // additionally respect the Wi-Fi-only setting (`wifiOnly` present). + val force = call.argument("force") ?: true + val data = syncWorkData(call, force) if (data == null) { result.error("bad_args", "Missing required sync arguments", null) return } - val request = OneTimeWorkRequestBuilder().setInputData(data).build() + val wifiOnly = call.argument("wifiOnly") + val builder = OneTimeWorkRequestBuilder().setInputData(data) + if (wifiOnly != null) { + builder.setConstraints( + Constraints.Builder() + .setRequiredNetworkType( + if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED, + ) + .build(), + ) + } WorkManager.getInstance(this).enqueueUniqueWork( - SyncWorker.UNIQUE_ONE_OFF_NAME, - ExistingWorkPolicy.REPLACE, - request, + // Per account so switching accounts doesn't let one account's + // run replace/keep-out another's. + SyncWorker.oneOffNameFor(call.argument("accountId")!!), + if (force) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP, + builder.build(), ) result.success(null) } diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/NooNotificationChannels.kt b/android/app/src/main/kotlin/dev/ayushya/noo/NooNotificationChannels.kt index e5e026a..f5c4d5a 100644 --- a/android/app/src/main/kotlin/dev/ayushya/noo/NooNotificationChannels.kt +++ b/android/app/src/main/kotlin/dev/ayushya/noo/NooNotificationChannels.kt @@ -18,11 +18,21 @@ object NooNotificationChannels { name: String, importance: Int, description: String, + // Old channel ids this one replaces. A channel's importance (and so + // whether it makes sound) can't be changed once it's been created - + // Android ignores later changes - so changing it means moving to a + // new id and removing the old channel. + legacyIds: List = emptyList(), ) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + legacyIds.forEach { manager.deleteNotificationChannel(it) } val channel = NotificationChannel(id, name, importance).apply { this.description = description + if (importance <= NotificationManager.IMPORTANCE_LOW) { + setSound(null, null) + enableVibration(false) + } } manager.createNotificationChannel(channel) } diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/ShareUploadService.kt b/android/app/src/main/kotlin/dev/ayushya/noo/ShareUploadService.kt index 27c16c5..569aebd 100644 --- a/android/app/src/main/kotlin/dev/ayushya/noo/ShareUploadService.kt +++ b/android/app/src/main/kotlin/dev/ayushya/noo/ShareUploadService.kt @@ -47,7 +47,7 @@ class ShareUploadService : Service() { const val EXTRA_AUTH_HEADER = "authHeader" const val EXTRA_REMOTE_FOLDER = "remoteFolder" - private const val CHANNEL_ID = "share_upload" + private const val CHANNEL_ID = "share_upload_v2" private const val NOTIFICATION_ID = 4201 } @@ -90,7 +90,9 @@ class ShareUploadService : Service() { this, CHANNEL_ID, "File uploads", - NotificationManager.IMPORTANCE_LOW, + // Default (not low) importance so a finished upload actually + // alerts; the progress updates themselves stay silent below. + NotificationManager.IMPORTANCE_DEFAULT, "Progress for files shared to Noo", ) ServiceCompat.startForeground( @@ -239,6 +241,7 @@ class ShareUploadService : Service() { .setContentTitle("Uploading to Noo") .setContentText(text) .setOnlyAlertOnce(true) + .setSilent(true) .setOngoing(true) .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", cancelPendingIntent) when { diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/SyncEngine.kt b/android/app/src/main/kotlin/dev/ayushya/noo/SyncEngine.kt index b8ba6eb..8f962ef 100644 --- a/android/app/src/main/kotlin/dev/ayushya/noo/SyncEngine.kt +++ b/android/app/src/main/kotlin/dev/ayushya/noo/SyncEngine.kt @@ -7,6 +7,7 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import kotlinx.coroutines.sync.Mutex import org.json.JSONArray import org.json.JSONObject import java.io.File @@ -48,6 +49,27 @@ object SyncEngine { val localMTime: Long, ) + /** + * What we last saw for a configured sync root when a walk of it fully + * completed cleanly: its own etag (Nextcloud propagates a change to any + * descendant up through every ancestor folder's etag, so an unchanged + * root etag means nothing beneath it changed on the server) and when + * that full walk happened. Lets a background pass answer "did anything + * change?" with one tiny Depth-0 PROPFIND per root instead of + * re-walking the whole tree every time. + */ + data class RootMarker(val etag: String, val fullWalkAt: Long) + + /** + * The server couldn't be reached or answered with an error (anything + * but a clean 404). Distinct from "the path is genuinely gone/empty": + * treating a network blip as an empty listing made every previously + * synced file look deleted server-side, and the diff then deleted the + * local copies. Callers skip the affected path for this run instead. + */ + class RemoteUnavailableException(message: String, cause: Throwable? = null) : + Exception(message, cause) + sealed class SyncAction { data class Download(val entry: RemoteEntry) : SyncAction() data class Upload(val relPath: String, val fileId: String) : SyncAction() @@ -55,6 +77,18 @@ object SyncEngine { data class Conflict(val entry: RemoteEntry, val relPath: String) : SyncAction() } + /** + * Held for the whole of any run that reads-then-rewrites a sync state + * map ([SyncWorker], [ConflictResolveWorker]). The periodic job and the + * one-off "sync now" jobs have different WorkManager names, so + * WorkManager itself happily runs them at once - and both load the + * state, mutate it, then save the *whole* map back, so whichever + * finished last silently discarded the other's updates. One process-wide + * lock serializes them (also keeps [SyncStatusBus], which only tracks + * one account at a time, coherent when several accounts are due). + */ + val syncLock = Mutex() + private const val TAG = "NooSync" private const val STATE_PREFS = "noo_sync_state" private val rfc1123 = @@ -73,6 +107,108 @@ object SyncEngine { } private fun stateKey(accountId: String) = "state_$accountId" + private fun rootsKey(accountId: String) = "roots_$accountId" + private fun rootsVersionKey(accountId: String) = "roots_version_$accountId" + private fun missingKey(accountId: String) = "missing_$accountId" + + // Bumped whenever a walk starts doing something new (e.g. mirroring + // empty folders) so markers recorded by an older version - which would + // make the next run skip the walk - are ignored once. + private const val ROOTS_VERSION = 2 + + fun loadRootMarkers(context: Context, accountId: String): MutableMap { + val prefs = context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE) + if (prefs.getInt(rootsVersionKey(accountId), 0) != ROOTS_VERSION) return mutableMapOf() + val json = prefs.getString(rootsKey(accountId), null) ?: return mutableMapOf() + val obj = JSONObject(json) + val result = mutableMapOf() + for (path in obj.keys()) { + val entry = obj.getJSONObject(path) + result[path] = RootMarker(entry.getString("etag"), entry.getLong("fullWalkAt")) + } + return result + } + + fun saveRootMarkers(context: Context, accountId: String, markers: Map) { + val obj = JSONObject() + for ((path, m) in markers) { + obj.put(path, JSONObject().apply { + put("etag", m.etag) + put("fullWalkAt", m.fullWalkAt) + }) + } + val prefs = context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE) + prefs.edit() + .putString(rootsKey(accountId), obj.toString()) + .putInt(rootsVersionKey(accountId), ROOTS_VERSION) + .apply() + } + + /** + * Configured sync roots the server reported as gone (a clean 404) - e.g. + * a synced file or folder that was deleted on the web. The app removes + * these from its synced list (there's nothing left to sync) and then + * clears them via [removeLocalSync]. + */ + fun loadMissingRoots(context: Context, accountId: String): Set { + val prefs = context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE) + return prefs.getStringSet(missingKey(accountId), emptySet())?.toSet() ?: emptySet() + } + + fun setRootMissing(context: Context, accountId: String, path: String, missing: Boolean) { + val prefs = context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE) + val current = loadMissingRoots(context, accountId).toMutableSet() + val changed = if (missing) current.add(path) else current.remove(path) + if (changed) prefs.edit().putStringSet(missingKey(accountId), current).apply() + } + + /** Creates a local directory for every remote folder, so empty folders exist on device too. */ + fun mirrorFolders(entries: List, syncRoot: File, rootRel: String) { + if (rootRel.isNotEmpty()) File(syncRoot, rootRel).mkdirs() + for (e in entries) { + if (e.isFolder) File(syncRoot, e.path.trim('/')).mkdirs() + } + } + + /** + * Removes local directories under [rootRel] that no longer exist on the + * server ([remoteFolders], relative paths) - the counterpart to + * [mirrorFolders]. Only *empty* directories are removed: by the time this + * runs the files that were inside a deleted server folder have already + * been deleted by the diff, while a directory that still holds something + * (a file this device created that hasn't synced) is left alone. Never + * removes the sync root itself. + */ + fun pruneRemovedFolders(syncRoot: File, rootRel: String, remoteFolders: Set) { + val top = if (rootRel.isEmpty()) syncRoot else File(syncRoot, rootRel) + if (!top.isDirectory) return + + fun prune(dir: File, rel: String) { + dir.listFiles()?.filter { it.isDirectory }?.forEach { child -> + prune(child, if (rel.isEmpty()) child.name else "$rel/${child.name}") + } + if (rel.isNotEmpty() && rel !in remoteFolders && dir.list().isNullOrEmpty()) { + dir.delete() + } + } + prune(top, rootRel) + } + + /** + * True if every file in [fileIds] is still on disk exactly as the sync + * state last recorded it (same size and mtime) - i.e. there's nothing + * local to upload and nothing missing to re-download. Pure local stat + * calls, no network. + */ + fun localMatchesState( + state: Map, + fileIds: Collection, + syncRoot: File, + ): Boolean = fileIds.all { id -> + val s = state[id] ?: return@all false + val f = File(syncRoot, s.relPath) + f.exists() && f.length() == s.size && f.lastModified() == s.localMTime + } fun loadState(context: Context, accountId: String): MutableMap { val prefs = context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE) @@ -161,9 +297,10 @@ object SyncEngine { return try { httpClient.newCall(request).execute().use { response -> Log.d(TAG, "PROPFIND $url -> ${response.code}") + if (response.code == 404) return emptyList() // genuinely gone if (!response.isSuccessful) { Log.w(TAG, "PROPFIND $url failed: ${response.code} ${response.body?.string()}") - return emptyList() + throw RemoteUnavailableException("PROPFIND $url -> ${response.code}") } val doc = DocumentBuilderFactory.newInstance() @@ -173,9 +310,11 @@ object SyncEngine { parsePropfindResponse(doc, username, cleanPath, skipSelf) } + } catch (e: RemoteUnavailableException) { + throw e } catch (e: Exception) { Log.e(TAG, "PROPFIND $url threw", e) - emptyList() + throw RemoteUnavailableException("PROPFIND $url threw", e) } } @@ -323,11 +462,16 @@ object SyncEngine { (localFile.lastModified() != prior.localMTime || localFile.length() != prior.size) when { - !localExists && !serverChanged -> { - // User deleted the local mirror copy themselves and the - // server hasn't changed - respect that deletion rather - // than silently re-creating it. - actions.add(SyncAction.Delete(relPath, entry.fileId)) + !localExists -> { + // State says this file was mirrored but the local copy + // is gone (stale state left over from an earlier sync of + // this path, or the file was removed outside the app). + // It's inside a path the user asked to sync, so pull it + // back down. This used to be treated as "the user + // deleted it, respect that" and emitted a Delete - which + // dropped the state, skipped the download, and reported + // phantom "N removed" for files that were never on disk. + actions.add(SyncAction.Download(entry)) } serverChanged && localChanged -> actions.add(SyncAction.Conflict(entry, relPath)) serverChanged -> actions.add(SyncAction.Download(entry)) @@ -380,6 +524,24 @@ object SyncEngine { for (fileId in toRemove) state.remove(fileId) saveState(context, accountId, state) + setRootMissing(context, accountId, "/" + cleanPath, false) + setRootMissing(context, accountId, cleanPath, false) + + // A root marker for this path (or an ancestor/descendant that also + // covers some of what was just deleted) would make the next + // background pass think nothing changed and skip re-downloading. + val removedPath = "/" + cleanPath + val markers = loadRootMarkers(context, accountId) + val staleKeys = markers.keys.filter { key -> + val k = key.trimEnd('/') + k.isEmpty() || removedPath == "/" || k == removedPath || + k.startsWith("$removedPath/") || removedPath.startsWith("$k/") + } + if (staleKeys.isNotEmpty()) { + staleKeys.forEach { markers.remove(it) } + saveRootMarkers(context, accountId, markers) + } + 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") diff --git a/android/app/src/main/kotlin/dev/ayushya/noo/SyncWorker.kt b/android/app/src/main/kotlin/dev/ayushya/noo/SyncWorker.kt index 0191d02..758a0f6 100644 --- a/android/app/src/main/kotlin/dev/ayushya/noo/SyncWorker.kt +++ b/android/app/src/main/kotlin/dev/ayushya/noo/SyncWorker.kt @@ -10,6 +10,7 @@ import androidx.core.app.NotificationCompat import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.json.JSONArray import java.io.File @@ -32,27 +33,94 @@ class SyncWorker(appContext: Context, params: WorkerParameters) : const val KEY_AUTH_HEADER = "authHeader" const val KEY_FOLDERS = "folders" // JSON array of remote file/folder paths - const val UNIQUE_PERIODIC_NAME = "noo_sync_periodic" - const val UNIQUE_ONE_OFF_NAME = "noo_sync_now" + // True for a user-initiated "Sync now": always does a full walk of + // every root (never trusts the root-etag shortcut) and shows the + // "Checking for changes..." notification immediately. False for + // periodic/automatic runs, which stay silent unless something + // actually transfers. + const val KEY_FORCE = "force" - private const val CHANNEL_ID = "device_sync" - private const val SUMMARY_NOTIFICATION_ID = 4401 - private const val CONFLICT_NOTIFICATION_ID_BASE = 4500 + // Whether automatic (non-force) runs may post progress/summary + // notifications - the "Background sync notifications" setting. + // Conflicts are always notified (they need a decision), and a + // user-initiated run (force) always shows progress. + const val KEY_NOTIFY = "notify" + + // Even when a root's etag hasn't changed, walk it in full at least + // this often: etag propagation isn't reliable everywhere (external + // storage mounts in particular), so this bounds how stale a mirror + // can get if the cheap check is ever wrong. + private const val FULL_WALK_MAX_AGE_MS = 6L * 60 * 60 * 1000 + + // Pre-multi-account name of the single shared periodic job - only + // kept so it can be cancelled when migrating to per-account jobs. + const val UNIQUE_PERIODIC_NAME = "noo_sync_periodic" + + // Each account with sync enabled gets its own periodic job (with + // that account's own credentials/paths/interval baked in), so + // background sync works for every enabled account, not just + // whichever one was active last. + fun periodicNameFor(accountId: String) = "${UNIQUE_PERIODIC_NAME}_$accountId" + fun oneOffNameFor(accountId: String) = "noo_sync_now_$accountId" + + // Low importance = no sound/vibration. Importance can't be changed + // on an existing channel, hence the new id (the old "device_sync" + // channel, which made noise, is deleted when this one is created). + private const val CHANNEL_ID = "device_sync_v2" + private const val LEGACY_CHANNEL_ID = "device_sync" + + // Notification ids are per account so two accounts syncing never + // overwrite each other's progress/summary/conflict notifications. + // Progress and the final summary share one id per account - the + // summary's notify() call naturally replaces the ongoing progress + // notification in place (no flicker of two separate notifications), + // and if there was nothing to actually sync, doWork explicitly + // cancels it since no summary gets posted to replace it. + private const val SUMMARY_NOTIFICATION_ID_BASE = 100_000 + private const val CONFLICT_NOTIFICATION_ID_BASE = 200_000 + fun summaryNotificationId(accountId: String) = + SUMMARY_NOTIFICATION_ID_BASE + (accountId.hashCode() and 0xFFFF) + fun conflictNotificationId(accountId: String, relPath: String) = + CONFLICT_NOTIFICATION_ID_BASE + ((accountId + "|" + relPath).hashCode() and 0xFFFF) private const val TAG = "NooSync" } + // totalTransfers is only known once at least one path's been diffed - + // grows across the run as each configured path is diffed in turn. + private var totalTransfers = 0 + private var completedTransfers = 0 + private var progressShown = false + + // Set at the top of performSync - the notify* helpers below need the + // account (for its notification ids / title) and whether this run may + // show progress at all. + private var notifyAccountId = "" + private var notifyUsername = "" + private var showProgressNotifications = true + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + // One sync at a time, app-wide - see SyncEngine.syncLock. A run that + // has to wait just starts once the current one finishes. + SyncEngine.syncLock.withLock { performSync() } + } + + private suspend fun performSync(): Result = withContext(Dispatchers.IO) { val accountId = inputData.getString(KEY_ACCOUNT_ID) ?: return@withContext Result.failure() val serverUrl = inputData.getString(KEY_SERVER_URL) ?: return@withContext Result.failure() val username = inputData.getString(KEY_USERNAME) ?: return@withContext Result.failure() val authHeader = inputData.getString(KEY_AUTH_HEADER) ?: return@withContext Result.failure() val foldersJson = inputData.getString(KEY_FOLDERS) ?: "[]" + val force = inputData.getBoolean(KEY_FORCE, false) + notifyAccountId = accountId + notifyUsername = username + showProgressNotifications = force || inputData.getBoolean(KEY_NOTIFY, true) val folders = (0 until JSONArray(foldersJson).length()).map { JSONArray(foldersJson).getString(it) } Log.d(TAG, "doWork: account=$accountId server=$serverUrl user=$username folders=$folders") if (folders.isEmpty()) return@withContext Result.success() val syncRoot = SyncEngine.syncRoot(applicationContext, accountId) val state = SyncEngine.loadState(applicationContext, accountId).toMutableMap() + val markers = SyncEngine.loadRootMarkers(applicationContext, accountId) var downloaded = 0 var uploaded = 0 @@ -60,81 +128,180 @@ class SyncWorker(appContext: Context, params: WorkerParameters) : val conflicts = mutableListOf>() SyncStatusBus.setSyncing(accountId, true) + // For a user-initiated run, shown immediately rather than lazily on + // the first transfer - the PROPFIND walk/diff below (per configured + // path, potentially recursive) can itself take real time, and the + // user just asked for a sync so they expect to see it happening. + // Automatic runs stay silent until something actually transfers. + if (force) notifyProgress("Checking for changes…") try { for (path in folders) { - // A configured path can be a file or a folder now - check - // which before deciding whether to walk it recursively or - // just diff the single item. - val self = SyncEngine.propfindSelf(serverUrl, username, authHeader, path) - val entries = when { - self == null -> emptyList() - self.isFolder -> SyncEngine.walkRemoteTree(serverUrl, username, authHeader, path) - else -> listOf(self) - } - val pathPrefix = path.trimEnd('/') + "/" - val priorFileIdsForPath = state.filterValues { - it.relPath == path.trimStart('/') || it.relPath.startsWith(pathPrefix.trimStart('/')) - }.keys + try { + // A configured path can be a file or a folder now - check + // which before deciding whether to walk it recursively or + // just diff the single item. + val self = SyncEngine.propfindSelf(serverUrl, username, authHeader, path) + val pathPrefix = path.trimEnd('/') + "/" + val priorFileIdsForPath = state.filterValues { + it.relPath == path.trimStart('/') || it.relPath.startsWith(pathPrefix.trimStart('/')) + }.keys - val actions = SyncEngine.diffFolder(entries, state, priorFileIdsForPath, syncRoot) - for (action in actions) { - when (action) { - is SyncEngine.SyncAction.Download -> { - SyncStatusBus.markFileSyncing(accountId, action.entry.fileId, true) - val relPath = action.entry.path.removePrefix("/") - val dest = File(syncRoot, relPath) - if (SyncEngine.downloadFile(serverUrl, username, authHeader, action.entry.path, dest)) { - state[action.entry.fileId] = SyncEngine.FileState( - relPath = relPath, - etag = action.entry.etag, - lastModified = action.entry.lastModified, - size = action.entry.size, - localMTime = dest.lastModified(), - ) - downloaded++ - } - SyncStatusBus.markFileSyncing(accountId, action.entry.fileId, false) - } - is SyncEngine.SyncAction.Upload -> { - SyncStatusBus.markFileSyncing(accountId, action.fileId, true) - val localFile = File(syncRoot, action.relPath) - val remotePath = "/${action.relPath}" - if (localFile.exists() && - SyncEngine.uploadFile(serverUrl, username, authHeader, remotePath, localFile) - ) { - val prior = state[action.fileId] - state[action.fileId] = SyncEngine.FileState( - relPath = action.relPath, - etag = prior?.etag ?: "", - lastModified = localFile.lastModified(), - size = localFile.length(), - localMTime = localFile.lastModified(), - ) - uploaded++ - } - SyncStatusBus.markFileSyncing(accountId, action.fileId, false) - } - is SyncEngine.SyncAction.Delete -> { - File(syncRoot, action.relPath).delete() - state.remove(action.fileId) - deleted++ - } - is SyncEngine.SyncAction.Conflict -> conflicts.add(action.entry to action.relPath) + // Cheap "did anything change?" check: an unchanged root etag + // means nothing beneath it changed on the server, and if + // every local file is also still exactly as recorded there's + // nothing to upload or re-download either - so skip the walk + // (one Depth-0 PROPFIND instead of one per subfolder). + val marker = markers[path] + if (!force && self != null && self.isFolder && marker != null && + marker.etag == self.etag && + System.currentTimeMillis() - marker.fullWalkAt < FULL_WALK_MAX_AGE_MS && + priorFileIdsForPath.isNotEmpty() && + SyncEngine.localMatchesState(state, priorFileIdsForPath, syncRoot) + ) { + Log.d(TAG, "skip $path: server etag unchanged, local intact") + continue } + + // The server says this synced path no longer exists (a + // clean 404, not a network error): flag it so the app drops + // it from the synced list instead of leaving a dead entry. + SyncEngine.setRootMissing(applicationContext, accountId, path, self == null) + + val entries = when { + self == null -> emptyList() + self.isFolder -> SyncEngine.walkRemoteTree(serverUrl, username, authHeader, path) + else -> listOf(self) + } + var pathClean = true + + val actions = SyncEngine.diffFolder(entries, state, priorFileIdsForPath, syncRoot) + totalTransfers += actions.count { + it is SyncEngine.SyncAction.Download || it is SyncEngine.SyncAction.Upload + } + for (action in actions) { + when (action) { + is SyncEngine.SyncAction.Download -> { + val relPath = action.entry.path.removePrefix("/") + notifyProgress(relPath.substringAfterLast('/')) + SyncStatusBus.markFileSyncing(accountId, action.entry.fileId, true) + val dest = File(syncRoot, relPath) + if (SyncEngine.downloadFile(serverUrl, username, authHeader, action.entry.path, dest)) { + state[action.entry.fileId] = SyncEngine.FileState( + relPath = relPath, + etag = action.entry.etag, + lastModified = action.entry.lastModified, + size = action.entry.size, + localMTime = dest.lastModified(), + ) + downloaded++ + } else { + pathClean = false + } + SyncStatusBus.markFileSyncing(accountId, action.entry.fileId, false) + completedTransfers++ + } + is SyncEngine.SyncAction.Upload -> { + notifyProgress(action.relPath.substringAfterLast('/')) + SyncStatusBus.markFileSyncing(accountId, action.fileId, true) + val localFile = File(syncRoot, action.relPath) + val remotePath = "/${action.relPath}" + if (localFile.exists() && + SyncEngine.uploadFile(serverUrl, username, authHeader, remotePath, localFile) + ) { + val prior = state[action.fileId] + state[action.fileId] = SyncEngine.FileState( + relPath = action.relPath, + etag = prior?.etag ?: "", + lastModified = localFile.lastModified(), + size = localFile.length(), + localMTime = localFile.lastModified(), + ) + uploaded++ + } else { + pathClean = false + } + SyncStatusBus.markFileSyncing(accountId, action.fileId, false) + completedTransfers++ + } + is SyncEngine.SyncAction.Delete -> { + // Only counts toward the "removed" summary if a + // local copy actually existed and was deleted - + // dropping stale state for a file that was never + // on disk isn't a removal the user should hear + // about. + val localFile = File(syncRoot, action.relPath) + if (localFile.exists() && localFile.delete()) deleted++ + state.remove(action.fileId) + } + is SyncEngine.SyncAction.Conflict -> { + conflicts.add(action.entry to action.relPath) + pathClean = false + } + } + } + + // Mirror the folder structure itself: empty folders + // created on the server appear on device, and folders + // deleted on the server (whose files the diff above has + // already removed) don't linger as empty directories. + if (self == null || self.isFolder) { + val rootRel = path.trim('/') + if (self != null) SyncEngine.mirrorFolders(entries, syncRoot, rootRel) + val remoteFolders = entries.filter { it.isFolder } + .map { it.path.trim('/') } + .toMutableSet() + if (self != null) remoteFolders.add(rootRel) + SyncEngine.pruneRemovedFolders(syncRoot, rootRel, remoteFolders) + } + + // Only remember this root's etag once the whole path synced + // without a hitch - anything left over (a failed transfer, an + // unresolved conflict) has to be retried by a real walk next + // time, not skipped because the etag "hasn't changed". + if (self != null && self.isFolder && pathClean) { + markers[path] = SyncEngine.RootMarker(self.etag, System.currentTimeMillis()) + } else { + markers.remove(path) + } + } catch (e: SyncEngine.RemoteUnavailableException) { + // Couldn't reach the server for this path - leave its state + // and marker untouched and try again next run. Crucially, + // never diff against an empty listing here: that would look + // like the whole folder was deleted server-side. + Log.w(TAG, "skipping $path this run: ${e.message}") } } } finally { + markers.keys.retainAll(folders.toSet()) + SyncEngine.saveRootMarkers(applicationContext, accountId, markers) + // State must be saved *before* announcing that syncing has + // stopped - `syncStatusMap` (MainActivity.kt) recomputes + // `syncedFileIds` fresh from `SyncEngine.loadState` every time + // this bus publishes, so publishing first would hand Dart a + // snapshot that's still missing every file this run just + // downloaded, and nothing would ever correct it afterward + // (no further bus event fires post-save) - newly-synced files + // would never show their "synced" badge until the next app + // restart forced a fresh `getStatus()` read. + SyncEngine.saveState(applicationContext, accountId, state) SyncStatusBus.setSyncing(accountId, false) } - SyncEngine.saveState(applicationContext, accountId, state) Log.d( TAG, "doWork done: downloaded=$downloaded uploaded=$uploaded deleted=$deleted conflicts=${conflicts.size}", ) - if (downloaded > 0 || uploaded > 0 || deleted > 0) { + if ((downloaded > 0 || uploaded > 0 || deleted > 0) && showProgressNotifications) { + // Replaces the ongoing progress notification in place (same + // ID) with a final, dismissible summary. notifySummary(downloaded, uploaded, deleted) + } else if (progressShown) { + // Nothing actually transferred (e.g. every diffed action was a + // Delete/Conflict, or the progress notification was shown for a + // run that ended up empty) - nothing to replace it with, so + // just clear it rather than leaving a stale "Syncing…" behind. + manager().cancel(summaryNotificationId(accountId)) } if (conflicts.isNotEmpty()) { SyncStatusBus.addConflicts( @@ -155,6 +322,39 @@ class SyncWorker(appContext: Context, params: WorkerParameters) : Result.success() } + /// Called once immediately at the start of every run that actually has + /// folders configured (covers the PROPFIND walk/diff phase, which can + /// itself take real time), then again per file as transfers happen. + /// `totalTransfers` grows as each configured path is diffed (paths are + /// diffed one at a time), so early on this shows an indeterminate bar; + /// it becomes determinate once the true total for this run is known. A + /// run that turns out to have nothing to transfer cancels this + /// notification at the end instead of leaving it stuck (see the + /// `progressShown` check in `doWork`). + private fun notifyProgress(fileName: String) { + if (!showProgressNotifications) return + progressShown = true + createChannel() + val text = if (totalTransfers > 1) { + "$fileName (${completedTransfers + 1}/$totalTransfers)" + } else { + fileName + } + val builder = NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_notify_sync) + .setContentTitle("Syncing to device · $notifyUsername") + .setContentText(text) + .setOnlyAlertOnce(true) + .setSilent(true) + .setOngoing(true) + if (totalTransfers > 0) { + builder.setProgress(totalTransfers, completedTransfers, false) + } else { + builder.setProgress(0, 0, true) + } + manager().notify(summaryNotificationId(notifyAccountId), builder.build()) + } + private fun notifySummary(downloaded: Int, uploaded: Int, deleted: Int) { createChannel() val parts = mutableListOf() @@ -165,11 +365,12 @@ class SyncWorker(appContext: Context, params: WorkerParameters) : val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) .setSmallIcon(android.R.drawable.stat_notify_sync) - .setContentTitle("Noo sync") + .setContentTitle("Noo sync · $notifyUsername") .setContentText(text) .setAutoCancel(true) + .setSilent(true) .build() - manager().notify(SUMMARY_NOTIFICATION_ID, notification) + manager().notify(summaryNotificationId(notifyAccountId), notification) } private fun notifyConflicts( @@ -183,7 +384,7 @@ class SyncWorker(appContext: Context, params: WorkerParameters) : for ((index, conflict) in conflicts.withIndex()) { val (entry, relPath) = conflict val fileName = relPath.substringAfterLast('/') - val notificationId = CONFLICT_NOTIFICATION_ID_BASE + (relPath.hashCode() and 0xFFFF) + val notificationId = conflictNotificationId(accountId, relPath) val useLocalIntent = conflictActionIntent( accountId, @@ -211,8 +412,9 @@ class SyncWorker(appContext: Context, params: WorkerParameters) : val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) .setSmallIcon(android.R.drawable.stat_notify_error) .setContentTitle("Sync conflict: $fileName") - .setContentText("Changed both on this device and on the server.") + .setContentText("$username - changed both on this device and on the server.") .setAutoCancel(true) + .setSilent(true) .addAction(0, "Keep local", useLocalIntent) .addAction(0, "Use server", useServerIntent) .build() @@ -245,7 +447,7 @@ class SyncWorker(appContext: Context, params: WorkerParameters) : } // Request code must be unique per (file, resolution) pair, else the // two actions' PendingIntents collide and only one survives. - val requestCode = (relPath + resolution).hashCode() + val requestCode = (accountId + relPath + resolution).hashCode() return PendingIntent.getBroadcast( applicationContext, requestCode, @@ -262,8 +464,9 @@ class SyncWorker(appContext: Context, params: WorkerParameters) : applicationContext, CHANNEL_ID, "Device sync", - NotificationManager.IMPORTANCE_DEFAULT, + NotificationManager.IMPORTANCE_LOW, "Updates and conflicts for folders synced to this device", + legacyIds = listOf(LEGACY_CHANNEL_ID), ) } } diff --git a/lib/main.dart b/lib/main.dart index 3892de1..3be9d34 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,9 +6,11 @@ import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'models/app_tab.dart'; import 'models/pick_request.dart'; +import 'providers/connectivity_controller.dart'; import 'providers/favorites_controller.dart'; import 'providers/files_controller.dart'; import 'providers/item_operations.dart'; +import 'providers/offline_controller.dart'; import 'providers/photos_controller.dart'; import 'providers/pick_controller.dart'; import 'providers/recent_controller.dart'; @@ -38,14 +40,26 @@ void main() { // replaced/recreated for the app's lifetime (account switching is // internal state on SessionController, not a new provider instance). providers: [ - ChangeNotifierProvider(create: (_) => SessionController()), - ChangeNotifierProvider(create: (_) => SettingsController()), + ChangeNotifierProvider(create: (_) => ConnectivityController()), ChangeNotifierProvider( - create: (context) => SyncStatusController(context.read()), + create: (context) => SessionController(context.read()), ), + ChangeNotifierProvider(create: (_) => SettingsController()), + // Before SyncStatusController, which follows its Files Cache rule + // for how often synced files refresh. ChangeNotifierProvider( create: (context) => FilesController(context.read()), ), + ChangeNotifierProvider( + // Eager (`lazy: false`) - this controller only loads its persisted + // state when SessionController fires the account-ready event, and + // that fires once at startup, before any widget has read this + // provider. Lazily created, it would register its listener too + // late and stay empty ("Sync off") until an account switch. + lazy: false, + create: (context) => + SyncStatusController(context.read(), context.read()), + ), ChangeNotifierProvider( create: (context) => PhotosController(context.read(), context.read()), @@ -66,6 +80,12 @@ void main() { ChangeNotifierProvider( create: (context) => PickController(context.read()), ), + ChangeNotifierProvider( + // Eager for the same reason as SyncStatusController above. + lazy: false, + create: (context) => + OfflineController(context.read(), context.read()), + ), Provider( create: (context) => ItemOperations( session: context.read(), @@ -276,6 +296,7 @@ class _MainShellViewState extends State { Widget build(BuildContext context) { final settings = context.watch(); final pick = context.watch(); + final connectivity = context.watch(); // A one-shot request (e.g. tapping a search result) to switch tabs - // consumed here so it only fires once, then cleared after this frame @@ -294,9 +315,15 @@ class _MainShellViewState extends State { // real "pick a file from here" destinations, and only these two tabs' // views even know how to handle picking-mode taps. This overrides the // user's own hidden/reordered tab settings rather than respecting - // them, since picking is a separate mode from normal browsing. + // them, since picking is a separate mode from normal browsing. With + // no network at all, every other tab would just show its own loading + // spinner forever/an error state, so the bottom nav collapses to just + // Offline - the one tab that works without a connection (see + // `FilesView(offline: true)`/`ConnectivityController`). final visible = pickRequest != null ? [AppTab.files, AppTab.photos] + : connectivity.isOffline + ? [AppTab.offline] : settings.visibleTabs; final selectedTab = visible.contains(_currentTab) ? _currentTab diff --git a/lib/models/app_tab.dart b/lib/models/app_tab.dart index 7a8ccb0..c4295c0 100644 --- a/lib/models/app_tab.dart +++ b/lib/models/app_tab.dart @@ -4,7 +4,16 @@ import 'package:material_symbols_icons/symbols.dart'; /// Every destination the bottom nav bar can show. Order here is only the /// fallback default — actual order/visibility/default-tab are user /// configurable and persisted in `SettingsController`. -enum AppTab { files, photos, favorites, activity, trash, shares, recent } +enum AppTab { + files, + photos, + favorites, + activity, + trash, + shares, + recent, + offline, +} /// At most this many tabs may be visible in the bottom nav bar at once — /// the rest are reachable through the "more" dropdown instead. @@ -27,6 +36,8 @@ extension AppTabInfo on AppTab { return 'Shares'; case AppTab.recent: return 'Recent'; + case AppTab.offline: + return 'Offline'; } } @@ -46,6 +57,8 @@ extension AppTabInfo on AppTab { return Icons.groups_rounded; case AppTab.recent: return Symbols.search_activity_rounded; + case AppTab.offline: + return Icons.offline_pin_rounded; } } } diff --git a/lib/providers/connectivity_controller.dart b/lib/providers/connectivity_controller.dart new file mode 100644 index 0000000..b8a18a7 --- /dev/null +++ b/lib/providers/connectivity_controller.dart @@ -0,0 +1,53 @@ +import 'dart:async'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/foundation.dart'; + +/// Whether the device currently has *any* network route (Wi-Fi, mobile +/// data, ethernet - not a guarantee the Nextcloud server itself is +/// reachable, just OS-level connectivity). The single source of truth +/// other controllers/views consult before attempting a network request or +/// deciding whether to fall back to offline-only behavior - see +/// `SessionController` (skips its login-restore HTTP call entirely while +/// offline) and `MainShellView` (restricts the bottom nav to just the +/// Offline tab while offline). +class ConnectivityController extends ChangeNotifier { + bool _isOffline = false; + StreamSubscription>? _sub; + Timer? _recheckTimer; + + bool get isOffline => _isOffline; + + ConnectivityController() { + Connectivity().checkConnectivity().then(_apply); + _sub = Connectivity().onConnectivityChanged.listen(_apply); + // The very first check can spuriously report "no network" while the + // OS's connectivity stack is still attaching callbacks to this + // just-started process - a real Android cold-start quirk, not a + // genuine transition. That matters here specifically because + // onConnectivityChanged only fires on actual transitions, so a false + // initial "offline" read would otherwise stick for the rest of the + // session (SessionController would keep treating every login as + // provisional, and every network-fetching controller would never get + // its real activation signal - see addAccountReadyListener's doc + // comment) even though the device was online the whole time. Re- + // verify once, shortly after, to correct it. + _recheckTimer = Timer(const Duration(seconds: 2), () { + Connectivity().checkConnectivity().then(_apply); + }); + } + + void _apply(List results) { + final offline = + results.isEmpty || results.every((r) => r == ConnectivityResult.none); + if (offline == _isOffline) return; + _isOffline = offline; + notifyListeners(); + } + + @override + void dispose() { + _sub?.cancel(); + _recheckTimer?.cancel(); + super.dispose(); + } +} diff --git a/lib/providers/files_controller.dart b/lib/providers/files_controller.dart index 5502119..71d2f78 100644 --- a/lib/providers/files_controller.dart +++ b/lib/providers/files_controller.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/nextcloud_item.dart'; +import 'folder_browser.dart'; import 'session_controller.dart'; enum FileSortField { name, dateCreated, dateModified, size } @@ -20,7 +21,8 @@ enum FilesTypeFilter { all, filesOnly, foldersOnly } /// content - just the list of names/sizes/dates) are reused across /// navigation instead of refetched from the server every time. enum CachePolicy { - /// Every navigation refetches - today's behavior. + /// Every navigation refetches. Synced files are checked each time the app + /// opens (and on pull-to-refresh), but not on a background schedule. never, /// A listing is reused until it's older than [FilesController. @@ -29,9 +31,17 @@ enum CachePolicy { interval, /// A listing is reused indefinitely until the user pulls to refresh. + /// Synced files likewise only update on pull-to-refresh / "Sync now". manual, } +/// New accounts (and anyone who's never touched the setting) refresh +/// periodically - the Files Cache rule also drives how often synced +/// folders/files are brought in line with the server, see +/// `SyncStatusController`. +const defaultCachePolicy = CachePolicy.interval; +const defaultCacheIntervalMinutes = 15; + /// One cached folder listing and when it was fetched. class _CachedDirectory { final List items; @@ -52,7 +62,9 @@ class _CachedDirectory { /// comments for why they depend on this one. /// /// Split out of the former single `ServerProvider` god object. -class FilesController extends ChangeNotifier with WidgetsBindingObserver { +class FilesController extends ChangeNotifier + with WidgetsBindingObserver + implements FolderBrowser { final SessionController session; static const _prefGridView = 'ui_grid_view'; @@ -60,8 +72,8 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver { static const _prefFilesTypeFilter = 'ui_files_type_filter'; static const _prefShowHiddenFiles = 'ui_show_hidden'; static const _prefFolderSort = 'ui_folder_sort'; - static const _prefCachePolicy = 'ui_cache_policy'; - static const _prefCacheIntervalMinutes = 'ui_cache_interval_minutes'; + static const prefCachePolicy = 'ui_cache_policy'; + static const prefCacheIntervalMinutes = 'ui_cache_interval_minutes'; String _currentFolderPath = '/'; List _pathStack = ['/']; @@ -74,11 +86,16 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver { final Map _folderSortField = {}; final Map _folderSortAscending = {}; - CachePolicy _cachePolicy = CachePolicy.never; - int _cacheIntervalMinutes = 5; + CachePolicy _cachePolicy = defaultCachePolicy; + int _cacheIntervalMinutes = defaultCacheIntervalMinutes; final Map _directoryCache = {}; Timer? _cacheRefreshTimer; + // Auto-retry of a failed (not in-place) load that looks like a network + // hiccup - see [_scheduleNetworkRetry]. + Timer? _retryTimer; + int _retryAttempt = 0; + List _items = []; NextcloudUserQuota? _quota; List _activities = []; @@ -89,26 +106,37 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver { WidgetsBinding.instance.addObserver(this); session.addAccountClearedListener(_onAccountCleared); session.addAccountActivatedListener(_onAccountActivated); + session.addAccountReadyListener(_restoreDisplayPrefs); } // Getters + @override String get currentFolderPath => _currentFolderPath; + @override List get pathStack => _pathStack; bool get isGridView => _isGridView; StorageScope get storageScope => _storageScope; FilesTypeFilter get filesTypeFilter => _filesTypeFilter; bool get showHiddenFiles => _showHiddenFiles; - FileSortField get filesSortField => - _folderSortField[_currentFolderPath] ?? FileSortField.name; - bool get filesSortAscending => - _folderSortAscending[_currentFolderPath] ?? true; + FileSortField get filesSortField => sortFieldFor(_currentFolderPath); + bool get filesSortAscending => sortAscendingFor(_currentFolderPath); + + /// Per-folder sort prefs keyed by remote path - also what the Offline tab + /// reads for the same path in the local mirror, so a folder sorts the + /// same in both places. + FileSortField sortFieldFor(String path) => + _folderSortField[path] ?? FileSortField.name; + bool sortAscendingFor(String path) => _folderSortAscending[path] ?? true; CachePolicy get cachePolicy => _cachePolicy; int get cacheIntervalMinutes => _cacheIntervalMinutes; + @override bool get isLoading => _isLoading; + @override String? get errorMessage => _errorMessage; NextcloudUserQuota? get quota => _quota; List get activities => _activities; + @override List get items => applyFilesDisplayPrefs(_items); int _compareItems(NextcloudItem a, NextcloudItem b, FileSortField field) { @@ -140,6 +168,7 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver { List source, { required bool showFavoritesOnly, required bool showHidden, + bool applyStorageScope = true, }) { var filtered = source; if (showFavoritesOnly) { @@ -148,13 +177,15 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver { if (!showHidden) { filtered = filtered.where((item) => !_isHiddenItem(item)).toList(); } - filtered = filtered - .where( - (item) => _storageScope == StorageScope.external - ? item.isExternalStorage - : !item.isExternalStorage, - ) - .toList(); + if (applyStorageScope) { + filtered = filtered + .where( + (item) => _storageScope == StorageScope.external + ? item.isExternalStorage + : !item.isExternalStorage, + ) + .toList(); + } return filtered; } @@ -165,13 +196,22 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver { /// listings via [fetchFolderListing] rather than reading [items] itself) /// can render with the exact same controls/behavior as the Files tab /// without duplicating this logic. - List applyFilesDisplayPrefs(List rawItems) { + /// + /// [folderPath] picks which folder's sort prefs apply (default: the + /// Files tab's current folder). [applyStorageScope] is off for the + /// Offline tab, whose local mirror has no cloud/external distinction. + List applyFilesDisplayPrefs( + List rawItems, { + String? folderPath, + bool applyStorageScope = true, + }) { var filtered = applyCommonFilters( rawItems, // Files itself doesn't filter by favorite - that's the dedicated // Favorites tab's job. showFavoritesOnly: false, showHidden: _showHiddenFiles, + applyStorageScope: applyStorageScope, ); switch (_filesTypeFilter) { case FilesTypeFilter.all: @@ -182,17 +222,21 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver { filtered = filtered.where((i) => i.isFolder).toList(); } - final field = filesSortField; + final path = folderPath ?? _currentFolderPath; + final field = sortFieldFor(path); final folders = filtered.where((i) => i.isFolder).toList() ..sort((a, b) => _compareItems(a, b, field)); final files = filtered.where((i) => !i.isFolder).toList() ..sort((a, b) => _compareItems(a, b, field)); - return filesSortAscending + return sortAscendingFor(path) ? [...folders, ...files] : [...folders.reversed, ...files.reversed]; } void _onAccountCleared() { + _prefsRestore = null; + _retryTimer?.cancel(); + _retryAttempt = 0; _cacheRefreshTimer?.cancel(); _directoryCache.clear(); _folderSortField.clear(); @@ -206,61 +250,97 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver { _storageScope = StorageScope.cloud; _filesTypeFilter = FilesTypeFilter.all; _showHiddenFiles = false; - _cachePolicy = CachePolicy.never; - _cacheIntervalMinutes = 5; + _cachePolicy = defaultCachePolicy; + _cacheIntervalMinutes = defaultCacheIntervalMinutes; _isLoading = false; _errorMessage = null; notifyListeners(); } - Future _onAccountActivated() async { + // Memoized per account (reset in _onAccountCleared) so the ready-event + // and activated-event paths below share one restore instead of racing + // two reads of the same prefs. + Future? _prefsRestore; + + /// Loads this account's display prefs (grid/list, hidden, type filter, + /// sort, cache policy) - hooked to `addAccountReadyListener` as well as + /// activation so the Offline tab, which reads these same prefs, respects + /// them even on a provisional/offline login where activation never fires. + Future _restoreDisplayPrefs() => _prefsRestore ??= _doRestoreDisplayPrefs(); + + /// Completes once this account's cache policy/display prefs have been + /// loaded from storage - lets `SyncStatusController` schedule background + /// sync from the user's real policy instead of the defaults. + Future get displayPrefsLoaded => _restoreDisplayPrefs(); + + Future _doRestoreDisplayPrefs() async { final id = session.activeAccountId; if (id != null) { - final prefs = await session.prefsFuture; - String k(String base) => session.accountStore.accountPrefKey(id, base); + // The whole prefs-restore block is wrapped, not just the JSON + // decode below - a single bad/mistyped stored value here (an + // unguarded `prefs.getBool`/`getString`/`getInt` throws if the + // key holds a different runtime type than requested) would + // otherwise propagate uncaught out of this method entirely, + // silently skipping `refreshData()` below and leaving the Files + // tab permanently empty on this activation with no error surfaced + // anywhere - exactly what happened before this was guarded. + try { + final prefs = await session.prefsFuture; + String k(String base) => session.accountStore.accountPrefKey(id, base); - _isGridView = prefs.getBool(k(_prefGridView)) ?? false; - final storageScopeName = prefs.getString(k(_prefStorageScope)); - _storageScope = StorageScope.values.firstWhere( - (s) => s.name == storageScopeName, - orElse: () => StorageScope.cloud, - ); - final filesTypeFilterName = prefs.getString(k(_prefFilesTypeFilter)); - _filesTypeFilter = FilesTypeFilter.values.firstWhere( - (f) => f.name == filesTypeFilterName, - orElse: () => FilesTypeFilter.all, - ); - _showHiddenFiles = prefs.getBool(k(_prefShowHiddenFiles)) ?? false; + _isGridView = prefs.getBool(k(_prefGridView)) ?? false; + final storageScopeName = prefs.getString(k(_prefStorageScope)); + _storageScope = StorageScope.values.firstWhere( + (s) => s.name == storageScopeName, + orElse: () => StorageScope.cloud, + ); + final filesTypeFilterName = prefs.getString(k(_prefFilesTypeFilter)); + _filesTypeFilter = FilesTypeFilter.values.firstWhere( + (f) => f.name == filesTypeFilterName, + orElse: () => FilesTypeFilter.all, + ); + _showHiddenFiles = prefs.getBool(k(_prefShowHiddenFiles)) ?? false; - final folderSortJson = prefs.getString(k(_prefFolderSort)); - if (folderSortJson != null) { - try { - final decoded = jsonDecode(folderSortJson) as Map; - for (final entry in decoded.entries) { - final value = entry.value as Map; - final fieldName = value['field'] as String?; - if (fieldName != null) { - _folderSortField[entry.key] = FileSortField.values.firstWhere( - (f) => f.name == fieldName, - orElse: () => FileSortField.name, - ); + final folderSortJson = prefs.getString(k(_prefFolderSort)); + if (folderSortJson != null) { + try { + final decoded = jsonDecode(folderSortJson) as Map; + for (final entry in decoded.entries) { + final value = entry.value as Map; + final fieldName = value['field'] as String?; + if (fieldName != null) { + _folderSortField[entry.key] = FileSortField.values.firstWhere( + (f) => f.name == fieldName, + orElse: () => FileSortField.name, + ); + } + final ascending = value['ascending'] as bool?; + if (ascending != null) { + _folderSortAscending[entry.key] = ascending; + } } - final ascending = value['ascending'] as bool?; - if (ascending != null) _folderSortAscending[entry.key] = ascending; + } catch (e) { + debugPrint('[FilesController] Folder sort restore failed: $e'); } - } catch (e) { - debugPrint('[FilesController] Folder sort restore failed: $e'); } - } - final cachePolicyName = prefs.getString(k(_prefCachePolicy)); - _cachePolicy = CachePolicy.values.firstWhere( - (c) => c.name == cachePolicyName, - orElse: () => CachePolicy.never, - ); - _cacheIntervalMinutes = prefs.getInt(k(_prefCacheIntervalMinutes)) ?? 5; - notifyListeners(); + final cachePolicyName = prefs.getString(k(prefCachePolicy)); + _cachePolicy = CachePolicy.values.firstWhere( + (c) => c.name == cachePolicyName, + orElse: () => defaultCachePolicy, + ); + _cacheIntervalMinutes = + prefs.getInt(k(prefCacheIntervalMinutes)) ?? + defaultCacheIntervalMinutes; + notifyListeners(); + } catch (e) { + debugPrint('[FilesController] Account-activation prefs restore failed: $e'); + } } + } + + Future _onAccountActivated() async { + await _restoreDisplayPrefs(); _startCacheRefreshTimerIfNeeded(); await refreshData(); } @@ -305,10 +385,21 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver { // can recognize it's for an account the user has already left and // discard itself instead of overwriting the new account's content. final gen = session.sessionGeneration; + _retryTimer?.cancel(); - _isLoading = true; - _errorMessage = null; - notifyListeners(); + // Refreshing a folder that's already on screen (pull-to-refresh, the + // periodic cache refresh) swaps the listing in place once the server + // answers - blanking the list to a spinner first made every refresh + // look like the files vanished and reappeared. The spinner is only for + // when there's nothing to show yet (first load, just navigated into a + // new folder - see [_navigateTo], which clears the outgoing folder's + // items - or retrying after an error). + final inPlace = _items.isNotEmpty && _errorMessage == null; + if (!inPlace) { + _isLoading = true; + _errorMessage = null; + notifyListeners(); + } debugPrint('[FilesController] Refreshing data for path: $_currentFolderPath'); @@ -320,6 +411,11 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver { _items, DateTime.now(), ); + // Show the new listing now rather than after the quota/activity + // fetches below - they're unrelated to what the list displays. + _isLoading = false; + _retryAttempt = 0; + notifyListeners(); debugPrint( '[FilesController] Loaded ${_items.length} items for $_currentFolderPath', ); @@ -336,7 +432,12 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver { } catch (e) { if (gen != session.sessionGeneration) return; debugPrint('[FilesController] Error fetching directory: $e'); - _errorMessage = e.toString().replaceAll('Exception: ', ''); + // A failed in-place refresh keeps the (slightly stale) listing on + // screen instead of replacing it with an error page. + if (!inPlace) { + _errorMessage = e.toString().replaceAll('Exception: ', ''); + _scheduleNetworkRetry(e); + } } finally { if (gen == session.sessionGeneration) { _isLoading = false; @@ -345,17 +446,46 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver { } } + /// The OS reports "connected" a moment before the route actually works, + /// so the first request after regaining Wi-Fi/data can fail with "Network + /// is unreachable" - leaving the Files tab on an error page until the + /// user taps Retry. Quietly retry a few times (2s, 4s, 8s, 15s) when the + /// failure is network-level and the device is online; a real server error + /// (auth, 5xx) isn't retried. + void _scheduleNetworkRetry(Object error) { + final text = error.toString(); + final isNetworkError = + text.contains('SocketException') || + text.contains('ClientException') || + text.contains('TimeoutException') || + text.contains('Network is unreachable'); + if (!isNetworkError || + _retryAttempt >= 4 || + session.connectivity.isOffline) { + return; + } + final delays = [2, 4, 8, 15]; + final delay = Duration(seconds: delays[_retryAttempt]); + _retryAttempt++; + _retryTimer = Timer(delay, () => unawaited(refreshData())); + } + Future> searchFiles(String query) async { final service = session.service; if (service == null) return []; return service.searchFiles(query); } + @override + Future reload() => refreshData(); + + @override Future navigateToFolder(String path) async { _pathStack.add(path); await _navigateTo(path); } + @override Future navigateUp() async { if (_pathStack.length > 1) { _pathStack.removeLast(); @@ -365,6 +495,7 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver { /// Jumps directly to an ancestor folder by its position in [pathStack] /// (as tapped from a breadcrumb), trimming everything below it. + @override Future navigateToPathIndex(int index) async { if (index < 0 || index >= _pathStack.length - 1) return; _pathStack = _pathStack.sublist(0, index + 1); @@ -402,6 +533,10 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); return; } + // Don't leave the folder just left on screen under the new breadcrumb + // while this one loads - clearing it is what makes [refreshData] show + // its spinner (rather than refreshing in place) for a navigation. + _items = []; await refreshData(); } @@ -493,15 +628,20 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver { ); } - void setFilesSortField(FileSortField field) { - if (filesSortField == field) return; - _folderSortField[_currentFolderPath] = field; + void setFilesSortField(FileSortField field) => + setSortFieldFor(_currentFolderPath, field); + + void toggleFilesSortOrder() => toggleSortOrderFor(_currentFolderPath); + + void setSortFieldFor(String path, FileSortField field) { + if (sortFieldFor(path) == field) return; + _folderSortField[path] = field; notifyListeners(); _persistFolderSort(); } - void toggleFilesSortOrder() { - _folderSortAscending[_currentFolderPath] = !filesSortAscending; + void toggleSortOrderFor(String path) { + _folderSortAscending[path] = !sortAscendingFor(path); notifyListeners(); _persistFolderSort(); } @@ -511,7 +651,7 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver { _cachePolicy = policy; notifyListeners(); _persistAccountPref( - _prefCachePolicy, + prefCachePolicy, (p, key) => p.setString(key, policy.name), ); _startCacheRefreshTimerIfNeeded(); @@ -523,7 +663,7 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver { _cacheIntervalMinutes = clamped; notifyListeners(); _persistAccountPref( - _prefCacheIntervalMinutes, + prefCacheIntervalMinutes, (p, key) => p.setInt(key, clamped), ); _startCacheRefreshTimerIfNeeded(); diff --git a/lib/providers/folder_browser.dart b/lib/providers/folder_browser.dart new file mode 100644 index 0000000..b992763 --- /dev/null +++ b/lib/providers/folder_browser.dart @@ -0,0 +1,31 @@ +import '../models/nextcloud_item.dart'; + +/// What `FilesView` needs from whatever is supplying the folder it's +/// showing: the breadcrumb trail, the (already display-pref-filtered and +/// sorted) items, loading/error state, and navigation. Implemented by +/// [FilesController] (the server's folders) and [OfflineController] (the +/// device-sync mirror of those same folders) so the Files tab and the +/// Offline tab are literally the same view over two data sources. +/// +/// Deliberately not a `Listenable` - `context.watch` subscribes to the +/// concrete provider registered in `main()`, not to this interface, so the +/// view picks the concrete controller and then treats it as a +/// [FolderBrowser]. +abstract interface class FolderBrowser { + List get pathStack; + String get currentFolderPath; + + /// Items in [currentFolderPath] with the shared Files display prefs + /// (hidden files, type filter, sort) already applied. + List get items; + + bool get isLoading; + String? get errorMessage; + + Future navigateToFolder(String path); + Future navigateToPathIndex(int index); + Future navigateUp(); + + /// Re-reads the current folder (pull-to-refresh / retry). + Future reload(); +} diff --git a/lib/providers/offline_controller.dart b/lib/providers/offline_controller.dart new file mode 100644 index 0000000..3939e7e --- /dev/null +++ b/lib/providers/offline_controller.dart @@ -0,0 +1,246 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import '../models/nextcloud_item.dart'; +import '../services/sync_service.dart'; +import 'files_controller.dart'; +import 'folder_browser.dart'; +import 'session_controller.dart'; + +/// The Offline tab's data source: `FilesView` (the same view as the Files +/// tab) over whatever device-sync has actually mirrored to local storage, +/// i.e. the Files tab's folders filtered down to what's available offline - +/// read straight off +/// disk, no server round-trip, since the whole point is browsing what's +/// available without a connection. Walks +/// `/sync//...` directly (mirrors +/// `SyncEngine.kt#syncRoot`'s layout exactly, same technique +/// `SyncStatusController.localSyncedFilePath` already relies on) rather +/// than round-tripping through a MethodChannel. Listens for +/// [SessionController.addAccountReadyListener] rather than +/// `addAccountActivatedListener` - this controller's own work is entirely +/// local, so it's safe to run even on a provisional/offline login (see +/// that method's doc comment). +/// +/// Display controls (grid/list, sort, hidden files, files/folders filter) +/// aren't kept here - [items] runs the listing through the Files tab's own +/// [FilesController] prefs so the two tabs always agree. +class OfflineController extends ChangeNotifier + with WidgetsBindingObserver + implements FolderBrowser { + final SessionController session; + final FilesController files; + + List _pathStack = const ['/']; + List _items = []; + bool _isLoading = false; + String? _errorMessage; + bool _wasSyncing = false; + // Resolved once in _load - lets [localFileFor] stay synchronous so it can + // be called straight from a widget's build. + String? _syncBasePath; + StreamSubscription? _statusSub; + + OfflineController(this.session, this.files) { + WidgetsBinding.instance.addObserver(this); + session.addAccountClearedListener(_onAccountCleared); + session.addAccountReadyListener(_onAccountActivated); + // Refreshes automatically once a sync pass finishes, so newly + // downloaded files show up here without a manual pull-to-refresh. + _statusSub = SyncService.statusStream.listen(_onStatusSnapshot); + } + + @override + List get pathStack => _pathStack; + @override + String get currentFolderPath => _pathStack.last; + @override + List get items => files.applyFilesDisplayPrefs( + _items, + folderPath: currentFolderPath, + // The local mirror has no cloud/external distinction to filter on. + applyStorageScope: false, + ); + // Only "loading" while there's nothing to show yet - a reload over an + // already-listed folder (e.g. the automatic one after a sync pass + // finishes) swaps the list in place instead of flashing a spinner. + @override + bool get isLoading => _isLoading && _items.isEmpty; + @override + String? get errorMessage => _errorMessage; + + void _onAccountCleared() { + _pathStack = const ['/']; + _items = []; + _isLoading = false; + _errorMessage = null; + notifyListeners(); + } + + void _onAccountActivated() { + unawaited(_load()); + } + + /// A background sync (which can run while the app is closed or paused) + /// may have added or removed files since this list was last read - re-read + /// the folder whenever the app comes back to the foreground. + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) unawaited(_load()); + } + + void _onStatusSnapshot(SyncStatusSnapshot snapshot) { + if (_wasSyncing && !snapshot.syncing) { + unawaited(_load()); + } + _wasSyncing = snapshot.syncing; + } + + /// Reloads the currently browsed folder - used by pull-to-refresh and as + /// the account-activation entry point. + @override + Future reload() => _load(); + + @override + Future navigateToFolder(String path) async { + _pathStack = [..._pathStack, path]; + await _load(); + } + + @override + Future navigateToPathIndex(int index) async { + if (index < 0 || index >= _pathStack.length - 1) return; + _pathStack = _pathStack.sublist(0, index + 1); + await _load(); + } + + @override + Future navigateUp() async { + if (_pathStack.length <= 1) return; + _pathStack = _pathStack.sublist(0, _pathStack.length - 1); + await _load(); + } + + Future _load() async { + final accountId = session.activeAccountId; + if (accountId == null) return; + + _isLoading = true; + _errorMessage = null; + notifyListeners(); + + try { + _syncBasePath ??= (await getExternalStorageDirectory())?.path; + // A sync can delete the very folder being browsed (deleted on the + // server) - step out to the nearest ancestor that still exists rather + // than showing a phantom empty folder. + final base = _syncBasePath; + if (base != null) { + while (_pathStack.length > 1 && + !Directory( + p.join( + base, + 'sync', + accountId, + currentFolderPath.replaceFirst('/', ''), + ), + ).existsSync()) { + _pathStack = _pathStack.sublist(0, _pathStack.length - 1); + } + } + _items = await _listFolder(accountId, currentFolderPath); + } catch (e) { + debugPrint('[OfflineController] Error listing local files: $e'); + _errorMessage = e.toString().replaceAll('Exception: ', ''); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + /// The absolute local path for [item] - a pure function of its remote- + /// style `path` (same `/` layout `SyncEngine.kt# + /// syncRoot` uses). Deliberately doesn't re-verify [item] is "in sync + /// scope" the way `SyncStatusController.localSyncedFilePath` does for + /// arbitrary items from elsewhere in the app - every item this + /// controller ever hands out already came from listing this exact + /// directory tree, so it's definitionally already local; re-checking + /// scope here was the actual bug behind a file the Offline tab had just + /// listed reporting itself as "no longer available". + /// Synchronous counterpart to [localPathFor] for building thumbnails - + /// null until the first [_load] has resolved the storage directory. Doesn't + /// check the file exists; the image widget's own error fallback covers a + /// missing one. + File? localFileFor(NextcloudItem item) { + final accountId = session.activeAccountId; + final base = _syncBasePath; + if (accountId == null || base == null) return null; + final relPath = item.path.startsWith('/') + ? item.path.substring(1) + : item.path; + return File(p.join(base, 'sync', accountId, relPath)); + } + + Future localPathFor(NextcloudItem item) async { + final accountId = session.activeAccountId; + if (accountId == null) return null; + final base = await getExternalStorageDirectory(); + if (base == null) return null; + final relPath = item.path.startsWith('/') + ? item.path.substring(1) + : item.path; + final file = File(p.join(base.path, 'sync', accountId, relPath)); + return file.existsSync() ? file.path : null; + } + + static Future> _listFolder( + String accountId, + String folderPath, + ) async { + final base = await getExternalStorageDirectory(); + if (base == null) return []; + final relFolder = folderPath == '/' ? '' : folderPath.replaceFirst('/', ''); + final dir = Directory(p.join(base.path, 'sync', accountId, relFolder)); + if (!dir.existsSync()) return []; + + final items = []; + await for (final entity in dir.list(followLinks: false)) { + final name = p.basename(entity.path); + final remotePath = folderPath == '/' ? '/$name' : '$folderPath/$name'; + if (entity is Directory) { + items.add( + NextcloudItem( + id: remotePath, + name: name, + path: remotePath, + type: NextcloudItemType.folder, + size: 0, + lastModified: (await entity.stat()).modified, + ), + ); + } else if (entity is File) { + final stat = await entity.stat(); + items.add( + NextcloudItem( + id: remotePath, + name: name, + path: remotePath, + type: NextcloudItem.deduceType(remotePath, false, null), + size: stat.size, + lastModified: stat.modified, + ), + ); + } + } + return items; + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _statusSub?.cancel(); + super.dispose(); + } +} diff --git a/lib/providers/photos_controller.dart b/lib/providers/photos_controller.dart index 94f645b..b5b4665 100644 --- a/lib/providers/photos_controller.dart +++ b/lib/providers/photos_controller.dart @@ -76,18 +76,27 @@ class PhotosController extends ChangeNotifier { Future _onAccountActivated() async { final id = session.activeAccountId; if (id != null) { - final prefs = await session.prefsFuture; - String k(String base) => session.accountStore.accountPrefKey(id, base); - _showFavoritesOnly = - prefs.getBool(k(_prefShowFavoritesOnlyPhotos)) ?? false; - _showHidden = prefs.getBool(k(_prefShowHiddenPhotos)) ?? false; - final sortFieldName = prefs.getString(k(_prefSortField)); - _sortField = FileSortField.values.firstWhere( - (f) => f.name == sortFieldName, - orElse: () => FileSortField.name, - ); - _sortAscending = prefs.getBool(k(_prefSortAscending)) ?? true; - notifyListeners(); + // Wrapped so a single bad/mistyped stored pref value can't + // silently skip fetchAllMedia() below and leave Photos permanently + // empty on this activation with no error surfaced anywhere - see + // FilesController._onAccountActivated's identical guard for the + // real incident this mirrors. + try { + final prefs = await session.prefsFuture; + String k(String base) => session.accountStore.accountPrefKey(id, base); + _showFavoritesOnly = + prefs.getBool(k(_prefShowFavoritesOnlyPhotos)) ?? false; + _showHidden = prefs.getBool(k(_prefShowHiddenPhotos)) ?? false; + final sortFieldName = prefs.getString(k(_prefSortField)); + _sortField = FileSortField.values.firstWhere( + (f) => f.name == sortFieldName, + orElse: () => FileSortField.name, + ); + _sortAscending = prefs.getBool(k(_prefSortAscending)) ?? true; + notifyListeners(); + } catch (e) { + debugPrint('[PhotosController] Account-activation prefs restore failed: $e'); + } } unawaited(fetchAllMedia()); } diff --git a/lib/providers/session_controller.dart b/lib/providers/session_controller.dart index ac047bc..e22d08e 100644 --- a/lib/providers/session_controller.dart +++ b/lib/providers/session_controller.dart @@ -8,6 +8,7 @@ import '../services/app_lock_service.dart'; import '../services/login_flow_service.dart'; import '../services/nextcloud_service.dart'; import '../services/sync_service.dart'; +import 'connectivity_controller.dart'; enum LoginFlowStatus { idle, initiating, awaitingBrowser, error } @@ -25,10 +26,21 @@ enum LoginFlowStatus { idle, initiating, awaitingBrowser, error } /// `.claude/context/architecture.md`'s "State management" section for the /// full rationale. class SessionController extends ChangeNotifier with WidgetsBindingObserver { + final ConnectivityController connectivity; final Future prefsFuture = SharedPreferences.getInstance(); final AccountStore accountStore = AccountStore(); + // True from the moment a saved session is restored (or a network + // failure keeps it alive - see `_applyCredentialsForAccount`) without + // ever having actually reached the server, until a real + // `testConnection()` succeeds. Network-fetching controllers have no + // reason to check this themselves - they only ever hear about account + // activation via [addAccountActivatedListener], which simply doesn't + // fire while this is true (see [addAccountReadyListener] for the + // signal that *does* fire either way). + bool _isProvisionalLogin = false; + String _serverUrl = ''; String _username = ''; String _password = ''; @@ -71,13 +83,23 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver { // them - it's constructed first, they depend on it. `cleared` fires // wherever `_clearActiveContent()` used to run (about to switch/log out - // reset your own state); `activated` fires only once credentials are - // actually verified (fetch your own data now). + // actually verified against the server, so it's safe to make network + // requests (fetch your own data now) - it deliberately does *not* fire + // for a provisional/offline login (see `_isProvisionalLogin`), so a + // network-fetching controller never has to check connectivity itself. + // `ready` fires in *both* cases - verified or provisional - for the + // handful of controllers (SyncStatusController/OfflineController) whose + // own activation work is local-only (prefs, on-disk files) and safe to + // run with no connection at all. final List _accountClearedListeners = []; final List _accountActivatedListeners = []; + final List _accountReadyListeners = []; void addAccountClearedListener(VoidCallback cb) => _accountClearedListeners.add(cb); void addAccountActivatedListener(VoidCallback cb) => _accountActivatedListeners.add(cb); + void addAccountReadyListener(VoidCallback cb) => + _accountReadyListeners.add(cb); void _notifyAccountCleared() { for (final cb in _accountClearedListeners) { cb(); @@ -88,10 +110,20 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver { for (final cb in _accountActivatedListeners) { cb(); } + for (final cb in _accountReadyListeners) { + cb(); + } } - SessionController() { + void _notifyAccountProvisionallyReady() { + for (final cb in _accountReadyListeners) { + cb(); + } + } + + SessionController(this.connectivity) { WidgetsBinding.instance.addObserver(this); + connectivity.addListener(_onConnectivityChanged); _init(); } @@ -175,11 +207,16 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver { /// Verifies [appPassword] for [account] and, on success, makes it the /// live session, firing [_notifyAccountActivated] so every other - /// controller fetches its own data. The password is expected to already - /// be durably saved by the caller (either freshly, via - /// [_completeLoginFlow], or previously, since this is also how a saved - /// session is restored/switched to) - this method only writes to - /// [AccountStore] to drop a password that turns out to no longer work. + /// controller fetches its own data. With no network route at all, or on + /// a network-level (not auth) failure, skips/gives up on verification + /// but still logs in *provisionally* - see [_isProvisionalLogin] - so + /// the app still opens into `MainShellView` (restricted to the Offline + /// tab while offline) instead of bouncing to `LoginView`. The password + /// is expected to already be durably saved by the caller (either + /// freshly, via [_completeLoginFlow], or previously, since this is also + /// how a saved session is restored/switched to) - this method only + /// writes to [AccountStore] to drop a password that turns out to no + /// longer work (an actual 401, never a network failure). Future _applyCredentialsForAccount( SavedAccount account, String appPassword, @@ -199,10 +236,25 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver { password: _password, ); + // No point even attempting the request with no network route at all - + // treat this exactly like the network-failure branch below (stay + // logged in provisionally, `service` already assigned) without paying + // for a doomed HTTP call first. + if (connectivity.isOffline) { + if (gen != _sessionGeneration) return false; + _isProvisionalLogin = true; + _isLoggedIn = true; + _isLoading = false; + notifyListeners(); + _notifyAccountProvisionallyReady(); + return true; + } + try { final success = await _service!.testConnection(); if (gen != _sessionGeneration) return false; if (success) { + _isProvisionalLogin = false; _isLoggedIn = true; _isLoading = false; notifyListeners(); @@ -212,20 +264,35 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver { } catch (e) { if (gen != _sessionGeneration) return false; _errorMessage = e.toString().replaceAll('Exception: ', ''); - // Only drop the stored password on an actual auth rejection (401) - - // testConnection also throws for network-level failures (DNS, - // timeout, unreachable host), and those are transient: deleting a - // still-valid password on a dropped connection would permanently - // log the account out with no way back in short of Login Flow v2 - // again, since activeAccountId is never cleared and switchAccount() - // no-ops when asked to "switch" to the account that's already - // (nominally) active. + // Only drop the stored password - and only actually log out - on an + // actual auth rejection (401). Every other failure (DNS, timeout, + // unreachable host, a transient 5xx) is provisional: staying + // "logged in" with the cached account/service lets the app still + // land on MainShellView instead of bouncing to LoginView, which + // would strand a genuinely offline user with no way back in short + // of Login Flow v2 again (activeAccountId is never cleared and + // switchAccount() no-ops when asked to "switch" to the account + // that's already nominally active). `_service` is already assigned + // above and needs no re-creation once connectivity returns. if (_errorMessage?.contains('401') == true) { await accountStore.deletePassword(account.id); + _isProvisionalLogin = false; + _isLoggedIn = false; + _service = null; + _isLoading = false; + notifyListeners(); + return false; } + _isProvisionalLogin = true; + _isLoggedIn = true; + _isLoading = false; + notifyListeners(); + _notifyAccountProvisionallyReady(); + return true; } if (gen != _sessionGeneration) return false; + _isProvisionalLogin = false; _isLoggedIn = false; _service = null; _isLoading = false; @@ -233,6 +300,41 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver { return false; } + /// Once connectivity actually returns, re-verifies a provisional/offline + /// login for real - this is what finally fires [_notifyAccountActivated] + /// (not just [_notifyAccountProvisionallyReady]) so network-fetching + /// controllers get their first real fetch without requiring an app + /// restart. + void _onConnectivityChanged() { + _reverifyTimer?.cancel(); + if (!_isProvisionalLogin || connectivity.isOffline) return; + unawaited(_reverify(0)); + } + + Timer? _reverifyTimer; + + /// One re-verification attempt, retried with a growing delay if the + /// server still can't be reached. The OS reports "connected" a moment + /// before the route actually works (the first request right after + /// regaining Wi-Fi/data fails with "Network is unreachable"), and + /// `onConnectivityChanged` won't fire again to give a second chance - so + /// without retrying, the login stayed provisional and the Files tab showed + /// a connection error until the user tapped Retry. + Future _reverify(int attempt) async { + if (!_isProvisionalLogin || connectivity.isOffline) return; + final id = _activeAccountId; + if (id == null) return; + final account = _accounts.where((a) => a.id == id).firstOrNull; + if (account == null) return; + await _applyCredentialsForAccount(account, _password); + if (_isProvisionalLogin && !connectivity.isOffline && attempt < 5) { + _reverifyTimer = Timer( + Duration(seconds: 2 * (attempt + 1)), + () => unawaited(_reverify(attempt + 1)), + ); + } + } + /// Starts Nextcloud Login Flow v2: asks the server for a one-time login /// URL, then polls until the user authorizes (in LoginWebViewView, pushed /// by LoginView once loginFlowStatus flips to awaitingBrowser below) and @@ -370,6 +472,8 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver { _activeAccountId = accountId; final prefs = await prefsFuture; await accountStore.saveActiveAccountId(prefs, accountId); + // Signed back in (or switched to) - it syncs in the background again. + await accountStore.setSignedOut(prefs, accountId, false); notifyListeners(); final account = _accounts.where((a) => a.id == accountId).firstOrNull; @@ -377,6 +481,7 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver { ? null : await accountStore.readPassword(accountId); if (account == null || password == null) { + _isProvisionalLogin = false; _isLoading = false; _isLoggedIn = false; notifyListeners(); @@ -443,6 +548,9 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver { for (final key in AccountStore.perAccountPrefKeys) { await prefs.remove(accountStore.accountPrefKey(accountId, key)); } + await accountStore.setSignedOut(prefs, accountId, false); + // Its background sync job holds that account's credentials - stop it. + unawaited(SyncService.cancelAccount(accountId)); if (!wasActive) { notifyListeners(); @@ -466,6 +574,7 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver { Future _deactivateSession() async { _sessionGeneration++; _notifyAccountCleared(); + _isProvisionalLogin = false; _isLoggedIn = false; _serverUrl = ''; _username = ''; @@ -474,7 +583,6 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver { final prefs = await prefsFuture; await accountStore.saveActiveAccountId(prefs, null); notifyListeners(); - unawaited(SyncService.cancel()); } /// Ends the active session but keeps this account saved - unlike @@ -484,8 +592,16 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver { /// Always lands on the login screen even if other accounts are saved - /// deliberately not the same as switching to one of them. Future logout() async { - if (_activeAccountId == null) return; + final id = _activeAccountId; + if (id == null) return; + // Logging out stops that account's background sync (the other saved + // accounts keep syncing - each has its own job). It's remembered as + // signed out so a later reschedule for some other account doesn't + // bring its job back; signing in again re-enables it. + final prefs = await prefsFuture; + await accountStore.setSignedOut(prefs, id, true); await _deactivateSession(); + unawaited(SyncService.cancelAccount(id)); } /// Confirms the device can do local auth and prompts once to enable login @@ -564,6 +680,8 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver { @override void dispose() { WidgetsBinding.instance.removeObserver(this); + connectivity.removeListener(_onConnectivityChanged); + _reverifyTimer?.cancel(); _pollTimer?.cancel(); _pollTimeoutTimer?.cancel(); super.dispose(); diff --git a/lib/providers/sync_status_controller.dart b/lib/providers/sync_status_controller.dart index 46b44f6..0e38beb 100644 --- a/lib/providers/sync_status_controller.dart +++ b/lib/providers/sync_status_controller.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../models/nextcloud_item.dart'; import '../models/sync_status.dart'; import '../services/sync_service.dart'; +import 'files_controller.dart'; import 'session_controller.dart'; /// Device-sync state and settings: which remote paths (files or folders, @@ -16,13 +17,37 @@ import 'session_controller.dart'; /// (`syncStatusFor`) despite living in an unrelated domain; now it's a /// second, focused provider those views read alongside their own tab /// controller. -class SyncStatusController extends ChangeNotifier { +/// +/// **Keeping synced files current.** Synced folders/files follow the same +/// Files Cache rule (Settings) as folder listings, so nothing needs the +/// "Sync now" button: +/// +/// - *Refresh periodically (N min)*: a WorkManager periodic job runs in the +/// background at that interval (Android's floor is 15 min, so shorter +/// values only apply in the foreground), and while the app's open an +/// in-app timer runs a pass every N minutes plus one on resume if it's +/// been longer than that. +/// - *Never cache*: no background schedule; a pass runs each time the app +/// is opened/resumed. +/// - *Refresh manually only*: only pull-to-refresh (any tab's sync header) +/// and "Sync now". +/// +/// Every automatic pass is cheap when nothing changed - the native worker +/// checks each synced root's etag (Nextcloud propagates any descendant +/// change up to every ancestor's etag) and skips the full walk if it's +/// unchanged and the local copy is intact. There's no server push: Nextcloud's +/// only push channels (the `notify_push` server app, or FCM via Nextcloud's +/// push proxy) need server-side setup and a persistent connection/registered +/// app identity, so this polls instead. +class SyncStatusController extends ChangeNotifier with WidgetsBindingObserver { final SessionController session; + final FilesController files; - static const _prefSyncedPaths = 'ui_synced_folders'; + static const prefSyncedPaths = 'ui_synced_folders'; static const _prefSyncedPathTypes = 'ui_synced_folder_types'; - static const _prefSyncEverything = 'ui_sync_everything'; + static const prefSyncEverything = 'ui_sync_everything'; static const _prefSyncOnCellular = 'ui_sync_on_cellular'; + static const _prefSyncNotifications = 'ui_sync_notifications'; List _syncedPaths = []; // path -> isFolder, keyed the same as [_syncedPaths] - missing entries @@ -31,6 +56,7 @@ class SyncStatusController extends ChangeNotifier { Map _syncedPathTypes = {}; bool _syncEverything = false; bool _syncOnCellular = false; + bool _syncNotifications = true; bool _isSyncingNow = false; Set _syncingFileIds = {}; @@ -38,9 +64,45 @@ class SyncStatusController extends ChangeNotifier { List _syncConflicts = []; StreamSubscription? _statusSub; - SyncStatusController(this.session) { + // Gates addSyncedPaths/removeSyncedPaths (anything that persists + // _syncedPaths) against running before _onAccountActivated's async + // prefs load has actually finished - without this, syncing a folder + // right after opening the app could race the load, overwrite the + // persisted list with just the one path being added/removed, and + // silently destroy every previously-synced folder. Deliberately starts + // *incomplete*, not pre-completed - a mutation is only ever reachable + // from Files/Offline UI, which requires a completed activation already + // (Files' own item list depends on the same login event), so there's no + // real deadlock risk, and starting complete would leave the very first + // cold-start load completely unprotected (only account *switches* + // re-arm it, via _onAccountCleared - the bug that actually caused + // repeated data loss here). + Completer _accountLoadedGate = Completer(); + + // True once _syncedPaths/_syncedPathTypes/_syncEverything have been + // loaded from prefs for the *current* account - _onAccountActivated can + // fire more than once per account (e.g. SessionController re-verifying + // a provisional/offline login once connectivity returns), and a second + // pass re-reading from storage could clobber an in-memory mutation made + // between the first load and the second one if its own persist hadn't + // landed yet. Reset in _onAccountCleared. + bool _hasLoadedSyncedPathsForAccount = false; + + Timer? _autoSyncTimer; + bool _foreground = true; + DateTime? _lastAutoSyncAt; + CachePolicy? _lastPolicy; + int? _lastInterval; + + SyncStatusController(this.session, this.files) { + WidgetsBinding.instance.addObserver(this); + files.addListener(_onFilesChanged); session.addAccountClearedListener(_onAccountCleared); - session.addAccountActivatedListener(_onAccountActivated); + // `ready`, not `activated` - this controller's own activation work + // (loading prefs, calling the native sync channel) is local/native + // only, so it's safe to run even on a provisional/offline login (see + // `SessionController.addAccountReadyListener`'s doc comment). + session.addAccountReadyListener(_onAccountActivated); _statusSub = SyncService.statusStream.listen(_applySnapshot); _loadGlobalPrefs(); } @@ -49,7 +111,21 @@ class SyncStatusController extends ChangeNotifier { bool get syncEverything => _syncEverything; bool get syncOnCellular => _syncOnCellular; + /// Whether background (automatic) sync runs may post progress/summary + /// notifications. Conflicts and user-initiated "Sync now" always notify. + bool get syncNotifications => _syncNotifications; + bool get isSyncingNow => _isSyncingNow; + + bool get _hasSyncScope => _syncEverything || _syncedPaths.isNotEmpty; + + /// How often the background WorkManager job should run, or null for no + /// background sync at all - only the "refresh periodically" Files Cache + /// policy schedules one (see the class doc comment). + int? get backgroundSyncIntervalMinutes => + files.cachePolicy == CachePolicy.interval + ? files.cacheIntervalMinutes + : null; List get syncConflicts => List.unmodifiable(_syncConflicts); /// How many of [syncedPaths] are folders (as opposed to individual @@ -57,11 +133,6 @@ class SyncStatusController extends ChangeNotifier { int get syncedFolderCount => _syncedPaths.where((p) => _syncedPathTypes[p] ?? true).length; - /// How many individual files have actually been mirrored locally so far - /// - distinct from [syncedFolderCount]/[syncedPaths], which are just the - /// configured *targets*, not what's actually landed on disk yet. - int get syncedItemCount => _syncedFileIds.length; - SyncHeaderStatus get syncHeaderStatus { if (_syncConflicts.isNotEmpty) return SyncHeaderStatus.alert; if (_isSyncingNow) return SyncHeaderStatus.syncing; @@ -121,10 +192,21 @@ class SyncStatusController extends ChangeNotifier { Future _loadGlobalPrefs() async { final prefs = await session.prefsFuture; _syncOnCellular = prefs.getBool(_prefSyncOnCellular) ?? _syncOnCellular; + _syncNotifications = + prefs.getBool(_prefSyncNotifications) ?? _syncNotifications; notifyListeners(); } void _onAccountCleared() { + // Re-armed (not completed) here, not just at construction - an + // account switch means the *next* activation's load has to finish + // before any mutation touching _syncedPaths is safe again. Only + // replace the Completer if nothing's still awaiting the old one - + // swapping it out from under a pending awaiter would orphan that + // await forever, since nothing would ever complete the discarded + // instance. + if (_accountLoadedGate.isCompleted) _accountLoadedGate = Completer(); + _hasLoadedSyncedPathsForAccount = false; _syncedPaths = []; _syncedPathTypes = {}; _syncEverything = false; @@ -132,47 +214,175 @@ class SyncStatusController extends ChangeNotifier { _syncingFileIds = {}; _syncedFileIds = {}; _syncConflicts = []; + _autoSyncTimer?.cancel(); + _lastAutoSyncAt = null; + _lastPolicy = null; + _lastInterval = null; notifyListeners(); } Future _onAccountActivated() async { final id = session.activeAccountId; - if (id != null) { - final prefs = await session.prefsFuture; - String k(String base) => session.accountStore.accountPrefKey(id, base); - final pathsJson = prefs.getString(k(_prefSyncedPaths)); - if (pathsJson != null) { - try { - _syncedPaths = (jsonDecode(pathsJson) as List).cast(); - } catch (e) { - debugPrint('[SyncStatusController] Synced paths restore failed: $e'); + // Only load _syncedPaths/_syncedPathTypes/_syncEverything once per + // account, not on every activation - this can fire more than once + // for the same account (e.g. SessionController re-verifying a + // provisional/offline login once connectivity returns), and a second + // read from storage could clobber an in-memory mutation made between + // the first load and this one if its own persist hadn't landed yet. + // See _hasLoadedSyncedPathsForAccount's doc comment. + if (id != null && !_hasLoadedSyncedPathsForAccount) { + _hasLoadedSyncedPathsForAccount = true; + // Wrapped so a single bad/mistyped stored pref value can't + // propagate uncaught out of this method and skip the gate + // completion below - that would leave every future + // addSyncedPaths/removeSyncedPaths call awaiting a gate that never + // completes, hanging forever. See FilesController. + // _onAccountActivated's identical guard for the real incident this + // mirrors. + try { + final prefs = await session.prefsFuture; + String k(String base) => session.accountStore.accountPrefKey(id, base); + final pathsJson = prefs.getString(k(prefSyncedPaths)); + if (pathsJson != null) { + try { + _syncedPaths = (jsonDecode(pathsJson) as List).cast(); + } catch (e) { + debugPrint('[SyncStatusController] Synced paths restore failed: $e'); + _syncedPaths = []; + } + } else { _syncedPaths = []; } - } else { - _syncedPaths = []; - } - final typesJson = prefs.getString(k(_prefSyncedPathTypes)); - if (typesJson != null) { - try { - _syncedPathTypes = (jsonDecode(typesJson) as Map).map( - (k, v) => MapEntry(k as String, v as bool), - ); - } catch (e) { - debugPrint('[SyncStatusController] Synced path types restore failed: $e'); + final typesJson = prefs.getString(k(_prefSyncedPathTypes)); + if (typesJson != null) { + try { + _syncedPathTypes = (jsonDecode(typesJson) as Map).map( + (k, v) => MapEntry(k as String, v as bool), + ); + } catch (e) { + debugPrint('[SyncStatusController] Synced path types restore failed: $e'); + _syncedPathTypes = {}; + } + } else { _syncedPathTypes = {}; } - } else { - _syncedPathTypes = {}; + _syncEverything = prefs.getBool(k(prefSyncEverything)) ?? false; + notifyListeners(); + } catch (e) { + debugPrint('[SyncStatusController] Account-activation prefs restore failed: $e'); } - _syncEverything = prefs.getBool(k(_prefSyncEverything)) ?? false; - notifyListeners(); } + // Only now is it safe for addSyncedPaths/removeSyncedPaths to mutate + // and persist _syncedPaths - see _accountLoadedGate's doc comment. + if (!_accountLoadedGate.isCompleted) _accountLoadedGate.complete(); + unawaited(SyncService.getStatus(id).then(_applySnapshot)); + + // Scheduling depends on the Files Cache policy, so wait for it to load + // rather than scheduling from the defaults and correcting a moment later. + await files.displayPrefsLoaded; + _lastPolicy = files.cachePolicy; + _lastInterval = files.cacheIntervalMinutes; unawaited(SyncService.reschedule(session, this)); - unawaited(SyncService.getStatus().then(_applySnapshot)); + _restartAutoSyncTimer(); + if (_dueForAutoSync) unawaited(_autoSync()); + } + + // ---- Automatic sync (see the class doc comment) ---- + + void _onFilesChanged() { + // FilesController notifies on every navigation - only the cache rule + // itself matters here. + if (_lastPolicy == null) return; // account not activated yet + final policy = files.cachePolicy; + final interval = files.cacheIntervalMinutes; + if (policy == _lastPolicy && interval == _lastInterval) return; + _lastPolicy = policy; + _lastInterval = interval; + unawaited(SyncService.reschedule(session, this)); + _restartAutoSyncTimer(); + } + + /// Whether an app-open/resume should kick off a pass under the current + /// cache rule. + bool get _dueForAutoSync { + switch (files.cachePolicy) { + case CachePolicy.never: + return true; + case CachePolicy.manual: + return false; + case CachePolicy.interval: + final last = _lastAutoSyncAt; + return last == null || + DateTime.now().difference(last) >= + Duration(minutes: files.cacheIntervalMinutes); + } + } + + void _restartAutoSyncTimer() { + _autoSyncTimer?.cancel(); + if (!_foreground || + !_hasSyncScope || + files.cachePolicy != CachePolicy.interval) { + return; + } + _autoSyncTimer = Timer.periodic( + Duration(minutes: files.cacheIntervalMinutes), + (_) => unawaited(_autoSync()), + ); + } + + /// A quiet, network-setting-respecting pass - see [SyncService.syncNow]. + Future _autoSync() async { + if (!_canSyncQuietly) return; + _lastAutoSyncAt = DateTime.now(); + try { + await SyncService.syncNow( + session, + this, + force: false, + respectNetworkSetting: true, + ); + } catch (e) { + debugPrint('[SyncStatusController] Automatic sync failed: $e'); + } + } + + /// A deliberate pull-to-refresh (any tab's sync header): check for server + /// changes now, regardless of the cache rule or the Wi-Fi-only setting. + Future syncOnPull() async { + if (!_canSyncQuietly) return; + try { + await SyncService.syncNow(session, this, force: false); + } catch (e) { + debugPrint('[SyncStatusController] Pull-triggered sync failed: $e'); + } + } + + bool get _canSyncQuietly => + _hasSyncScope && + !_isSyncingNow && + session.isLoggedIn && + !session.connectivity.isOffline; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _foreground = true; + _restartAutoSyncTimer(); + if (_dueForAutoSync) unawaited(_autoSync()); + } else if (state == AppLifecycleState.paused) { + // The in-app timer only runs while foregrounded; WorkManager owns the + // background schedule. + _foreground = false; + _autoSyncTimer?.cancel(); + } } void _applySnapshot(SyncStatusSnapshot snapshot) { - if (snapshot.accountId != null && + // A null accountId is the bus's initial "nothing has synced in this + // process yet" emission on subscribe - applying it would wipe the + // synced-file ids seeded by getStatus(accountId). + if (snapshot.accountId == null || snapshot.accountId != session.activeAccountId) { return; } @@ -181,6 +391,11 @@ class SyncStatusController extends ChangeNotifier { _syncedFileIds = snapshot.syncedFileIds; _syncConflicts = snapshot.conflicts; notifyListeners(); + + // A synced file/folder deleted on the server has nothing left to sync - + // drop it from the synced list rather than leaving a dead entry. + final gone = snapshot.missingRoots.where(_syncedPaths.contains).toList(); + if (gone.isNotEmpty) unawaited(removeSyncedPaths(gone)); } void _persistSyncedPaths() { @@ -188,7 +403,7 @@ class SyncStatusController extends ChangeNotifier { if (id == null) return; session.prefsFuture.then((p) { p.setString( - session.accountStore.accountPrefKey(id, _prefSyncedPaths), + session.accountStore.accountPrefKey(id, prefSyncedPaths), jsonEncode(_syncedPaths), ); p.setString( @@ -200,26 +415,74 @@ class SyncStatusController extends ChangeNotifier { bool isPathSynced(String path) => _syncedPaths.contains(path); - void addSyncedPath(String path, {required bool isFolder}) { - if (_syncedPaths.contains(path)) return; - _syncedPaths = [..._syncedPaths, path]; - _syncedPathTypes = {..._syncedPathTypes, path: isFolder}; + Future addSyncedPath(String path, {required bool isFolder}) { + return addSyncedPaths({path: isFolder}); + } + + /// Adds every path in [entries] (path -> isFolder) to sync in one go - + /// unlike calling [addSyncedPath] once per item in a loop, this touches + /// `_syncedPaths`/persists/reschedules/triggers an immediate sync + /// exactly once no matter how many paths are being added. Looping + /// `addSyncedPath` from a multi-select "Sync to device" was the actual + /// bug behind only the first selected item ever actually syncing: each + /// call fired its own `SyncService.syncNow`, and WorkManager's + /// `enqueueUniqueWork(..., ExistingWorkPolicy.REPLACE, ...)` policy + /// meant each later call cancelled the previous item's still-in-flight + /// sync pass (and could stomp on its `SyncEngine.saveState`, which + /// overwrites the whole state map rather than merging) instead of + /// letting it finish. + Future addSyncedPaths(Map entries) async { + // Must not mutate/persist _syncedPaths before the per-account load + // has actually populated it - see _accountLoadedGate's doc comment. + await _accountLoadedGate.future; + var changed = false; + entries.forEach((path, isFolder) { + if (_syncedPaths.contains(path)) return; + _syncedPaths = [..._syncedPaths, path]; + _syncedPathTypes = {..._syncedPathTypes, path: isFolder}; + changed = true; + }); + if (!changed) return; notifyListeners(); _persistSyncedPaths(); unawaited(SyncService.reschedule(session, this)); + _restartAutoSyncTimer(); + // `reschedule` alone only lines up the *periodic* job, which can be up + // to an hour away from its first run - without an immediate one-off + // sync here too, newly-added items would just sit unsynced until the + // user thought to trigger one manually (e.g. pulling to refresh on + // the Offline tab). + unawaited(_syncNowSilently()); } /// Unsyncs [path] and deletes its already-downloaded local mirror (see /// `SyncService.removeLocalSync`) - stopping sync alone would leave /// whatever had already been downloaded sitting on disk indefinitely. - void removeSyncedPath(String path) { - if (!_syncedPaths.contains(path)) return; - _syncedPaths = _syncedPaths.where((f) => f != path).toList(); - _syncedPathTypes = {..._syncedPathTypes}..remove(path); + Future removeSyncedPath(String path) { + return removeSyncedPaths([path]); + } + + /// Batched counterpart to [removeSyncedPath] - see [addSyncedPaths]'s + /// doc comment for why a multi-item loop of individual calls is unsafe + /// for `reschedule`/native side effects, even though removal itself + /// (unlike adding) doesn't trigger a one-off sync to race. + Future removeSyncedPaths(List paths) async { + await _accountLoadedGate.future; + var changed = false; + for (final path in paths) { + if (!_syncedPaths.contains(path)) continue; + _syncedPaths = _syncedPaths.where((f) => f != path).toList(); + _syncedPathTypes = {..._syncedPathTypes}..remove(path); + changed = true; + } + if (!changed) return; notifyListeners(); _persistSyncedPaths(); unawaited(SyncService.reschedule(session, this)); - unawaited(SyncService.removeLocalSync(session, path)); + _restartAutoSyncTimer(); + for (final path in paths) { + unawaited(SyncService.removeLocalSync(session, path)); + } } void setSyncEverything(bool value) { @@ -230,12 +493,24 @@ class SyncStatusController extends ChangeNotifier { if (id != null) { session.prefsFuture.then( (p) => p.setBool( - session.accountStore.accountPrefKey(id, _prefSyncEverything), + session.accountStore.accountPrefKey(id, prefSyncEverything), value, ), ); } unawaited(SyncService.reschedule(session, this)); + _restartAutoSyncTimer(); + if (value) unawaited(_syncNowSilently()); + } + + void setSyncNotifications(bool value) { + if (_syncNotifications == value) return; + _syncNotifications = value; + notifyListeners(); + session.prefsFuture.then((p) => p.setBool(_prefSyncNotifications, value)); + // Baked into each periodic job's input, so the jobs have to be + // re-enqueued for the change to reach background runs. + unawaited(SyncService.reschedule(session, this)); } void setSyncOnCellular(bool value) { @@ -246,6 +521,18 @@ class SyncStatusController extends ChangeNotifier { unawaited(SyncService.reschedule(session, this)); } + /// Fire-and-forget immediate sync, swallowing errors - called right + /// after turning sync on for something (see [addSyncedPath]/ + /// [setSyncEverything]) so it actually starts syncing now instead of + /// only ever running on the next periodic pass or a manual "Sync now". + Future _syncNowSilently() async { + try { + await SyncService.syncNow(session, this); + } catch (e) { + debugPrint('[SyncStatusController] Immediate sync failed: $e'); + } + } + /// In-app conflict resolution (the sync header's "Keep local"/"Use /// server" buttons) - see `SyncService.resolveConflict`'s doc comment /// for why this shares the exact same native path the notification @@ -277,6 +564,9 @@ class SyncStatusController extends ChangeNotifier { @override void dispose() { + WidgetsBinding.instance.removeObserver(this); + files.removeListener(_onFilesChanged); + _autoSyncTimer?.cancel(); _statusSub?.cancel(); super.dispose(); } diff --git a/lib/services/account_store.dart b/lib/services/account_store.dart index 9dae77a..be2aac9 100644 --- a/lib/services/account_store.dart +++ b/lib/services/account_store.dart @@ -20,6 +20,7 @@ class AccountStore { static const _prefAccountsList = 'accounts_list'; static const _prefActiveAccountId = 'active_account_id'; static const _prefMigrationDone = 'account_migration_v1_done'; + static const _prefSignedOutAccounts = 'signed_out_account_ids'; /// The per-account browsing prefs that get namespaced under /// `acct__` on migration/save - everything else (theme, tab @@ -38,6 +39,7 @@ class AccountStore { 'ui_cache_policy', 'ui_cache_interval_minutes', 'ui_synced_folders', + 'ui_synced_folder_types', 'ui_sync_everything', ]; @@ -129,6 +131,24 @@ class AccountStore { } } + /// Accounts the user explicitly logged out of. They stay saved (password + /// and prefs intact, resumable from the login screen) but are treated as + /// signed out - notably, they don't sync in the background. Cleared again + /// the moment the account is activated. + Set loadSignedOut(SharedPreferences prefs) => + (prefs.getStringList(_prefSignedOutAccounts) ?? const []).toSet(); + + Future setSignedOut( + SharedPreferences prefs, + String accountId, + bool signedOut, + ) async { + final current = loadSignedOut(prefs); + final changed = signedOut ? current.add(accountId) : current.remove(accountId); + if (!changed) return; + await prefs.setStringList(_prefSignedOutAccounts, current.toList()); + } + Future readPassword(String accountId) => _storage.read(key: '$_passwordKeyPrefix$accountId'); diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart index 79b68df..aa870be 100644 --- a/lib/services/sync_service.dart +++ b/lib/services/sync_service.dart @@ -1,9 +1,13 @@ import 'dart:convert'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:path_provider/path_provider.dart'; import '../models/sync_status.dart'; +import '../models/saved_account.dart'; +import '../providers/files_controller.dart'; import '../providers/session_controller.dart'; import '../providers/sync_status_controller.dart'; @@ -16,12 +20,17 @@ class SyncStatusSnapshot { final Set syncedFileIds; final List conflicts; + /// Configured sync paths the server says no longer exist (deleted on the + /// web) - the app drops them from its synced list. + final Set missingRoots; + const SyncStatusSnapshot({ required this.accountId, required this.syncing, required this.syncingFileIds, required this.syncedFileIds, required this.conflicts, + this.missingRoots = const {}, }); factory SyncStatusSnapshot.fromMap(Map map) { @@ -38,6 +47,9 @@ class SyncStatusSnapshot { .cast>() .map(SyncConflictInfo.fromMap) .toList(), + missingRoots: ((map['missingRoots'] as List?) ?? const []) + .cast() + .toSet(), ); } } @@ -56,42 +68,89 @@ class SyncService { 'dev.ayushya.noo/sync_service/status', ); - /// Cancels (if [SyncStatusController.syncedPaths] is now empty) or - /// re-enqueues the periodic sync job with fresh account/credentials/ - /// path-list/network-constraint data - call this any time one of those - /// changes, since a periodic `WorkRequest`'s input is fixed at enqueue - /// time and can only be updated by re-enqueueing. + /// Brings the background periodic sync jobs in line with every saved + /// account's current settings - one job per account with sync enabled + /// (paths or "sync everything", and the Files Cache policy set to refresh + /// periodically), cancelled for accounts without and for accounts the + /// user logged out of. Background sync is deliberately not limited to the + /// active account: each account's job carries that account's own + /// credentials, paths and interval, so switching accounts doesn't stop the + /// others. + /// + /// Call whenever any of that changes - a periodic `WorkRequest`'s input is + /// fixed at enqueue time and can only be updated by re-enqueueing. The + /// active account's settings come from [sync]/its live state (they may not + /// have been persisted yet); every other account's are read from storage. static Future reschedule( SessionController session, SyncStatusController sync, ) async { - final accountId = session.activeAccountId; - final authHeader = session.service?.authHeaders['Authorization']; - if (accountId == null || authHeader == null) { - return cancel(); + final prefs = await session.prefsFuture; + final store = session.accountStore; + final wifiOnly = !sync.syncOnCellular; + final signedOut = store.loadSignedOut(prefs); + + for (final account in session.accounts) { + try { + if (signedOut.contains(account.id)) { + await cancelAccount(account.id); + continue; + } + final isActive = account.id == session.activeAccountId; + final config = isActive + ? _SyncConfig( + paths: sync.syncEverything ? ['/'] : sync.syncedPaths, + intervalMinutes: sync.backgroundSyncIntervalMinutes, + ) + : _SyncConfig.fromPrefs(prefs, store.accountPrefKey, account.id); + final password = await store.readPassword(account.id); + if (password == null || + config.paths.isEmpty || + config.intervalMinutes == null) { + await cancelAccount(account.id); + continue; + } + await _channel.invokeMethod('reschedule', { + 'accountId': account.id, + 'serverUrl': account.serverUrl, + 'username': account.username, + 'authHeader': _basicAuth(account, password), + 'folders': jsonEncode(config.paths), + 'wifiOnly': wifiOnly, + 'intervalMinutes': config.intervalMinutes, + 'notify': sync.syncNotifications, + }); + } catch (e) { + debugPrint('[SyncService] Could not schedule ${account.id}: $e'); + } } - - final paths = sync.syncEverything ? ['/'] : sync.syncedPaths; - await _channel.invokeMethod('reschedule', { - 'accountId': accountId, - 'serverUrl': session.serverUrl, - 'username': session.username, - 'authHeader': authHeader, - 'folders': jsonEncode(paths), - 'wifiOnly': !sync.syncOnCellular, - }); } - static Future cancel() async { - await _channel.invokeMethod('cancel'); + static String _basicAuth(SavedAccount account, String password) => + 'Basic ${base64Encode(utf8.encode('${account.username}:$password'))}'; + + /// Stops [accountId]'s periodic job (the account was removed, or has + /// nothing left to sync). + static Future cancelAccount(String accountId) async { + await _channel.invokeMethod('cancel', {'accountId': accountId}); } - /// Runs a one-off sync pass immediately (Settings' "Sync now"), - /// independent of the periodic schedule. + /// Runs a one-off sync pass immediately, independent of the periodic + /// schedule. + /// + /// [force] (Settings' "Sync now", a newly-added path): full walk of every + /// synced root, visible progress notification, supersedes any run already + /// in flight. Without it (pull-to-refresh, foreground timer, app resume) + /// the pass is a quiet check that uses the cheap root-etag shortcut and + /// never interrupts a run already in progress. [respectNetworkSetting] + /// makes such a pass wait for Wi-Fi when "Sync on cellular" is off (the + /// automatic triggers do; a deliberate pull doesn't). static Future syncNow( SessionController session, - SyncStatusController sync, - ) async { + SyncStatusController sync, { + bool force = true, + bool respectNetworkSetting = false, + }) async { final accountId = session.activeAccountId; final authHeader = session.service?.authHeaders['Authorization']; if (accountId == null || authHeader == null) { @@ -105,26 +164,43 @@ class SyncService { 'username': session.username, 'authHeader': authHeader, 'folders': jsonEncode(paths), + 'force': force, + 'notify': sync.syncNotifications, + if (respectNetworkSetting) 'wifiOnly': !sync.syncOnCellular, }); } /// One-shot status snapshot - used to seed [SyncStatusController]'s state /// right after login/account-switch, before the first [statusStream] /// event arrives. - static Future getStatus() async { + /// + /// [accountId] is required for `syncedFileIds` to be populated on a fresh + /// app start - the native bus only knows an account once a sync pass has + /// run in this process. + static Future getStatus(String? accountId) async { final result = await _channel.invokeMethod>( 'getSyncStatus', + {'accountId': accountId}, ); return SyncStatusSnapshot.fromMap(result ?? const {}); } /// Live status updates pushed from `SyncStatusBus` (Kotlin) - syncing /// started/stopped, per-file progress, new/resolved conflicts. - static Stream get statusStream { - return _statusChannel.receiveBroadcastStream().map( - (event) => SyncStatusSnapshot.fromMap(event as Map), - ); - } + /// + /// One shared stream, deliberately: every call to + /// `EventChannel.receiveBroadcastStream()` opens its *own* native + /// subscription, and the native side only keeps one listener - a second + /// subscriber (the Offline tab's controller, once it became eagerly + /// created) silently took the events away from the first + /// (`SyncStatusController`), so sync badges stopped updating until the app + /// was restarted. This is a broadcast stream, so any number of Dart + /// listeners share the one native subscription. + static final Stream statusStream = _statusChannel + .receiveBroadcastStream() + .map( + (event) => SyncStatusSnapshot.fromMap(event as Map), + ); /// In-app conflict resolution (the header's "Keep local"/"Use server" /// buttons) - shares the exact same native `ConflictResolveWorker` @@ -184,3 +260,66 @@ class SyncService { return file.existsSync() ? file.path : null; } } + +/// One account's background-sync settings, as far as scheduling is +/// concerned. +class _SyncConfig { + final List paths; + + /// Null = background sync off for this account. + final int? intervalMinutes; + + const _SyncConfig({required this.paths, required this.intervalMinutes}); + + /// Reads a *non-active* account's persisted settings - the same per-account + /// keys `SyncStatusController`/`FilesController` write. Malformed values + /// fall back to "nothing to sync" rather than throwing. + factory _SyncConfig.fromPrefs( + SharedPreferences prefs, + String Function(String accountId, String baseKey) key, + String accountId, + ) { + var paths = []; + try { + final everything = + prefs.getBool( + key(accountId, SyncStatusController.prefSyncEverything), + ) ?? + false; + final json = prefs.getString( + key(accountId, SyncStatusController.prefSyncedPaths), + ); + if (everything) { + paths = ['/']; + } else if (json != null) { + paths = (jsonDecode(json) as List).cast(); + } + } catch (e) { + debugPrint('[SyncService] Unreadable sync paths for $accountId: $e'); + } + + var policy = defaultCachePolicy; + var minutes = defaultCacheIntervalMinutes; + try { + final policyName = prefs.getString( + key(accountId, FilesController.prefCachePolicy), + ); + policy = CachePolicy.values.firstWhere( + (c) => c.name == policyName, + orElse: () => defaultCachePolicy, + ); + minutes = + prefs.getInt( + key(accountId, FilesController.prefCacheIntervalMinutes), + ) ?? + defaultCacheIntervalMinutes; + } catch (e) { + debugPrint('[SyncService] Unreadable cache rule for $accountId: $e'); + } + + return _SyncConfig( + paths: paths, + intervalMinutes: policy == CachePolicy.interval ? minutes : null, + ); + } +} diff --git a/lib/views/account_view.dart b/lib/views/account_view.dart index 267a398..db83ffc 100644 --- a/lib/views/account_view.dart +++ b/lib/views/account_view.dart @@ -8,6 +8,7 @@ import '../providers/settings_controller.dart'; import '../providers/sync_status_controller.dart'; import '../services/sync_service.dart'; import '../theme/app_theme.dart'; +import '../widgets/app_tab_view_builder.dart'; import '../widgets/frosted_glass_container.dart'; import '../widgets/seek_bar_painter.dart'; import '../widgets/synced_header_scaffold.dart' show formatQuota; @@ -254,6 +255,25 @@ class AccountView extends StatelessWidget { const _DeviceSyncCard(), const SizedBox(height: 24), + // Files Cache + Text( + 'Files Cache', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + 'How often folder listings and synced files refresh from the server', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 10), + const _CacheSettingsCard(), + const SizedBox(height: 24), + + // Material You Design Settings Text( 'Material You Aesthetics', @@ -443,24 +463,6 @@ class AccountView extends StatelessWidget { ), const SizedBox(height: 10), const _MediaPlayerCard(), - const SizedBox(height: 24), - - // Files Cache - Text( - 'Files Cache', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 4), - Text( - 'Reuse a folder\'s listing instead of refetching it on every visit', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 10), - const _CacheSettingsCard(), ], ), ), @@ -772,8 +774,6 @@ class _DeviceSyncCardState extends State<_DeviceSyncCard> { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; final session = context.watch(); final sync = context.watch(); final folders = sync.syncedPaths; @@ -793,49 +793,37 @@ class _DeviceSyncCardState extends State<_DeviceSyncCard> { onChanged: sync.setSyncEverything, ), const Divider(height: 1, indent: 16, endIndent: 16), - if (everything) - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), - child: Text( - 'Every folder in this account is being synced to this ' - 'device.', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + ListTile( + leading: const Icon(Icons.offline_pin_rounded), + title: const Text('View offline files'), + subtitle: Text( + everything + ? 'Every folder in this account is synced' + : folders.isEmpty + ? 'Nothing synced yet' + : '${folders.length} synced folder${folders.length == 1 ? '' : 's'}/file${folders.length == 1 ? '' : 's'}', + ), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + Scaffold(body: buildAppTabView(AppTab.offline, ScrollController())), ), - ) - else if (folders.isEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), - child: Text( - 'No folders synced yet - select a folder in Files and use ' - '"Sync to device" to mirror it here for offline access.', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ) - else - for (final folder in folders) ...[ - ListTile( - leading: const Icon(Icons.sync_rounded), - title: Text(folder), - trailing: IconButton( - icon: const Icon(Icons.close_rounded), - tooltip: 'Stop syncing', - onPressed: () { - sync.removeSyncedPath(folder); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Removed from device sync'), - behavior: SnackBarBehavior.floating, - ), - ); - }, - ), - ), - const Divider(height: 1, indent: 16, endIndent: 16), - ], + ), + ), + const Divider(height: 1, indent: 16, endIndent: 16), + SwitchListTile( + secondary: const Icon(Icons.notifications_outlined), + title: const Text('Background sync notifications'), + subtitle: const Text( + 'Show a notification when a background sync updates files. ' + 'Conflicts and "Sync now" always notify.', + ), + value: sync.syncNotifications, + onChanged: sync.setSyncNotifications, + ), + const Divider(height: 1, indent: 16, endIndent: 16), SwitchListTile( secondary: const Icon(Icons.signal_cellular_alt_rounded), title: const Text('Sync on cellular'), @@ -1359,11 +1347,14 @@ class _CacheSettingsCard extends StatelessWidget { String _description(CachePolicy policy) { switch (policy) { case CachePolicy.never: - return 'Every visit to a folder fetches it fresh'; + return 'Every visit to a folder fetches it fresh; synced files are ' + 'checked each time you open the app'; case CachePolicy.interval: - return 'Reuse a folder\'s listing until it\'s a few minutes old'; + return 'Reuse a folder\'s listing until it\'s a few minutes old; ' + 'synced files update in the background on the same schedule'; case CachePolicy.manual: - return 'Reuse a folder\'s listing until you pull to refresh'; + return 'Reuse a folder\'s listing until you pull to refresh; synced ' + 'files only update when you pull down or tap Sync now'; } } @@ -1425,6 +1416,14 @@ class _CacheSettingsCard extends StatelessWidget { onChanged: (value) => files.setCacheIntervalMinutes(value.round()), ), + if (files.cacheIntervalMinutes < 15) + Text( + 'While the app is closed, synced files refresh at most ' + 'every 15 min (an Android limit).', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), ], ), ), diff --git a/lib/views/file_viewer_screen.dart b/lib/views/file_viewer_screen.dart index 7078294..cffd66d 100644 --- a/lib/views/file_viewer_screen.dart +++ b/lib/views/file_viewer_screen.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'dart:math' as math; import 'dart:typed_data'; import 'package:flutter/material.dart'; @@ -53,10 +54,30 @@ class FileViewerScreen extends StatefulWidget { /// The other items in the same folder/album, so images and videos can be /// swiped through without leaving the viewer. Non-media items in this list /// are ignored. If omitted (or the item isn't an image/video), the viewer - /// just shows [item] on its own. + /// just shows [item] on its own. Works the same way whether or not + /// [localPathResolver] is set - swiping through offline media resolves + /// each sibling's local path on demand, exactly like Files/Photos. final List? siblings; - const FileViewerScreen({super.key, required this.item, this.siblings}); + /// When set, every preview (and "open externally") resolves its file + /// from local disk through this instead of fetching from the server - + /// used by the Offline tab, whose whole point is browsing already- + /// downloaded files with no connection. Called once per item actually + /// shown (the initial item and each swiped-to sibling), not just up + /// front. Actions that need a live server (favorite/delete/download-to- + /// device) hide themselves in this mode; Share/Open-externally/Details + /// still work (Share already prefers the local copy when one exists - + /// see `ShareSheet._shareFileDirectly` - and Details' shares/activity/ + /// versions tabs degrade the same way any other offline server call + /// does). + final Future Function(NextcloudItem item)? localPathResolver; + + const FileViewerScreen({ + super.key, + required this.item, + this.siblings, + this.localPathResolver, + }); /// Opens the viewer with no page transition — media should appear /// instantly, not fade/zoom in the way MaterialPageRoute normally would. @@ -65,10 +86,14 @@ class FileViewerScreen extends StatefulWidget { static Route route({ required NextcloudItem item, List? siblings, + Future Function(NextcloudItem item)? localPathResolver, }) { return _InstantOpenPageRoute( - pageBuilder: (context) => - FileViewerScreen(item: item, siblings: siblings), + pageBuilder: (context) => FileViewerScreen( + item: item, + siblings: siblings, + localPathResolver: localPathResolver, + ), ); } @@ -112,6 +137,8 @@ class _FileViewerScreenState extends State { NextcloudItem get _currentItem => _mediaItems[_currentIndex]; + bool get _isOffline => widget.localPathResolver != null; + bool get _isPdf => _currentItem.name.toLowerCase().endsWith('.pdf'); bool get _isText => _textPreviewExtensions.contains( p.extension(_currentItem.name).toLowerCase(), @@ -173,7 +200,19 @@ class _FileViewerScreenState extends State { final item = _currentItem; setState(() => _isBusy = true); try { - final path = await _downloadToTemp(session, item); + final path = _isOffline + ? await widget.localPathResolver!(item) + : await _downloadToTemp(session, item); + if (path == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('This file is no longer available offline'), + ), + ); + } + return; + } final result = await OpenFile.open(path); if (mounted && result.type != ResultType.done) { ScaffoldMessenger.of(context).showSnackBar( @@ -371,6 +410,10 @@ class _FileViewerScreenState extends State { child: _MediaActionBar( isFavorite: _currentItem.isFavorite, isBusy: _isBusy, + // Favorite/delete/download-to-device all need a live + // server - hidden rather than shown-and-failing while + // browsing an already-local file from the Offline tab. + showServerActions: !_isOffline, opacity: settings.bottomBarOpacity, blurSigma: settings.bottomBarBlur, onShare: () => ShareSheet.show(context, _currentItem), @@ -401,21 +444,59 @@ class _FileViewerScreenState extends State { onPageChanged: (index) => setState(() => _currentIndex = index), itemBuilder: (context, index) { final mediaItem = _mediaItems[index]; - if (mediaItem.type == NextcloudItemType.image) { - return _ImagePreview( + if (!_isOffline) { + if (mediaItem.type == NextcloudItemType.image) { + return _ImagePreview( + key: ValueKey(mediaItem.id), + url: session.service!.fileUrl(mediaItem.path), + headers: session.service!.authHeaders, + ); + } + return _VideoPreview( key: ValueKey(mediaItem.id), url: session.service!.fileUrl(mediaItem.path), headers: session.service!.authHeaders, + isActive: index == _currentIndex, + controlsBottomOffset: _controlsVisible ? 108 : 24, + opacity: settings.bottomBarOpacity, + blurSigma: settings.bottomBarBlur, ); } - return _VideoPreview( + // Offline: each swiped-to item resolves its own local path on + // demand (see FileViewerScreen.localPathResolver's doc comment) - + // this is what makes swiping through offline media behave exactly + // like Files/Photos instead of only ever showing the one item the + // viewer was opened on. + return FutureBuilder( key: ValueKey(mediaItem.id), - url: session.service!.fileUrl(mediaItem.path), - headers: session.service!.authHeaders, - isActive: index == _currentIndex, - controlsBottomOffset: _controlsVisible ? 108 : 24, - opacity: settings.bottomBarOpacity, - blurSigma: settings.bottomBarBlur, + future: widget.localPathResolver!(mediaItem), + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + final localPath = snapshot.data; + if (localPath == null) { + return Center( + child: Icon( + Icons.cloud_off_rounded, + color: Theme.of(context).colorScheme.onSurfaceVariant, + size: 64, + ), + ); + } + if (mediaItem.type == NextcloudItemType.image) { + return _ImagePreview(url: null, headers: const {}, localPath: localPath); + } + return _VideoPreview( + url: null, + headers: const {}, + localPath: localPath, + isActive: index == _currentIndex, + controlsBottomOffset: _controlsVisible ? 108 : 24, + opacity: settings.bottomBarOpacity, + blurSigma: settings.bottomBarBlur, + ); + }, ); }, ); @@ -429,10 +510,18 @@ class _FileViewerScreenState extends State { return const SizedBox.shrink(); default: if (_isPdf) { - return _PdfPreview(item: widget.item, session: session); + return _PdfPreview( + item: widget.item, + session: session, + localPathResolver: widget.localPathResolver, + ); } if (_isText) { - return _TextPreview(item: widget.item, session: session); + return _TextPreview( + item: widget.item, + session: session, + localPathResolver: widget.localPathResolver, + ); } return _UnsupportedPreview( item: widget.item, @@ -449,6 +538,7 @@ class _FileViewerScreenState extends State { class _MediaActionBar extends StatelessWidget { final bool isFavorite; final bool isBusy; + final bool showServerActions; final double opacity; final double blurSigma; final VoidCallback onShare; @@ -461,6 +551,7 @@ class _MediaActionBar extends StatelessWidget { const _MediaActionBar({ required this.isFavorite, required this.isBusy, + required this.showServerActions, required this.opacity, required this.blurSigma, required this.onShare, @@ -493,30 +584,33 @@ class _MediaActionBar extends StatelessWidget { tooltip: 'Share', onTap: isBusy ? null : onShare, ), - _ActionIconButton( - icon: isFavorite - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, - tooltip: 'Favorite', - color: isFavorite ? Colors.red : null, - onTap: onFavorite, - ), + if (showServerActions) + _ActionIconButton( + icon: isFavorite + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + tooltip: 'Favorite', + color: isFavorite ? Colors.red : null, + onTap: onFavorite, + ), _ActionIconButton( icon: Icons.open_in_new_rounded, tooltip: 'Open externally', onTap: isBusy ? null : onOpenExternally, ), - _ActionIconButton( - icon: Icons.download_rounded, - tooltip: 'Download', - onTap: isBusy ? null : onDownload, - ), - _ActionIconButton( - icon: Icons.delete_outline_rounded, - tooltip: 'Delete', - color: colorScheme.error, - onTap: isBusy ? null : onDelete, - ), + if (showServerActions) + _ActionIconButton( + icon: Icons.download_rounded, + tooltip: 'Download', + onTap: isBusy ? null : onDownload, + ), + if (showServerActions) + _ActionIconButton( + icon: Icons.delete_outline_rounded, + tooltip: 'Delete', + color: colorScheme.error, + onTap: isBusy ? null : onDelete, + ), _ActionIconButton( icon: Icons.info_outline_rounded, tooltip: 'Details', @@ -567,10 +661,20 @@ class _ActionIconButton extends StatelessWidget { } class _ImagePreview extends StatefulWidget { - final String url; + final String? url; final Map headers; - const _ImagePreview({super.key, required this.url, required this.headers}); + /// When set, read the image from this on-disk path instead of [url] - + /// already-resolved by the caller (see + /// `FileViewerScreen.localPathResolver`'s doc comment). + final String? localPath; + + const _ImagePreview({ + super.key, + required this.url, + required this.headers, + this.localPath, + }); @override State<_ImagePreview> createState() => _ImagePreviewState(); @@ -646,24 +750,38 @@ class _ImagePreviewState extends State<_ImagePreview> minScale: 0.8, maxScale: 5.0, child: Center( - child: Image.network( - widget.url, - headers: widget.headers, - fit: BoxFit.contain, - loadingBuilder: (context, child, progress) { - if (progress == null) return child; - return Center( - child: CircularProgressIndicator(color: colorScheme.primary), - ); - }, - errorBuilder: (context, error, stack) => Center( - child: Icon( - Icons.broken_image_outlined, - color: colorScheme.onSurfaceVariant, - size: 64, - ), - ), - ), + child: widget.localPath != null + ? Image.file( + File(widget.localPath!), + fit: BoxFit.contain, + errorBuilder: (context, error, stack) => Center( + child: Icon( + Icons.broken_image_outlined, + color: colorScheme.onSurfaceVariant, + size: 64, + ), + ), + ) + : Image.network( + widget.url!, + headers: widget.headers, + fit: BoxFit.contain, + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return Center( + child: CircularProgressIndicator( + color: colorScheme.primary, + ), + ); + }, + errorBuilder: (context, error, stack) => Center( + child: Icon( + Icons.broken_image_outlined, + color: colorScheme.onSurfaceVariant, + size: 64, + ), + ), + ), ), ), ), @@ -672,10 +790,15 @@ class _ImagePreviewState extends State<_ImagePreview> } class _VideoPreview extends StatefulWidget { - final String url; + final String? url; final Map headers; final bool isActive; + /// When set, read the video from this on-disk path instead of [url] - + /// already-resolved by the caller (see + /// `FileViewerScreen.localPathResolver`'s doc comment). + final String? localPath; + /// Distance from the bottom of the screen the transport controls should /// sit at. Passed in by the parent so the controls can track the floating /// action bar: resting just above it when visible, sliding down to hug @@ -691,6 +814,7 @@ class _VideoPreview extends StatefulWidget { super.key, required this.url, required this.headers, + this.localPath, this.isActive = true, this.controlsBottomOffset = 24, this.opacity = 0.55, @@ -713,10 +837,12 @@ class _VideoPreviewState extends State<_VideoPreview> { Future _init() async { try { - final controller = VideoPlayerController.networkUrl( - Uri.parse(widget.url), - httpHeaders: widget.headers, - ); + final controller = widget.localPath != null + ? VideoPlayerController.file(File(widget.localPath!)) + : VideoPlayerController.networkUrl( + Uri.parse(widget.url!), + httpHeaders: widget.headers, + ); await controller.initialize(); if (!mounted) { controller.dispose(); @@ -973,7 +1099,16 @@ class _PdfPreview extends StatefulWidget { final NextcloudItem item; final SessionController session; - const _PdfPreview({required this.item, required this.session}); + /// When set, read bytes from the local path it resolves to instead of + /// fetching from the server - see + /// `FileViewerScreen.localPathResolver`'s doc comment. + final Future Function(NextcloudItem item)? localPathResolver; + + const _PdfPreview({ + required this.item, + required this.session, + this.localPathResolver, + }); @override State<_PdfPreview> createState() => _PdfPreviewState(); @@ -991,11 +1126,25 @@ class _PdfPreviewState extends State<_PdfPreview> { Future _load() async { try { - final bytes = await widget.session.service!.fetchBytes(widget.item.path); + final Uint8List bytes; + if (widget.localPathResolver != null) { + final localPath = await widget.localPathResolver!(widget.item); + if (localPath == null) { + if (mounted) { + setState(() => _error = 'No longer available offline'); + } + return; + } + bytes = await File(localPath).readAsBytes(); + } else { + bytes = Uint8List.fromList( + await widget.session.service!.fetchBytes(widget.item.path), + ); + } if (!mounted) return; setState(() { _controller = PdfControllerPinch( - document: PdfDocument.openData(Uint8List.fromList(bytes)), + document: PdfDocument.openData(bytes), ); }); } catch (e) { @@ -1040,7 +1189,16 @@ class _TextPreview extends StatefulWidget { final NextcloudItem item; final SessionController session; - const _TextPreview({required this.item, required this.session}); + /// When set, read bytes from the local path it resolves to instead of + /// fetching from the server - see + /// `FileViewerScreen.localPathResolver`'s doc comment. + final Future Function(NextcloudItem item)? localPathResolver; + + const _TextPreview({ + required this.item, + required this.session, + this.localPathResolver, + }); @override State<_TextPreview> createState() => _TextPreviewState(); @@ -1058,7 +1216,19 @@ class _TextPreviewState extends State<_TextPreview> { Future _load() async { try { - final bytes = await widget.session.service!.fetchBytes(widget.item.path); + final List bytes; + if (widget.localPathResolver != null) { + final localPath = await widget.localPathResolver!(widget.item); + if (localPath == null) { + if (mounted) { + setState(() => _error = 'No longer available offline'); + } + return; + } + bytes = await File(localPath).readAsBytes(); + } else { + bytes = await widget.session.service!.fetchBytes(widget.item.path); + } if (mounted) { setState(() => _content = utf8.decode(bytes, allowMalformed: true)); } diff --git a/lib/views/files_view.dart b/lib/views/files_view.dart index 2df04bd..45569c2 100644 --- a/lib/views/files_view.dart +++ b/lib/views/files_view.dart @@ -1,16 +1,18 @@ +import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:file_saver/file_saver.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; -import 'package:material_symbols_icons/symbols.dart'; import 'package:path/path.dart' as p; import 'package:provider/provider.dart'; import 'package:share_plus/share_plus.dart'; import '../models/nextcloud_item.dart'; import '../models/selection_action.dart'; import '../providers/files_controller.dart'; +import '../providers/folder_browser.dart'; import '../providers/item_operations.dart'; +import '../providers/offline_controller.dart'; import '../providers/pick_controller.dart'; import '../providers/session_controller.dart'; import '../providers/settings_controller.dart'; @@ -19,13 +21,14 @@ import '../services/download_service.dart'; import '../services/share_intent_service.dart'; import '../widgets/breadcrumbs.dart'; import '../widgets/details/details_sheet.dart'; +import '../widgets/files_controls_row.dart'; import '../widgets/item_icon.dart'; +import '../widgets/manage_synced_folders_sheet.dart'; +import '../widgets/media_grid_tile.dart'; import '../widgets/more_tabs_button.dart'; import '../widgets/profile_avatar_button.dart'; -import '../widgets/segmented_icon_toggle.dart'; import '../widgets/selectable_thumbnail.dart'; import '../widgets/share_sheet.dart'; -import '../widgets/sort_menu_button.dart'; import '../widgets/sticky_header_delegate.dart'; import '../widgets/swipeable_item.dart'; import '../widgets/sync_status_badge.dart'; @@ -34,10 +37,23 @@ import 'file_viewer_screen.dart'; import 'move_copy_destination_picker.dart'; import 'share_upload_view.dart'; +/// The Files tab - and, with [offline] set, the Offline tab, which is the +/// same view over the device-sync mirror instead of the server: the Files +/// tab's folders filtered down to what's available offline. The two share +/// every display pref/control, the header, breadcrumbs, list/grid tiles and +/// thumbnails; [offline] only swaps the data source (`FolderBrowser`), +/// reads images from the local file instead of a server preview, and turns +/// off everything that needs the server (selection and its bulk actions, +/// swipe actions, sync badges, the "+" menu, pick mode). class FilesView extends StatefulWidget { final ScrollController scrollController; + final bool offline; - const FilesView({super.key, required this.scrollController}); + const FilesView({ + super.key, + required this.scrollController, + this.offline = false, + }); @override State createState() => _FilesViewState(); @@ -116,6 +132,35 @@ class _FilesViewState extends State bool get _isSelecting => _selectedIds.isNotEmpty; + bool get _offline => widget.offline; + + /// The folder data source for this tab. Watches the concrete controller + /// (that's what's registered as a provider), then hands it back as the + /// [FolderBrowser] the rest of the view actually needs. + FolderBrowser _browserOf(BuildContext context) => _offline + ? context.watch() + : context.watch(); + + /// The on-device copy of [item] for the Offline tab; null online, where + /// thumbnails come from server previews instead. + File? _localFileFor(BuildContext context, NextcloudItem item) => _offline + ? context.read().localFileFor(item) + : null; + + // (Online only - the Offline tab always reloads on first build, since a + // local listing has no cheap "already loaded" signal.) + // Normally FilesController's own account-activation listener fetches the + // first listing (see FilesController._onAccountActivated) - but the + // bottom nav collapses to just the Offline tab while + // ConnectivityController briefly (mis)reports offline right after a cold + // start, which stops this view (and therefore FilesController, a lazily- + // constructed provider) from ever being built during the window that + // listener fires in. Photos/Favorites already guard against exactly this + // with their own one-shot self-fetch on first build; Files needs the same + // fallback so a folder that's genuinely empty is distinguishable from one + // that just never got its initial fetch. + bool _requestedInitialLoad = false; + @override void initState() { super.initState(); @@ -411,14 +456,26 @@ class _FilesViewState extends State Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; + // Display prefs (grid/list, filters, sort) live on FilesController for + // both tabs; `browser` is the tab's own folder listing. final files = context.watch(); + final browser = _browserOf(context); - final pathDepth = files.pathStack.length; + if (!_requestedInitialLoad) { + _requestedInitialLoad = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_offline || (browser.items.isEmpty && !browser.isLoading)) { + browser.reload(); + } + }); + } + + final pathDepth = browser.pathStack.length; final navigatingDeeper = pathDepth > _lastPathDepth; _lastPathDepth = pathDepth; - final hasBreadcrumbs = files.pathStack.length > 1; - final selectedItems = files.items + final hasBreadcrumbs = browser.pathStack.length > 1; + final selectedItems = browser.items .where((i) => _selectedIds.contains(i.id)) .toList(); @@ -430,134 +487,20 @@ class _FilesViewState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox( - height: 44, - // No persistent hint widget here - instead, a one-shot nudge - // (see _playControlsScrollHint, triggered from initState) just - // scrolls this row a little to the right and back, once, right - // after it first appears. Two static attempts before this - - // fading the controls' own opacity via ShaderMask, then a - // chevron badge overlaid on the edge - both add a - // permanent element competing with the actual controls; a - // motion cue that plays once and gets out of the way reads - // just as clearly without that cost. - child: SingleChildScrollView( - controller: _controlsScrollController, - scrollDirection: Axis.horizontal, - child: Row( - children: [ - IconButton( - icon: Icon( - files.filesSortAscending - ? Icons.arrow_upward_rounded - : Icons.arrow_downward_rounded, - size: 20, - ), - visualDensity: VisualDensity.compact, - tooltip: files.filesSortAscending - ? 'Ascending' - : 'Descending', - onPressed: files.toggleFilesSortOrder, - ), - // A plain width, not Expanded - this row scrolls - // horizontally now, which gives every control unbounded - // width to lay out in, so a flex child would throw. - SizedBox( - width: 130, - child: SortMenuButton( - field: files.filesSortField, - onChanged: files.setFilesSortField, - ), - ), - ToggleIconButton( - icon: files.showHiddenFiles - ? Icons.visibility_rounded - : Icons.visibility_off_rounded, - isSelected: files.showHiddenFiles, - onTap: () => files.toggleShowHiddenFiles(), - tooltip: 'Show hidden files', - ), - const SizedBox(width: 4), - SegmentedIconGroup( - children: [ - ToggleIconButton( - icon: Symbols.circles_rounded, - isSelected: files.storageScope == StorageScope.cloud, - onTap: () => - files.setStorageScope(StorageScope.cloud), - tooltip: 'Cloud storage', - ), - ToggleIconButton( - icon: Symbols.hard_drive_rounded, - isSelected: - files.storageScope == StorageScope.external, - onTap: () => - files.setStorageScope(StorageScope.external), - tooltip: 'External storage', - ), - ], - ), - const SizedBox(width: 8), - SegmentedIconGroup( - children: [ - ToggleIconButton( - icon: Icons.select_all_rounded, - isSelected: - files.filesTypeFilter == FilesTypeFilter.all, - onTap: () => - files.setFilesTypeFilter(FilesTypeFilter.all), - tooltip: 'Files & folders', - ), - ToggleIconButton( - icon: Icons.insert_drive_file_outlined, - isSelected: - files.filesTypeFilter == - FilesTypeFilter.filesOnly, - onTap: () => files.setFilesTypeFilter( - FilesTypeFilter.filesOnly, - ), - tooltip: 'Files only', - ), - ToggleIconButton( - icon: Icons.folder_outlined, - isSelected: - files.filesTypeFilter == - FilesTypeFilter.foldersOnly, - onTap: () => files.setFilesTypeFilter( - FilesTypeFilter.foldersOnly, - ), - tooltip: 'Folders only', - ), - ], - ), - const SizedBox(width: 8), - SegmentedIconGroup( - children: [ - ToggleIconButton( - icon: Icons.view_list_rounded, - isSelected: !files.isGridView, - onTap: () => files.setGridView(false), - tooltip: 'List view', - ), - ToggleIconButton( - icon: Icons.grid_view_rounded, - isSelected: files.isGridView, - onTap: () => files.setGridView(true), - tooltip: 'Grid view', - ), - ], - ), - ], - ), - ), + // The scroll-hint nudge (see _playScrollHint) plays on this row + // once, right after it first appears. + FilesControlsRow( + folderPath: browser.currentFolderPath, + showStorageScope: !_offline, + scrollController: _controlsScrollController, ), if (hasBreadcrumbs) ...[ const SizedBox(height: 10), SizedBox( height: 32, child: Breadcrumbs( - pathStack: files.pathStack, - onTap: (index) => files.navigateToPathIndex(index), + pathStack: browser.pathStack, + onTap: (index) => browser.navigateToPathIndex(index), ), ), ], @@ -577,12 +520,12 @@ class _FilesViewState extends State ), ), // Files List / Grid - if (files.isLoading) + if (browser.isLoading) const SliverFillRemaining( hasScrollBody: false, child: Center(child: CircularProgressIndicator()), ) - else if (files.errorMessage != null) + else if (browser.errorMessage != null) SliverFillRemaining( hasScrollBody: false, child: Center( @@ -598,7 +541,7 @@ class _FilesViewState extends State ), const SizedBox(height: 16), Text( - 'WebDAV Sync Error', + _offline ? 'Could not read local files' : 'WebDAV Sync Error', style: theme.textTheme.titleLarge?.copyWith( color: colorScheme.error, fontWeight: FontWeight.bold, @@ -606,7 +549,7 @@ class _FilesViewState extends State ), const SizedBox(height: 8), Text( - files.errorMessage!, + browser.errorMessage!, textAlign: TextAlign.center, style: theme.textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, @@ -614,16 +557,16 @@ class _FilesViewState extends State ), const SizedBox(height: 20), FilledButton.icon( - onPressed: files.refreshData, + onPressed: browser.reload, icon: const Icon(Icons.refresh_rounded), - label: const Text('Retry Connection'), + label: Text(_offline ? 'Retry' : 'Retry Connection'), ), ], ), ), ), ) - else if (files.items.isEmpty) + else if (browser.items.isEmpty) SliverFillRemaining( hasScrollBody: false, child: Center( @@ -631,13 +574,17 @@ class _FilesViewState extends State mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - Icons.folder_open_rounded, + _offline + ? Icons.offline_pin_outlined + : Icons.folder_open_rounded, size: 64, color: colorScheme.outlineVariant, ), const SizedBox(height: 12), Text( - 'Folder is empty', + _offline && !hasBreadcrumbs + ? 'Nothing downloaded yet' + : 'Folder is empty', style: theme.textTheme.titleMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -658,14 +605,14 @@ class _FilesViewState extends State mainAxisSpacing: 12, ), delegate: SliverChildBuilderDelegate((context, index) { - final item = files.items[index]; + final item = browser.items[index]; return _FolderEnterAnimation( - key: ValueKey('${files.currentFolderPath}::${item.id}'), + key: ValueKey('${browser.currentFolderPath}::${item.id}'), index: index, fromRight: navigatingDeeper, child: _buildGridCard(context, item), ); - }, childCount: files.items.length), + }, childCount: browser.items.length), ), ) else @@ -674,14 +621,14 @@ class _FilesViewState extends State padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), sliver: SliverList( delegate: SliverChildBuilderDelegate((context, index) { - final item = files.items[index]; + final item = browser.items[index]; return _FolderEnterAnimation( - key: ValueKey('${files.currentFolderPath}::${item.id}'), + key: ValueKey('${browser.currentFolderPath}::${item.id}'), index: index, fromRight: navigatingDeeper, child: _buildListTile(context, item), ); - }, childCount: files.items.length), + }, childCount: browser.items.length), ), ), @@ -696,23 +643,31 @@ class _FilesViewState extends State ]; return PopScope( - canPop: !_isSelecting && files.pathStack.length <= 1, + canPop: !_isSelecting && browser.pathStack.length <= 1, onPopInvokedWithResult: (didPop, result) { if (didPop) return; if (_isSelecting) { _clearSelection(); } else { - files.navigateUp(); + browser.navigateUp(); } }, child: SyncedHeaderScaffold( scrollController: widget.scrollController, + onRefresh: browser.reload, actions: [ - IconButton( - icon: const Icon(Icons.add_rounded), - tooltip: 'New', - onPressed: () => _showCreateMenu(context), - ), + if (_offline) + IconButton( + icon: const Icon(Icons.sync_rounded), + tooltip: 'Manage synced folders', + onPressed: () => ManageSyncedFoldersSheet.show(context), + ) + else + IconButton( + icon: const Icon(Icons.add_rounded), + tooltip: 'New', + onPressed: () => _showCreateMenu(context), + ), const MoreTabsButton(), const ProfileAvatarButton(), ], @@ -815,15 +770,17 @@ class _FilesViewState extends State ) { final allSynced = items.every((i) => sync.isPathSynced(i.path)); if (allSynced) { - for (final item in items) { - sync.removeSyncedPath(item.path); - } + // Batched (not one removeSyncedPath call per item) so the + // reschedule/native side effects fire once for the whole selection + // instead of racing each other - see addSyncedPaths' doc comment + // for why a per-item loop caused only the first item to actually + // sync. + sync.removeSyncedPaths(items.map((i) => i.path).toList()); } else { - for (final item in items) { - if (!sync.isPathSynced(item.path)) { - sync.addSyncedPath(item.path, isFolder: item.isFolder); - } - } + sync.addSyncedPaths({ + for (final item in items) + if (!sync.isPathSynced(item.path)) item.path: item.isFolder, + }); } _clearSelection(); ScaffoldMessenger.of(context).showSnackBar( @@ -989,13 +946,15 @@ class _FilesViewState extends State Widget _buildListTile(BuildContext context, NextcloudItem item) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final files = context.watch(); + final browser = _browserOf(context); final pick = context.watch(); final sync = context.watch(); final session = context.watch(); final settings = context.watch(); final ops = context.read(); final isSelected = _selectedIds.contains(item.id); + // Pick mode and selection are server-side features - never on Offline. + final picking = !_offline && pick.isPicking; // No border radius here — the outer ClipRRect below is the only place // that rounds this tile's corners. Rounding it here too would give the @@ -1007,17 +966,17 @@ class _FilesViewState extends State : colorScheme.surfaceContainerLow, child: InkWell( onTap: () { - if (pick.isPicking) { + if (picking) { _handlePickTap(context, item); } else if (_isSelecting) { _toggleSelection(item); } else if (item.isFolder) { - files.navigateToFolder(item.path); + browser.navigateToFolder(item.path); } else { _openFile(context, item); } }, - onLongPress: pick.isPicking && !pick.pickRequest!.allowMultiple + onLongPress: _offline || (picking && !pick.pickRequest!.allowMultiple) ? null : () => _toggleSelection(item), child: Padding( @@ -1034,18 +993,21 @@ class _FilesViewState extends State child: ItemThumbnail( item: item, service: session.service, + localFile: _localFileFor(context, item), size: 44, borderRadius: 12, iconSize: 22, ), ), - Positioned( - right: -2, - bottom: -2, - child: SyncStatusBadge( - status: sync.syncStatusFor(item), + // Everything on the Offline tab is synced by definition. + if (!_offline) + Positioned( + right: -2, + bottom: -2, + child: SyncStatusBadge( + status: sync.syncStatusFor(item), + ), ), - ), ], ), const SizedBox(width: 14), @@ -1078,7 +1040,7 @@ class _FilesViewState extends State ), ); - final content = _isSelecting || pick.isPicking + final content = _isSelecting || picking || _offline ? card : SwipeableItem( itemKey: ValueKey('file-${item.id}'), @@ -1111,14 +1073,19 @@ class _FilesViewState extends State } Widget _buildGridCard(BuildContext context, NextcloudItem item) { - final files = context.watch(); + final browser = _browserOf(context); final pick = context.watch(); final sync = context.watch(); final isSelected = _selectedIds.contains(item.id); - final isMedia = - (item.type == NextcloudItemType.image || - item.type == NextcloudItemType.video) && - item.previewUrl != null; + final picking = !_offline && pick.isPicking; + // Offline reads the image straight from the local mirror; videos have + // no local thumbnail (that would need a frame-extraction plugin), so + // they fall through to the plain icon card there. + final isMedia = _offline + ? item.type == NextcloudItemType.image + : (item.type == NextcloudItemType.image || + item.type == NextcloudItemType.video) && + item.previewUrl != null; return Material( color: Theme.of(context).colorScheme.surfaceContainerLow, @@ -1126,17 +1093,17 @@ class _FilesViewState extends State clipBehavior: Clip.antiAlias, child: InkWell( onTap: () { - if (pick.isPicking) { + if (picking) { _handlePickTap(context, item); } else if (_isSelecting) { _toggleSelection(item); } else if (item.isFolder) { - files.navigateToFolder(item.path); + browser.navigateToFolder(item.path); } else { _openFile(context, item); } }, - onLongPress: pick.isPicking && !pick.pickRequest!.allowMultiple + onLongPress: _offline || (picking && !pick.pickRequest!.allowMultiple) ? null : () => _toggleSelection(item), child: Stack( @@ -1148,92 +1115,36 @@ class _FilesViewState extends State ? _buildMediaGridContent(context, item) : _buildPlainGridContent(context, item), ), - Positioned( - right: 6, - bottom: 6, - child: SyncStatusBadge(status: sync.syncStatusFor(item)), - ), + if (!_offline) + Positioned( + right: 6, + bottom: 6, + child: SyncStatusBadge(status: sync.syncStatusFor(item)), + ), ], ), ), ); } - /// Grid content for images/videos: the actual preview fills the whole - /// card as a background, with the name/size legible over a bottom scrim - /// — matching a Google Photos-style grid instead of a small icon badge. + /// Grid content for images/videos - see [MediaGridTile]. Widget _buildMediaGridContent(BuildContext context, NextcloudItem item) { final session = context.watch(); - final theme = Theme.of(context); + final localFile = _localFileFor(context, item); - return LayoutBuilder( - builder: (context, constraints) { - final cachePixels = - (constraints.maxWidth * MediaQuery.of(context).devicePixelRatio) - .round(); - return Stack( - fit: StackFit.expand, - children: [ - Image.network( - item.previewUrl!, - headers: session.service?.authHeaders, - fit: BoxFit.cover, - cacheWidth: cachePixels, - cacheHeight: cachePixels, - filterQuality: FilterQuality.low, - gaplessPlayback: true, - errorBuilder: (ctx, err, stack) => - _buildPlainGridContent(context, item), - ), - if (item.type == NextcloudItemType.video) - const Center( - child: Icon( - Icons.play_circle_fill_rounded, - color: Colors.white, - size: 36, - ), + return MediaGridTile( + item: item, + imageBuilder: (cachePixels) => ResizeImage( + localFile != null + ? FileImage(localFile) + : NetworkImage( + item.previewUrl!, + headers: session.service?.authHeaders, ), - Positioned( - left: 0, - right: 0, - bottom: 0, - child: Container( - padding: const EdgeInsets.fromLTRB(10, 20, 10, 8), - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.transparent, Colors.black87], - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleSmall?.copyWith( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 12, - ), - ), - Text( - formatBytes(item.size), - style: theme.textTheme.bodySmall?.copyWith( - color: Colors.white70, - fontSize: 10, - ), - ), - ], - ), - ), - ), - ], - ); - }, + width: cachePixels, + height: cachePixels, + ), + fallbackBuilder: (ctx) => _buildPlainGridContent(ctx, item), ); } @@ -1287,6 +1198,21 @@ class _FilesViewState extends State } void _openFile(BuildContext context, NextcloudItem item) { + if (_offline) { + // Same in-app viewer (including swiping between the folder's media), + // just reading from disk instead of the server - see + // `FileViewerScreen.localPathResolver`'s doc comment. + final offline = context.read(); + Navigator.push( + context, + FileViewerScreen.route( + item: item, + siblings: offline.items, + localPathResolver: offline.localPathFor, + ), + ); + return; + } final files = context.read(); Navigator.push( context, diff --git a/lib/widgets/app_tab_view_builder.dart b/lib/widgets/app_tab_view_builder.dart index eebfc6a..f170cd8 100644 --- a/lib/widgets/app_tab_view_builder.dart +++ b/lib/widgets/app_tab_view_builder.dart @@ -14,7 +14,12 @@ import '../views/trash_view.dart'; Widget buildAppTabView(AppTab tab, ScrollController controller) { switch (tab) { case AppTab.files: - return FilesView(scrollController: controller); + // Keyed so the Files and Offline tabs (same widget type) never share + // State when the visible-tab list shifts positions in the IndexedStack. + return FilesView( + key: const ValueKey('files'), + scrollController: controller, + ); case AppTab.photos: return PhotosView(scrollController: controller); case AppTab.favorites: @@ -27,5 +32,11 @@ Widget buildAppTabView(AppTab tab, ScrollController controller) { return SharesView(scrollController: controller); case AppTab.recent: return RecentView(scrollController: controller); + case AppTab.offline: + return FilesView( + key: const ValueKey('offline'), + scrollController: controller, + offline: true, + ); } } diff --git a/lib/widgets/files_controls_row.dart b/lib/widgets/files_controls_row.dart new file mode 100644 index 0000000..9597fea --- /dev/null +++ b/lib/widgets/files_controls_row.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; +import '../providers/files_controller.dart'; +import 'segmented_icon_toggle.dart'; +import 'sort_menu_button.dart'; + +/// The Files tab's sort/hidden/scope/type-filter/view-mode controls row, +/// shared with the Offline tab so both read and write the exact same +/// [FilesController] prefs. [folderPath] picks which folder's sort applies +/// (the Files tab's current folder, or the folder being browsed offline). +/// [showStorageScope] is off for Offline - a local mirror has no +/// cloud/external distinction. +class FilesControlsRow extends StatelessWidget { + final String folderPath; + final bool showStorageScope; + final ScrollController? scrollController; + + const FilesControlsRow({ + super.key, + required this.folderPath, + this.showStorageScope = true, + this.scrollController, + }); + + @override + Widget build(BuildContext context) { + final files = context.watch(); + + return SizedBox( + height: 44, + child: SingleChildScrollView( + controller: scrollController, + scrollDirection: Axis.horizontal, + child: Row( + children: [ + IconButton( + icon: Icon( + files.sortAscendingFor(folderPath) + ? Icons.arrow_upward_rounded + : Icons.arrow_downward_rounded, + size: 20, + ), + visualDensity: VisualDensity.compact, + tooltip: files.sortAscendingFor(folderPath) + ? 'Ascending' + : 'Descending', + onPressed: () => files.toggleSortOrderFor(folderPath), + ), + // A plain width, not Expanded - this row scrolls horizontally, + // which gives every control unbounded width to lay out in, so a + // flex child would throw. + SizedBox( + width: 130, + child: SortMenuButton( + field: files.sortFieldFor(folderPath), + onChanged: (field) => files.setSortFieldFor(folderPath, field), + ), + ), + ToggleIconButton( + icon: files.showHiddenFiles + ? Icons.visibility_rounded + : Icons.visibility_off_rounded, + isSelected: files.showHiddenFiles, + onTap: () => files.toggleShowHiddenFiles(), + tooltip: 'Show hidden files', + ), + const SizedBox(width: 4), + if (showStorageScope) ...[ + SegmentedIconGroup( + children: [ + ToggleIconButton( + icon: Symbols.circles_rounded, + isSelected: files.storageScope == StorageScope.cloud, + onTap: () => files.setStorageScope(StorageScope.cloud), + tooltip: 'Cloud storage', + ), + ToggleIconButton( + icon: Symbols.hard_drive_rounded, + isSelected: files.storageScope == StorageScope.external, + onTap: () => files.setStorageScope(StorageScope.external), + tooltip: 'External storage', + ), + ], + ), + const SizedBox(width: 8), + ], + SegmentedIconGroup( + children: [ + ToggleIconButton( + icon: Icons.select_all_rounded, + isSelected: files.filesTypeFilter == FilesTypeFilter.all, + onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all), + tooltip: 'Files & folders', + ), + ToggleIconButton( + icon: Icons.insert_drive_file_outlined, + isSelected: + files.filesTypeFilter == FilesTypeFilter.filesOnly, + onTap: () => + files.setFilesTypeFilter(FilesTypeFilter.filesOnly), + tooltip: 'Files only', + ), + ToggleIconButton( + icon: Icons.folder_outlined, + isSelected: + files.filesTypeFilter == FilesTypeFilter.foldersOnly, + onTap: () => + files.setFilesTypeFilter(FilesTypeFilter.foldersOnly), + tooltip: 'Folders only', + ), + ], + ), + const SizedBox(width: 8), + SegmentedIconGroup( + children: [ + ToggleIconButton( + icon: Icons.view_list_rounded, + isSelected: !files.isGridView, + onTap: () => files.setGridView(false), + tooltip: 'List view', + ), + ToggleIconButton( + icon: Icons.grid_view_rounded, + isSelected: files.isGridView, + onTap: () => files.setGridView(true), + tooltip: 'Grid view', + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/item_icon.dart b/lib/widgets/item_icon.dart index a8ea08f..42c1f16 100644 --- a/lib/widgets/item_icon.dart +++ b/lib/widgets/item_icon.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import '../models/nextcloud_item.dart'; import '../services/nextcloud_service.dart'; @@ -56,10 +57,16 @@ class ItemThumbnail extends StatelessWidget { final double borderRadius; final double iconSize; + /// The on-device copy of [item] (the Offline tab's synced mirror) - when + /// set, images render from this file instead of a server preview, so + /// thumbnails work with no connection. + final File? localFile; + const ItemThumbnail({ super.key, required this.item, required this.service, + this.localFile, required this.size, required this.borderRadius, required this.iconSize, @@ -71,6 +78,7 @@ class ItemThumbnail extends StatelessWidget { final isMedia = item.type == NextcloudItemType.image || item.type == NextcloudItemType.video; + final useLocal = item.type == NextcloudItemType.image && localFile != null; // Decode straight to the size this thumbnail is actually painted at — // the server hands back a 500x500 preview regardless, and decoding that // in full for a ~40dp tile (times however many are on screen while @@ -86,16 +94,22 @@ class ItemThumbnail extends StatelessWidget { color: iconColor.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(borderRadius), ), - child: isMedia && item.previewUrl != null + child: useLocal || (isMedia && item.previewUrl != null) ? Stack( fit: StackFit.expand, children: [ - Image.network( - item.previewUrl!, - headers: service?.authHeaders, + Image( + image: ResizeImage( + useLocal + ? FileImage(localFile!) as ImageProvider + : NetworkImage( + item.previewUrl!, + headers: service?.authHeaders, + ), + width: cachePixels, + height: cachePixels, + ), fit: BoxFit.cover, - cacheWidth: cachePixels, - cacheHeight: cachePixels, filterQuality: FilterQuality.low, gaplessPlayback: true, loadingBuilder: (context, child, progress) { diff --git a/lib/widgets/manage_synced_folders_sheet.dart b/lib/widgets/manage_synced_folders_sheet.dart new file mode 100644 index 0000000..83db338 --- /dev/null +++ b/lib/widgets/manage_synced_folders_sheet.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/sync_status_controller.dart'; + +/// The configured sync targets (folders/files) with a "stop syncing" +/// action per target - what used to be Settings' Device Sync card's own +/// inline list, moved into a sheet here so the main Offline view can look +/// like a plain Files-style browser instead of always showing this +/// management UI up top. +class ManageSyncedFoldersSheet extends StatelessWidget { + final SyncStatusController sync; + + const ManageSyncedFoldersSheet({super.key, required this.sync}); + + static void show(BuildContext context) { + final sync = context.read(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh, + showDragHandle: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (sheetContext) => ManageSyncedFoldersSheet(sync: sync), + ); + } + + void _remove(BuildContext context, String path) { + sync.removeSyncedPath(path); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Removed from device sync'), + behavior: SnackBarBehavior.floating, + ), + ); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: sync, + builder: (context, _) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final everything = sync.syncEverything; + final targets = sync.syncedPaths; + + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Synced to this device', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + if (everything) + Text( + 'Every folder in this account is being synced to this ' + 'device.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ) + else if (targets.isEmpty) + Text( + 'Nothing synced yet - select a folder or file in Files ' + 'and use "Sync to device" to mirror it here for offline ' + 'access.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ) + else + Flexible( + child: ListView.separated( + shrinkWrap: true, + itemCount: targets.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final target = targets[index]; + return ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.sync_rounded), + title: Text(target), + trailing: IconButton( + icon: const Icon(Icons.close_rounded), + tooltip: 'Stop syncing', + onPressed: () => _remove(context, target), + ), + ); + }, + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/widgets/media_grid_tile.dart b/lib/widgets/media_grid_tile.dart new file mode 100644 index 0000000..2347ea9 --- /dev/null +++ b/lib/widgets/media_grid_tile.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import '../models/nextcloud_item.dart'; +import 'synced_header_scaffold.dart' show formatBytes; + +/// Grid content for an image/video: the actual preview fills the whole card +/// as a background, with the name/size legible over a bottom scrim - +/// matching a Google Photos-style grid instead of a small icon badge. +/// Shared by the Files and Offline tabs; they differ only in where the +/// pixels come from ([imageBuilder] - a server preview vs. the local +/// mirror file) and what to show if that fails ([fallbackBuilder]). +class MediaGridTile extends StatelessWidget { + final NextcloudItem item; + + /// Given the pixel width to decode at, returns the image to paint. + /// Decoding straight to the painted size (rather than the full 500x500 + /// server preview / full-resolution local file) avoids a real source of + /// scrolling jank. + final ImageProvider Function(int cachePixels) imageBuilder; + final WidgetBuilder fallbackBuilder; + + const MediaGridTile({ + super.key, + required this.item, + required this.imageBuilder, + required this.fallbackBuilder, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return LayoutBuilder( + builder: (context, constraints) { + final cachePixels = + (constraints.maxWidth * MediaQuery.of(context).devicePixelRatio) + .round(); + return Stack( + fit: StackFit.expand, + children: [ + Image( + image: imageBuilder(cachePixels), + fit: BoxFit.cover, + filterQuality: FilterQuality.low, + gaplessPlayback: true, + errorBuilder: (ctx, err, stack) => fallbackBuilder(ctx), + ), + if (item.type == NextcloudItemType.video) + const Center( + child: Icon( + Icons.play_circle_fill_rounded, + color: Colors.white, + size: 36, + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + padding: const EdgeInsets.fromLTRB(10, 20, 10, 8), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black87], + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + Text( + formatBytes(item.size), + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.white70, + fontSize: 10, + ), + ), + ], + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/widgets/more_tabs_button.dart b/lib/widgets/more_tabs_button.dart index 9832b5a..48d98ef 100644 --- a/lib/widgets/more_tabs_button.dart +++ b/lib/widgets/more_tabs_button.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/app_tab.dart'; +import '../providers/connectivity_controller.dart'; import '../providers/pick_controller.dart'; import '../providers/settings_controller.dart'; import 'app_tab_view_builder.dart'; @@ -16,13 +17,16 @@ class MoreTabsButton extends StatelessWidget { Widget build(BuildContext context) { final pick = context.watch(); final settings = context.watch(); + final connectivity = context.watch(); // While picking (another app's GET_CONTENT request), MainShellView // already restricts the bottom nav to Files/Photos - this button would // otherwise be the one remaining way to reach a tab that isn't a valid // pick source (Trash, Shares, ...), so it hides entirely rather than - // just disabling. - if (pick.isPicking) return const SizedBox.shrink(); + // just disabling. Same idea while offline: every hidden tab needs a + // network request to show anything useful, so there's nothing this + // button could usefully open. + if (pick.isPicking || connectivity.isOffline) return const SizedBox.shrink(); return PopupMenuButton( icon: const Icon(Icons.apps_rounded), diff --git a/lib/widgets/synced_header_scaffold.dart b/lib/widgets/synced_header_scaffold.dart index 530547b..4592065 100644 --- a/lib/widgets/synced_header_scaffold.dart +++ b/lib/widgets/synced_header_scaffold.dart @@ -1,8 +1,10 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../models/nextcloud_item.dart'; import '../models/sync_status.dart'; +import '../providers/connectivity_controller.dart'; import '../providers/files_controller.dart'; import '../providers/session_controller.dart'; import '../providers/sync_status_controller.dart'; @@ -11,8 +13,12 @@ import '../providers/sync_status_controller.dart'; /// status (see `SyncStatusController.syncHeaderStatus`), not the WebDAV /// directory-listing refresh the pull gesture itself triggers (that has /// its own, separate floating spinner bubble - see -/// `_SyncedHeaderScaffoldState`'s `_isRefreshing`). -(IconData, String) _syncHeaderDisplay(SyncHeaderStatus status) { +/// `_SyncedHeaderScaffoldState`'s `_isRefreshing`). [isOffline] takes +/// priority over everything else - with no connection at all, "nothing +/// configured to sync" and "can't sync right now" are different facts, +/// and the latter is the one actually worth surfacing. +(IconData, String) _syncHeaderDisplay(SyncHeaderStatus status, bool isOffline) { + if (isOffline) return (Icons.cloud_off_rounded, 'Offline'); return switch (status) { SyncHeaderStatus.off => (Icons.cloud_off_rounded, 'Sync off'), SyncHeaderStatus.syncing => (Icons.cloud_sync_rounded, 'Syncing…'), @@ -21,25 +27,25 @@ import '../providers/sync_status_controller.dart'; }; } -/// The expanded sync panel's headline - actual folder/item counts instead -/// of just repeating the compact chip's generic "Synced" label, which reads -/// as true even when nothing has actually finished syncing yet (e.g. right -/// after adding a folder, before the first pass completes). -String _syncSummary(SyncStatusController sync) { +/// The expanded sync panel's headline - counts of what's actually +/// *configured* to sync (folders/files) instead of just repeating the +/// compact chip's generic "Synced" label. Deliberately doesn't add up the +/// individual files inside a synced folder ("1 folder synced", not "1 +/// folder & 4 items synced") - once a folder's synced, its file count is +/// an implementation detail, not something the user picked. +String _syncSummary(SyncStatusController sync, bool isOffline) { + if (isOffline) return 'Offline'; if (sync.syncConflicts.isNotEmpty) return 'Sync issue'; if (sync.isSyncingNow) return 'Syncing…'; - final items = sync.syncedItemCount; - if (sync.syncEverything) { - return items > 0 ? '$items item${items == 1 ? '' : 's'} synced' : 'Sync off'; - } + if (sync.syncEverything) return 'Whole account synced'; final folders = sync.syncedFolderCount; - if (folders == 0) return 'Sync off'; - final folderWord = folders == 1 ? 'folder' : 'folders'; - if (items == 0) { - return '$folders $folderWord selected — nothing synced yet'; - } - final itemWord = items == 1 ? 'item' : 'items'; - return '$folders $folderWord & $items $itemWord synced'; + final files = sync.syncedPaths.length - folders; + if (folders == 0 && files == 0) return 'Sync off'; + final parts = [ + if (folders > 0) '$folders folder${folders == 1 ? '' : 's'}', + if (files > 0) '$files file${files == 1 ? '' : 's'}', + ]; + return '${parts.join(' & ')} synced'; } String formatBytes(int bytes) { @@ -143,6 +149,9 @@ class _SyncedHeaderScaffoldState extends State { final refresh = widget.onRefresh ?? context.read().refreshData; setState(() => _isRefreshing = true); + // A deliberate pull also checks synced folders/files for server changes + // - the "sync" header is exactly where you'd expect that to happen. + unawaited(context.read().syncOnPull()); refresh().whenComplete(() { if (mounted) setState(() => _isRefreshing = false); }); @@ -318,7 +327,8 @@ class _SyncStatusChip extends StatelessWidget { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final status = context.watch().syncHeaderStatus; - final (icon, label) = _syncHeaderDisplay(status); + final isOffline = context.watch().isOffline; + final (icon, label) = _syncHeaderDisplay(status, isOffline); final iconColor = status == SyncHeaderStatus.alert ? colorScheme.error : colorScheme.primary; @@ -372,6 +382,7 @@ class _SyncedStretchPanel extends StatelessWidget { final quota = context.watch().quota; final serverUrl = context.watch().serverUrl; final syncStatus = context.watch(); + final isOffline = context.watch().isOffline; return LayoutBuilder( builder: (context, constraints) { @@ -382,7 +393,7 @@ class _SyncedStretchPanel extends StatelessWidget { // Reaches 1.0 (title fully hidden) at 50px of pull — comfortably // before the 100px lock threshold. final progress = forceVisible ? 1.0 : (stretch / 50).clamp(0.0, 1.0); - final statusLabel = _syncSummary(syncStatus); + final statusLabel = _syncSummary(syncStatus, isOffline); final conflicts = syncStatus.syncConflicts; return Stack( diff --git a/pubspec.lock b/pubspec.lock index 66a1b9c..9bfe81a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -89,6 +89,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec + url: "https://pub.dev" + source: hosted + version: "6.1.5" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" + url: "https://pub.dev" + source: hosted + version: "2.1.0" cross_file: dependency: transitive description: @@ -568,6 +584,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" objective_c: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index fc892c1..b70eff4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -56,6 +56,7 @@ dependencies: local_auth: ^2.3.0 webview_flutter: ^4.14.1 permission_handler: ^11.3.1 + connectivity_plus: ^6.1.5 dev_dependencies: flutter_test: diff --git a/test/widget_test.dart b/test/widget_test.dart index 0aa914d..d98c857 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -4,9 +4,11 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:noo/main.dart'; +import 'package:noo/providers/connectivity_controller.dart'; import 'package:noo/providers/favorites_controller.dart'; import 'package:noo/providers/files_controller.dart'; import 'package:noo/providers/item_operations.dart'; +import 'package:noo/providers/offline_controller.dart'; import 'package:noo/providers/photos_controller.dart'; import 'package:noo/providers/pick_controller.dart'; import 'package:noo/providers/recent_controller.dart'; @@ -20,6 +22,12 @@ void main() { const secureStorageChannel = MethodChannel( 'plugins.it_nomads.com/flutter_secure_storage', ); + const connectivityChannel = MethodChannel( + 'dev.fluttercommunity.plus/connectivity', + ); + const connectivityStatusChannel = EventChannel( + 'dev.fluttercommunity.plus/connectivity_status', + ); setUpAll(() { GoogleFonts.config.allowRuntimeFetching = false; @@ -29,10 +37,22 @@ void main() { if (call.method == 'readAll') return {}; return null; }); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(connectivityChannel, (call) async { + if (call.method == 'check') return ['wifi']; + return null; + }); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockStreamHandler( + connectivityStatusChannel, + MockStreamHandler.inline( + onListen: (arguments, events) {}, + ), + ); }); test('SessionController initializes unauthenticated', () { - final session = SessionController(); + final session = SessionController(ConnectivityController()); expect(session.isLoggedIn, false); }); @@ -43,13 +63,17 @@ void main() { MultiProvider( // Mirrors main.dart's own provider order/wiring. providers: [ - ChangeNotifierProvider(create: (_) => SessionController()), + ChangeNotifierProvider(create: (_) => ConnectivityController()), + ChangeNotifierProvider( + create: (context) => SessionController(context.read()), + ), ChangeNotifierProvider(create: (_) => SettingsController()), ChangeNotifierProvider( - create: (context) => SyncStatusController(context.read()), + create: (context) => FilesController(context.read()), ), ChangeNotifierProvider( - create: (context) => FilesController(context.read()), + create: (context) => + SyncStatusController(context.read(), context.read()), ), ChangeNotifierProvider( create: (context) => @@ -71,6 +95,10 @@ void main() { ChangeNotifierProvider( create: (context) => PickController(context.read()), ), + ChangeNotifierProvider( + create: (context) => + OfflineController(context.read(), context.read()), + ), Provider( create: (context) => ItemOperations( session: context.read(), diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index b9467ec..025a298 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -16,6 +17,8 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); DynamicColorPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); FileSaverPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 21e18a6..4a9d14d 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + connectivity_plus dynamic_color file_saver flutter_secure_storage_windows