- 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 <noreply@anthropic.com>
51 KiB
Server integration
Auth: Login Flow v2
The app never collects a Nextcloud password directly. It implements
Login Flow v2
via LoginFlowService:
LoginFlowService.initiate(serverUrl)POSTs to{server}/index.php/login/v2, gets back a browser login URL + a poll endpoint/token.- The app opens the login URL for the user to authenticate/authorize in
LoginWebViewView, a normal screen this app owns (package:webview_flutter) - pushed byLoginViewthe momentloginFlowStatusflips toawaitingBrowser, for every login (first account or an additional one), not just add-account. This used to be split: first login went through a Chrome Custom Tab (url_launcher,LaunchMode.inAppBrowserView) for Chrome's own autofill, and only add-account used the embedded WebView, specifically to avoid a Custom Tab silently reusing Chrome's existing session for a different account. But a Custom Tab has real costs even for the first login - no way to close it automatically on success (the user has to switch back manually), and it's a separate task outside this app's own navigation entirely - so both paths now use the same owned screen.url_launcheris no longer a dependency. The cost is no Chrome-autofill (Android's own system Autofill framework, e.g. a password manager, may still work in the WebView; Chrome's own saved-password autofill specifically cannot, since that's Chrome-only).LoginWebViewViewalso clears cookies (WebViewCookieManager(). clearCookies()) before every load, not just once - Android's WebViewCookieManageris a single store shared/persisted across every WebView instance in the app process, not scoped per-controller, so without this a second/"Add Account" login silently reuses whichever account's Nextcloud session cookie is already there instead of prompting for credentials (the same class of bug the Custom-Tab-reuse issue above was, just recurring one layer down once everything moved to the owned WebView). SessionControllerpollsLoginFlowService.poll(pollEndpoint, token)every 2 seconds (Timer.periodic, see_pollTimer/_pollTimeoutTimerinsession_controller.dart) until it gets a 200 withserver/loginName/appPassword, a non-404 error, or a 10-minute timeout. A single dropped connection mid-poll (http.ClientException) is swallowed and retried on the next tick rather than aborting the whole flow.- The returned app password (scoped, revocable) is what gets stored and used for every subsequent request — real user passwords are never in memory or on disk.
LoginFlowStatus (idle → initiating → awaitingBrowser → error)
drives LoginView's UI; see LoginFlowService's doc comment for the full
flow rationale before changing it.
startLoginFlow(serverUrl, {addAccount = false}) is reused verbatim for
both the first/only login and "add another account" (pushed from Settings
while already logged into a different account, LoginView(isAddingAccount: true)) — addAccount only flags isAddAccountFlow for the UI (so the
pushed screen knows to auto-pop on success and cancel the flow on
back-swipe); the persistence path on success is identical either way, see
below.
Talking to the server
NextcloudService is the
client for an authenticated session — constructed with serverUrl +
username + the app password, one instance per login (held as
SessionController.service, recreated on every login/switch/logout). It's
effectively stateless per-instance (three final fields, headers rebuilt per
request), which is what makes it trivial to have one saved per account
rather than needing a rewrite for multi-account support.
- Files: WebDAV (
PROPFIND/MKCOL/DELETE/MOVE/PUTetc. against/remote.php/dav/files/{username}/...) via rawhttp/diocalls with a hand-rolled XML request body andpackage:xmlfor parsing responses — there is no WebDAV client dependency._parseDavDate/_davPathin this file exist because WebDAV responses use RFC 1123 dates and either bare paths or full URLs forhref; reuse them rather than re-deriving. - Everything else (shares, activity, trash, favorites, quota, user info,
file versions) goes through Nextcloud's OCS APIs (
/ocs/v2.php/...), JSON in, with theOCS-APIRequest: trueheader required on every OCS call. Toggling a favorite is OCS; listing every favorite (fetchFavorites, for the Favorites tab) is WebDAV instead - the sameSEARCHmechanismfetchAllMedia/fetchRecentFilesuse, filtered byoc:favoriteinstead of mimetype/date. Favorites is a real tab (views/favorites_view.dart/FavoritesController.items/fetchAll/_allFavorites), not a filter toggle scoped to whatever folder the Files tab happens to be browsing (that's what it used to be - see the note below on why that didn't work). It shares Files' own sort/hidden/storage-scope/grid-list display prefs (applyFilesDisplayPrefs, also used by the Move/Copy destination picker) rather than a separate parallel settings dimension. Tapping a favorited folder switches to the Files tab, navigated there (FilesController.navigateToAbsoluteFolder+SettingsController.requestTab); a favorited file opens directly from the Favorites tab itself.ItemOperations.deleteItem/renameItem/move/ copy all re-syncFavoritesController._allFavoritesafterward viaFavoritesController.syncIfLoaded(only once Favorites has actually been opened this session, tracked by_everFetched, so those actions don't pay for an extra request on every edit for an account that's never visited the tab) since none of them know how to patch_allFavoritesin place the wayItemOperations.toggleItemFavoritedoes (added/removed/ updated by id, right inline viaFavoritesController.applyFavoriteToggle). Note: this used to be a "favorites-only" filter toggle on the Files tab's controls row instead of its own tab, filtering the currently browsed folder's_items. That had two real bugs in sequence: first, the filter dropped every non-favorited item including folders, so a non-favorited folder (containing a favorited item nested inside) vanished from the listing entirely, with no way to navigate into it; fixing that by exempting folders from the filter was itself wrong, because the actual intent was for favorites-only to show every favorited item account-wide, not just direct children of whatever folder was open - a strict filter over the wrong scope. Converting it to a real tab, backed by an account-wide fetch, was the actual fix; keep favorites account-wide rather than reintroducing a current-folder-scoped filter. - Auth header is HTTP Basic (
username:appPassword, base64), built in_headers/exposed asauthHeadersfor widgets that need to hit URLs directly (e.g.Image.network(url, headers: service.authHeaders)for thumbnails/previews). NextcloudService.downloadToFile(Dio, progress callbacks) is still used for small in-app-only downloads: text/PDF previews (fetchBytesviapackage:http) and "open externally" (FileViewerScreen._downloadToTempstreams to the app's own cache dir soopen_filecan hand it to another app). Explicitly saving a file to the device - the "Download" action in Files/Photos' selection toolbar and the media viewer's download button- instead hands off to
DownloadService.kt, an Android foreground service, the same way "Share to Noo" hands its upload off toShareUploadService.kt(see that section below) rather than downloading in Dart and promptingfile_saverper file: a real Service survives the app being closed mid-download, with one cancellable notification for the whole batch.DownloadService.ktre-implements a plain WebDAV GET in Kotlin for the same reasonShareUploadService.kt's PUT does - keepNextcloudService.downloadToFilein sync manually if download semantics change - and writes straight into the device's public Downloads collection viaMediaStore.Downloads(API 29+; a background Service can't promptfile_saver's SAF picker the way the Flutter/Activity side can, so this is the direct equivalent) with a legacyEnvironment.DIRECTORY_DOWNLOADSfile-write fallback pre-Android-10. Folders are filtered out client-side before handing off (no recursive/ zip download support);DetailsVersionsTab's version-restore download andShareSheet's "Share file directly" (native OS share, not saved to Downloads) keep usingdownloadToFiledirectly instead, since both need the bytes in-app rather than saved to Downloads.
- instead hands off to
- Move/Copy (
moveItem/copyItem(itemPath, destFolderPath, {overwrite, newName})) share one private_moveOrCopyhelper withrenameItem(itself just a same-folder MOVE) - WebDAVMOVE/COPYare the same request shape, just a different verb, and both are recursive by default for a folder ("collection"), so no extraDepthheader is needed. They return the raw HTTP status rather than a bool:412 Precondition Failedis WebDAV's standard signal for "something's already there" whenOverwrite: F, which is exactly the conflictItemOperations.moveItems/copyItemsneed to detect without a separate existence-check request per item.ItemOperationsattempts every item in the batch first, collects conflicts intoMoveCopyResult.conflicts(models/move_copy_result.dart), and only then shows one summary (MoveCopyConflictSheet) instead of prompting per conflict as they're hit; resolving picks overwrite/keep-both (auto-renamed via_nextAvailableNameagainst a fresh listing of the destination, fetched once up front, not per item)/skip per item viaresolveConflicts. The destination-picker screen behind this (views/move_copy_destination_picker.dart) is covered inarchitecture.md, including why it can't reuse the Files tab's shared navigation state the wayShareUploadViewdoes. - Uploads: there's no more in-app-only upload path -
NextcloudService's ownuploadFileFromPathwas removed once the Files tab's "+" → "Upload File" was unified with the share-to-upload flow (below). Every upload, however it's triggered, now goes throughShareUploadView(caller-chosen destination viaFilesController.navigateToAbsoluteFolder, thenUploadService/ShareUploadService.kt). - Receiving a shared file from another app: hand-rolled in
MainActivity.kt(AndroidACTION_SEND/ACTION_SEND_MULTIPLE,android:launchModesingleTaskin the manifest so a second share while running hitsonNewIntentinstead of spawning a new instance) plusShareIntentServiceon the Dart side - not thereceive_sharing_intentplugin, which this used to be. That plugin resolves a sharedcontent://Uri by synchronously copying the entire file into the cache dir on the main thread during activity startup; for a large file that blocks long enough that Android kills the newly-launched activity for failing to draw a first frame, dropping the user straight back to the home screen with no error and no Dart code ever running.MainActivity.kt's doc comment has the full story. The fix:getInitialShare/onNewShareonly ever query cheap Uri metadata (name/size/mime, not content) soShareUploadView's destination picker - which mirrors the Files tab's own controls/filters/listing, reusing the sameFilesControllerfields andwidgets/item_icon.dart- always appears instantly regardless of file size. - Uploading a shared file: once the user picks a destination in
ShareUploadView,UploadServicehands the whole batch off toShareUploadService.kt, an Android foreground service, rather than uploading from Dart in that screen. This is deliberate, not just an implementation detail: the point is that closing the app right after confirming a destination doesn't interrupt the upload, the same guarantee a real file-manager app's upload notification gives you - a plain DartFuture(even one kept alive by a singleton service class) stops running once the Flutter engine/Activity are gone, only an actual AndroidServicesurvives that. The service re-implements the WebDAV PUT itself in Kotlin (HttpURLConnection, no new HTTP dependency) since it can't reach the Dart-sideNextcloudService/ Dio from a separate process lifecycle -UploadService.startUploadpasses everything the Kotlin side needs (the pre-builtAuthorizationheader fromNextcloudService.authHeaders, not the raw password) as Intent extras, a one-way handoff with no channel back to Dart afterward. Keep the two upload implementations in sync manually if upload semantics change. Progress/cancellation is entirely notification-driven (one ongoing, updatable notification for the whole batch; its Cancel action re-delivers an Intent to the same running service instance, which anAtomicBooleanthe copy/upload loops poll) - there's no plumbing back to the Dart UI, by design, since the app may not even be running.
Being picked by other apps (photo/file picker)
Noo can also be launched by another app as a GET_CONTENT picker (e.g.
Google Drive/Instagram's "choose a file" flow), the reverse direction of
"Share to Noo" above - hand-rolled the same way, not a plugin.
MainActivity.ktmatchesACTION_GET_CONTENT(OPENABLE, any mimeType - a single*/*filter, since Android matches it against whatever the caller actually requested) alongside its existingACTION_SEND/SEND_MULTIPLEfilters, and exposes the caller's requested mimeType/multi-select flag/app label via thedev.ayushya.noo/pick_intentmethod+event channel pair (PickIntentService/PickRequest) - same cold-start-vs-already-running split as the share-intent channels.PickController.pickRequest/isPickingdrive picking mode app-wide onceMainShellViewlearns about a request at startup or viaonNewPickRequest. While picking,MainShellViewrestricts the visible bottom-nav tabs to just Files and Photos (seearchitecture.md) -FilesView/PhotosViewroute taps throughitemMatchesPickFilter/confirmPickinstead of their normal open/select behavior (folders still navigate; a mime-mismatched file is rejected with a snackbar; matching files toggle-select or immediately confirm depending onPickRequest.allowMultiple).PickController.confirmPickdownloads the selected item(s) to apicker/scratch subfolder in the app's cache dir (downloadToFile, same as any other download) - each item into its ownpicker/<item.id>/subfolder, keeping the on-disk filename as plainitem.namerather than prefixing it with the id to dodge collisions between same-named items; the caller reads that on-disk name back as the display name, so prefixing it there was a real bug (Drive showing e.g.163332_photo.jpginstead ofphoto.jpg) - then hands the local paths toPickIntentService.finishPick, which calls back intoMainActivity.kt.finishPick: it wraps each file in acontent://Uri via this app's ownFileProvider(${applicationId}.picker.fileprovider, scoped to just that cache subfolder - seeandroid/app/src/main/res/xml/ file_paths.xml) and returns it to the caller viasetResult. Single file usessetDataAndType(never.data =/.type =as two separate calls - each one silently nulls out the other field on a plainIntent); multiple files useClipData.cancelPickmirrors this for backing out (system back while picking, or a picked-item mismatch) withRESULT_CANCELEDinstead.
Device sync
Mirrors selected folders to app-private local storage
(getExternalFilesDir(null)/sync/<accountId>/... - wiped on uninstall, no
extra storage permission needed) and keeps them updated in the background,
even with the app fully closed. Same rationale as the upload/download
services for going native instead of a Dart background-task plugin (see
above): a periodic job has to run without the Flutter engine loaded, and a
notification action has to resolve without launching the UI. Rather than
add workmanager (whose Dart callbackDispatcher spins up a second,
minimal Flutter engine that has to re-register every plugin it touches),
the whole engine is plain Kotlin using Android's WorkManager directly.
SyncEngine.ktholds the shared primitives, reused by both workers below: a Depth-1 (or Depth-0, for refreshing one item) PROPFIND (propfindChildren/propfindSelf) requestingd:getetagalongside the usual props - unlike every PROPFIND innextcloud_service.dart, which never requests it (etagis a dead field onNextcloudItemtoday); plainHttpURLConnectionGET/PUT (downloadFile/uploadFile, same style asDownloadService.kt/ShareUploadService.kt); anddiffFolder, which compares one folder's freshly-walked manifest against the persisted per-account sync-state map (nativeSharedPreferences, JSON keyed byoc:fileid- stable across renames/moves, unlikepath) to decide, per file: download (new, or serveretagchanged), upload (local file's mtime/size changed and the server didn't), delete locally (missing 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(CoroutineWorker) is both the periodic job and the one-off "Sync now": for each configured path,propfindSelfs it first to check whether it's a file or a folder - a folder gets the full recursivewalkRemoteTree, a file is diffed directly as a one-item list (nothing else aboutdiffFolder/download/upload/delete cares whether its entries came from a walk or a single lookup, so single-file sync needed no engine changes, just this one branch) - appliesdiffFolder's decisions, then posts a summary notification (files updated/uploaded/removed) and, for 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 finalnotifySummarycall naturally replaces the ongoing progress notification in place once the run finishes (no flicker of two separate notifications), and if nothing ended up changing,doWorkexplicitly cancels that ID since there's no summary to replace it with.- Live status reaches Dart via a push channel, not polling -
SyncStatusBus.ktis a plain in-process pub/sub (no IPC needed - the workers andMainActivityshare one process) thatSyncWorker/ConflictResolveWorkerpublish into (syncing started/stopped, whichfileIds are mid-transfer right now, new/resolved conflicts) andMainActivity.kt'sdev.ayushya.noo/sync_service/statusEventChannelforwards to Dart, same pattern as the share/pick-intent channels. It deliberately does not track "which files are already synced" itself - that's read fresh fromSyncEngine'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 - which is also whySyncWorker.doWork()saves state before its finalSyncStatusBus.setSyncing(accountId, false)publish, not after: that publish is what tells Dart's live listener to recomputesyncedFileIds, 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 viagetStatus().SyncService.getStatus(accountId)(one-shot, seedsSyncStatusControllerright after login/account-switch - the account id is passed explicitly becauseSyncStatusBusonly learns an account once a sync pass has run in this process, so on a fresh app start it'd report emptysyncedFileIds;SyncStatusController._applySnapshotlikewise ignores the bus's initial null-account emission so it can't wipe that seed) andSyncService.statusStream(live) both return the same snapshot shape.SyncStatusController.syncHeaderStatus(off/syncing/done/alert - drivesSyncedHeaderScaffold's persistent chip/panel, replacing what used to be the WebDAV-refresh-loading indicator there) andsyncStatusFor(item)(none/syncing/synced/conflict- drives the small corner badge on Files' tiles,
SyncStatusBadge) are both computed from this state, not fetched per-item. Folders never get their own entry in the native state map (diffFolderonly ever tracks individual files -if (entry.isFolder) continue), sosyncStatusForderives a folder's badge differently than a file's:syncedonce the folder's own path (or an ancestor of it) is in sync scope (_isPathInSyncScope, shared withlocalSyncedFilePath),syncingwhile any sync pass is running,conflictif a pending conflict'sremotePathfalls under it - not fromsyncedFileIds, which only ever contains individual files.
- drives the small corner badge on Files' tiles,
- In-app conflict resolution reuses the exact same enqueue path as the
notification actions -
ConflictResolveWorker.enqueue(...)is a shared companion function;SyncConflictReceiver(the notification action) andMainActivity.kt'sresolveConflictMethodChannel method (the sync header's "Keep local"/"Use server" buttons,SyncStatusController.resolveSyncConflict) both just call it, so there's one resolution code path regardless of which surface triggered it. - Conflicts are never auto-resolved. The notification's "Keep local"/
"Use server" actions are
PendingIntent.getBroadcasts (same shape as the Cancel action on upload/download notifications, just broadcast instead of service-targeted) toSyncConflictReceiver- a manifest-registered
BroadcastReceiver(works even with the app process dead) that can't itself block on network, so it just dismisses the notification and enqueues a one-shotConflictResolveWorkerto actually push the local copy up or pull the server copy down and refresh that file's recorded state.
- a manifest-registered
- 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 WorkManagerPeriodicWorkRequestatmax(interval, 15)min (Android's floor) viareschedule(intervalMinutes:), plus an in-appTimerat 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 (SyncedHeaderScaffoldcallssyncOnPull, on any tab) and "Sync now". There's no server push: Nextcloud's push options (notify_pushneeds 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'sgetetag, so each run first does one Depth-0 PROPFIND per synced root and skips the recursive walk if the etag matches the storedSyncEngine.RootMarkerand 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 areforce: 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) throwRemoteUnavailableExceptionand 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'sdev.ayushya.noo/sync_servicechannel (reschedule/cancel/syncNow/removeLocalSync) is the only bridge from Dart: a periodicWorkRequest's inputDataandConstraintsare fixed at enqueue time, so changing the synced-folder list, the active account, or the Wi-Fi-only setting means cancelling and re-enqueueing, not updating in place.SyncService(Dart) wraps this -SyncStatusControllercallsSyncService.rescheduleafter every successful login/account switch and every synced-folder/ cache-rule/syncOnCellularchange.reschedulecovers 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 fromAccountStore), paths and interval - the active account's from liveSyncStatusControllerstate, the others' from their persisted per-account prefs (_SyncConfig.fromPrefs). Logging out stops that account's background sync:SessionController.logoutrecords it inAccountStore's signed-out set (so a laterreschedulefor another account doesn't resurrect its job) and cancels it viaSyncService.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,
SyncWorkercallsSyncEngine.mirrorFolders(a local directory for every remote folder, so an empty folder created on the web appears on device) andpruneRemovedFolders(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 viasetRootMissing; the snapshot'smissingRootsletsSyncStatusController._applySnapshotdrop 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 usefile_downloads_v2/share_upload_v2at default importance (progress silent, completion audible). Channel importance can't be changed once created, so changing it means a new channel id plusNooNotificationChannels.ensure(legacyIds = ...)deleting the old one. - One sync at a time.
SyncEngine.syncLock(a process-wide coroutineMutex) wraps bothSyncWorkerandConflictResolveWorker. 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
SyncStatusBusonly tracks one account at a time. A run that has to wait simply starts when the current one finishes. The network constraint isNetworkType.UNMETEREDby default (!syncOnCellular, Wi-Fi only) orNetworkType.CONNECTEDif the user's opted into cellular sync. Turning sync off for a path (SyncStatusController.removeSyncedPath) also callsSyncService.removeLocalSync, which runsSyncEngine.removeLocalSyncon a background thread (deletes the local mirror files under that path and their entries in the native sync-state map - clearing state too, not just the files, matters because a bare "file's gone but the server hasn't changed" without a state reset reads as a user-initiated local deletion todiffFolder, so a later re-add wouldn't re-download anything).
- a pull, two accounts due at once) silently discarded each other's
updates, and
- Synced-path list (
SyncStatusController.syncedPaths- files or folders, not just folders despite the name of the underlying pref/nativeDatakey, which stayedui_synced_folders/foldersto avoid a storage-key migration for a rename) follows the standard per-account-pref pattern (JSON-encoded string list, inAccountStore.perAccountPrefKeys); a parallel_syncedPathTypesmap (path -> isFolder,ui_synced_folder_types) 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).addSyncedPaths/removeSyncedPathsgate on_accountLoadedGate(aCompleter, deliberately starting incomplete - not pre-completed - completed once_onAccountActivated's async per-account prefs load actually finishes, re-armed on_onAccountCleared) before touching_syncedPathsat 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_onAccountActivatedfrom re-reading_syncedPathsfrom storage more than once per account - activation can fire again for the same account (e.g.SessionControllerre-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,SyncServicesends['/']as the path list instead ofsyncedPaths, mirroring the whole account rather than requiring per-item opt-in.syncOnCellularis a plain global pref.syncEverythingandsyncOnCellularare 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 throughSyncStatusController.addSyncedPaths/removeSyncedPaths(batched), never a per-item loop ofaddSyncedPath/removeSyncedPath- looping was an actual bug: eachaddSyncedPathcall fires its ownSyncService.syncNow, andsyncNow's native side enqueues viaWorkManager.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_syncedPathsfor the whole set and callreschedule/syncNowexactly once, with the complete folder list, soSyncWorkerhandles every item in one run (it already loops its wholefolderslist sequentially within a singledoWork()call - see above). - The Offline tab (
FilesView(offline: true)overOfflineController, seearchitecture.md'sFolderBrowser) 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<externalFilesDir>/sync/<accountId>/<currentFolder>(non-recursiveDirectory.list()per folder) viadart:io, not fetched from the server (mirrorsSyncEngine.kt#syncRoot's layout exactly), so it works with no connection and never round-trips through a MethodChannel just to list files.OfflineControllerrefetches the current folder automatically whenever aSyncService.statusStreamsnapshot shows syncing just stopped, so newly-downloaded files show up without a manual pull-to-refresh. Tapping an item opensFileViewerScreenwithlocalPathset (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 viaOfflineController.localPathFor(item), a pure function of the item's path - deliberately notSyncStatusController.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" fromsyncedPathscould 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 triggersSyncService.syncNowfollowed 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.proexists specifically for this feature, and keepsandroidx.work.**wholesale rather than naming individual classes. Flutter's own Gradle plugin auto-enables R8 minification for release builds (FlutterPlugin.ktsetsisMinifyEnabled = trueunconditionally for thereleasebuild type, and auto-wires this exact file if it exists - nothing in this project's ownbuild.gradle.ktsopts into it), which broke device sync on real-device testing twice in a row: firstWorkDatabase(WorkManager locates its bundled Room database by reflecting off the abstract database class's own, possibly-renamed, name), then - after narrowly keeping just that class -OverwritingInputMerger(WorkManager's default input merger, also reflection-instantiated) broke the exact same way and silently ate everyenqueueUniqueWorkcall, including "Sync now", with zero indication beyond aWM-InputMergerNoSuchMethodExceptionin logcat - no crash, no Dart-visible error, just a folder that stayed empty. R8's member-level shrinking strips whatever a class's reflection-only callers don't reference directly, even when the class itself survives a plain-keep classwith no wildcard, and WorkManager reflects into more of its own internals than any one test pass is likely to exercise - hence the wholesale keep instead of chasing individual classes one crash at a time. If adding another native background component reached only via reflection (not a manifest-declared component, which AGP already keeps automatically), don't assume default AndroidX consumer rules cover it - verify on an actual release build, not justflutter analyze/a debug build, since minification only applies to release.- Files land under
Android/data/<package>/files/sync/...(getExternalFilesDir), which no third-party file manager can browse without root - Android's scoped storage sandboxes that whole directory tree from other apps by design, same as any app-private storage. This surprised real-device testing (a file manager app logged "Can't read directory ... trying su" and came up empty even though the sync had actually worked) - it's expected, not a bug. Confirm synced files landed viaadb shell run-as/a rooted shell, not a regular file manager UI. - Already-synced files skip the network in two places:
SyncStatusController.localSyncedFilePath(item)is a pure function of the remote path (mirrorsSyncEngine.kt'ssyncRootlayout exactly, so Dart never needs to read the native sync-stateSharedPreferences) that returns the local mirror path if it exists on disk.ShareSheet's "Share file directly" andFilesView._downloadSelected(only when every selected file is already synced - a mixed selection still goes through the normalDownloadServicebatch) both check it first.
Working offline
ConnectivityController
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._applyCredentialsForAccountchecksconnectivity.isOfflinebefore callingNextcloudService.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 = truewith the already-constructed_servicekept around,_isProvisionalLogin = true. Only an actual 401 (_errorMessagecontains "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 onMainShellView(restricted to the Offline tab - see below) instead of bouncing toLoginView, which would strand the user with no way back in short of Login Flow v2 again (switchAccountno-ops when "switching" to the account that's already nominally active, andactiveAccountIdis never cleared by a network failure).
- 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:
- 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 - whatFilesController/PhotosController/FavoritesController/TrashController/SharesController/RecentControllerall register for, since their own activation work is a network fetch) vs.addAccountReadyListener(fires on either a verified or a provisional login - whatSyncStatusController/OfflineControllerregister 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 onlyready, neveractivated; a verified login fires both. - Reconnecting re-verifies automatically.
SessionControllerlistens toconnectivityitself; the moment it flips from offline to online while_isProvisionalLoginis still true, it re-runs_applyCredentialsForAccountwith the same cached account/password. On success this is what finally fires_notifyAccountActivatedfor real, so Files/Photos/etc. get their first actual fetch without the user having to force-quit/restart the app. MainShellViewcollapses the bottom nav to just the Offline tab whileconnectivity.isOffline, the same override mechanism already used for picking mode (seearchitecture.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.MoreTabsButtonhides 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 abool isOfflineand 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. FileViewerScreencan read a file straight from disk. Its optionallocalPathResolverparam (Future<String?> Function(NextcloudItem), only ever set by the Offline tab, passingOfflineController. localPathFor) swaps every preview widget's data source -_ImagePreview/_VideoPreviewuseImage.file/VideoPlayerController.fileinstead of the.network/.networkUrlvariants,_PdfPreview/_TextPreviewread viaFile(path).readAsBytes()instead ofNextcloudService.fetchBytes- same in-app viewer either way, no separate "offline preview" screen. Unlike a single up-front path, a resolver is what makessiblings(swipe-between-media) behave identically to Files/Photos while offline: the swipeablePageView.buildercalls it again for whichever sibling you've swiped to (wrapped in aFutureBuilder, since each resolution is an async disk check), not just the item the viewer opened on - the Offline tab passes its whole current folder'sitemsassiblings, 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 (_openExternallycalls the resolver directly instead of downloading to a temp file first; Share already prefers a local copy when one exists, seeShareSheet._shareFileDirectly).
Multi-account storage & session persistence
AccountStore owns everything
account-identity-related; SessionController owns everything about which
account is currently live (see architecture.md).
- Per-account secrets: one
flutter_secure_storagekey per account,nc_app_password_<accountId>— nevershared_preferences.accountIdis deterministic (SavedAccount.makeId(serverUrl, username), a slug of both), so re-adding the same account refreshes its password instead of creating a duplicate. - Account identity list (non-secret: id/serverUrl/username) and
which one is active live in
shared_preferencesasaccounts_list(JSON array) andactive_account_id. - Global UI prefs (theme, dynamic color, AMOLED, bottom-bar
opacity/blur, tap-to-scroll-top, seek bar style, tab order/hidden/default,
swipe actions) stay flat, un-namespaced
shared_preferenceskeys — same as before multi-account, untouched by switching. - Per-account browsing prefs (grid/list view, favorites-only ×2,
storage scope, show-hidden ×2, Photos sort field/ascending, Files'
per-folder sort map, cache policy/interval — the full list is
AccountStore.perAccountPrefKeys) are namespacedacct_<accountId>_<key>and owned by whichever controller that domain belongs to (e.g.FilesControllerfor grid/list/storage-scope/sort,PhotosControllerfor Photos' own sort/filter). Each reloads its own slice in its_onAccountActivatedlistener, registered viaSessionController.addAccountActivatedListenerin its constructor - there's no longer one central method that reloads every domain's prefs at once;SessionControllerjust fires the notification, and every sibling controller reacts independently. - Legacy migration:
AccountStore.migrateLegacyIfNeededruns once ever (guarded by theaccount_migration_v1_doneflag), turning a pre-multi- account install's 3 flat secure-storage keys + flat browsing prefs into the first saved (and active) account, so upgrading users are never logged out. Never assume the legacy keys are gone — always check the migration flag rather than the keys' absence. - On startup,
SessionController's constructor awaits the migration, loads the account list + active id, then_restoreSession()looks up the active account's password and calls_applyCredentialsForAccount(generation-guarded, account-aware) to rebuild the session without re-hitting the login flow - firing_notifyAccountActivated()on success, which is what triggers every sibling controller's own initial fetch. - Switching accounts (
switchAccount/cycleToNextAccount/cycleToPreviousAccount/removeAccount's fallback, plus landing on a freshly-added account) all funnel through the single_activateAccountengine: bumpsessionGeneration, cancel any pending login flow, call_notifyAccountCleared()(every sibling controller's registeredaddAccountClearedListenercallback resets that controller's own state) without ever settingisLoggedInfalse (that's the detail that keepsmain.dart's root routing from bouncing throughLoginViewmid-switch), then verify its credentials and refetch everything. This is a full teardown-and-reload every time — there is deliberately no simultaneous multi-account state or background sync; only one account's content is ever live. logout()vsremoveAccount()are deliberately different actions, both funneling into a shared_deactivateSession()helper for the teardown/pointer-clearing part:logout()ends the active session but keeps the account itself fully intact (password, prefs, its entry inaccountsall untouched) — always lands onLoginVieweven if other accounts are saved (it does not fall back to one of them the wayremoveAccountdoes). This exists soLoginViewcan offer a "Continue as ..." one-tap resume list (_SavedAccountsSectioninlogin_view.dart) with no Login Flow v2 needed - logging out must never be mistaken for forgetting an account.removeAccount(id)deletes everything for that account (secure-storage password, namespaced prefs, itsaccountsentry) and, only if it was the active one, falls back to another saved account or - if none remain - calls the same_deactivateSession(). This is the only path (besideslogout()) that can setisLoggedInfalse, and the only one that's actually destructive/irreversible - UI call sites (AccountView) gate it behind a confirmation dialog;logout()doesn't need one.- Any UI code that calls either and might have ended the session should
check
!session.isLoggedInafterward andNavigator.popUntil((r) => r.isFirst)if so — otherwise a screen pushed on top (Settings) is left stranded over a root route that's silently swapped toLoginViewunderneath it. Don't pop unconditionally — removing a non-active account, or one that fell back to another, keeps the user logged in and Settings should just stay open.
App lock (login lock)
An orthogonal, app-wide security layer on top of the Nextcloud
login/session above — not account credentials, just a gate on using the
app. AppLockService wraps
local_auth; this app never implements its own PIN entry/storage/hashing —
authenticate() always delegates to whatever the OS already has configured
(biometric, or device PIN/pattern/password as fallback, via
biometricOnly: false). Never build a custom in-app PIN screen for this —
extend AppLockService/the SessionController gates described in
architecture.md instead.
Android native requirements (both already done, keep them if you touch
these files): MainActivity.kt must extend FlutterFragmentActivity, not
the default FlutterActivity — local_auth's Android implementation hosts
its prompt via a Fragment and silently fails to build/crashes without it.
AndroidManifest.xml needs <uses-permission android:name="android.permission.USE_BIOMETRIC"/> (also declared by the
plugin's own manifest via merge, but kept explicit here too).
android/app/build.gradle.kts floors minSdk at 24 (local_auth_android's
own requirement) via maxOf(24, flutter.minSdkVersion) rather than trusting
Flutter's own default to already be high enough.
isRestoringSession still gates the splash screen until the above resolves
— see standards.md for why widget tests must mock both storage channels
rather than relying on this async path throwing naturally.