Documents the app-lock layer and multi-account storage/session model in architecture.md/server.md, and adds a standards.md note that `flutter install` wipes app data (it uninstalls before installing) — use `adb install -r` for local test deploys instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
10 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 in a Chrome Custom Tab (
url_launcher,LaunchMode.inAppBrowserView— real Chrome, so saved passwords/autofill work, unlike Flutter's own embedded web view); the user authenticates and authorizes there. There's no way to close the tab automatically on success (Login Flow v2 never redirects back into the app, and a Custom Tab belongs to Chrome's own task) — the user switches back manually. ServerProviderpollsLoginFlowService.poll(pollEndpoint, token)every 2 seconds (Timer.periodic, see_pollTimer/_pollTimeoutTimerinserver_provider.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
ServerProvider.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. - 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). - Downloads stream through
Dio(downloadToFile) for progress callbacks; small in-app previews (text/PDF) usefetchBytesviapackage:http. - Uploads (
uploadFileFromPath(folderPath, fileName, localFilePath, {onProgress})) stream the local file viaDio().put()with an explicitContent-LengthandonSendProgress, mirroring the download path. TheServerProviderwrapper always uploads into_currentFolderPath— the share-to-upload flow (ShareUploadView) gets a caller-chosen destination by navigating there first (navigateToAbsoluteFolder), then uploading. - Receiving a shared file from another app:
receive_sharing_intent(AndroidACTION_SEND/ACTION_SEND_MULTIPLE,android:launchModesingleTaskin the manifest so a second share while running hitsonNewIntentinstead of spawning a new instance).MainShellViewlistens viagetInitialMedia()/getMediaStream()and pushesShareUploadView, which reuses the sameuploadFileFromPathpath after the user picks a destination folder.
Multi-account storage & session persistence
AccountStore owns everything
account-identity-related; ServerProvider 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 reloaded on every switch viaServerProvider._applyAccountPrefs. - 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,
ServerProvider._init()awaits the migration, loads the account list + active id, then_restoreSession()looks up the active account's password and calls_applyCredentialsForAccount(the renamed, generation-guarded, account-aware version of what used to be_applyCredentials) to rebuild the session without re-hitting the login flow. - Switching accounts (
switchAccount/cycleToNextAccount/cycleToPreviousAccount/removeAccount's fallback, plus landing on a freshly-added account) all funnel through the single_activateAccountengine: bump_sessionGeneration, cancel any pending login flow, clear every content field without ever settingisLoggedInfalse (that's the detail that keepsmain.dart's root routing from bouncing throughLoginViewmid-switch), reload the target account's prefs, 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
!provider.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 ServerProvider 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.