- Let Noo be picked as a source (Files + Photos) by other apps' native file/photo pickers via Android's GET_CONTENT intent, mirroring the existing "Share to Noo" plumbing: MainActivity.kt/PickIntentService/ PickRequest, ServerProvider.pickRequest/confirmPick/cancelPick, and pick-mode tap handling in FilesView/PhotosView. While picking, the bottom nav and MoreTabsButton are restricted to Files/Photos only. - Fix two account bugs: testConnection() failures no longer delete a still-valid stored app password (only an actual 401 does), and switchAccount() now retries a nominally-active-but-logged-out account instead of no-op'ing - both were silently locking users out of "Continue as" with no way back in except removing and re-adding the account (now exposed via login_view.dart's inline remove button). - Fix the login WebView: clear cookies before every load so "Add Account" can't silently reuse an existing session, and push it as a non-animated route to avoid a known WebView-as-PlatformView black-screen- on-pop issue under Impeller. - Redesign the Files/Photos selection toolbar to take over the top app bar entirely (SyncedHeaderScaffold.selectionBar) instead of sitting as a second bar under the controls row, with a horizontally-scrollable, uniformly-colored actions row and the same one-shot scroll-hint nudge the controls row uses. - Redesign ShareUploadView to share the same SyncedHeaderScaffold chrome as every tab, with the uploading-file summary (marqueed via the new shared MarqueeTitle widget) and upload button grouped into one rounded/elevated bottom sheet. - Restyle ShareSheet to open on the same DetailsHeader every other per-item sheet uses, and fix a couple of small design-language drifts (hardcoded text style, a non-rounded icon). - Settings' account card: icon buttons with tooltips instead of text buttons for refresh/logout/remove, divider removed.
17 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). 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: 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 sameServerProviderfields 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.ServerProvider.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).ServerProvider.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.
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.