- Folders now show the synced status badge themselves, not just their nested files - device-sync state never tracked folders individually, so syncStatusFor derives a folder's badge from whether its path falls within sync scope instead. - Turning sync off for a path now deletes its local mirror and clears its native sync-state entries (SyncEngine.removeLocalSync), instead of just stopping future updates and leaving the downloaded copy behind. - "Sync to device" in Files' selection toolbar now works over the whole selection at once instead of being gated to a single selected item. - The sync header's expanded panel shows real folder/item counts instead of a static "Synced" label that stayed accurate-sounding even before anything had actually synced. - Confirmation SnackBars on starting/stopping sync from both the selection toolbar and Settings' Device Sync card. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
34 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), respect a local deletion (file's gone and the server didn't change either - don't recreate it), or flag a conflict (both changed since the last recorded state).SyncWorker.kt(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.- 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.SyncService.getStatus()(one-shot, seedsSyncStatusControllerright after login/account-switch) 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
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/syncOnCellularchange, andSyncService.cancelon logout/last-account- removed. 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).- 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).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. 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). 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.
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.