- WebDAV MOVE/COPY-backed copy/move for files and folders, with a destination picker (independent nav state so it doesn't disturb the Files tab's browsing position) and a conflict-resolution sheet (overwrite all / keep both / decide per item). - Downloads now use the same foreground-service + notification approach as uploads, surviving app closure. - Favorites is now its own tab (account-wide, via a WebDAV SEARCH query) instead of a Files-tab filter toggle, which couldn't represent favorited items outside the currently browsed folder correctly. - Share sheet restyled to match the app's design language, plus a "share file directly" action via the OS share sheet; details sheet tab icons resized to match the bottom nav. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
22 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. 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/ServerProvider.favoriteItems/fetchAllFavorites/_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 (navigateToAbsoluteFolder+requestTab); a favorited file opens directly from the Favorites tab itself.deleteItem/renameItem/move/ copy all re-sync_allFavoritesafterward via_syncFavoritesIfLoaded(only once Favorites has actually been opened this session, tracked by_favoritesEverFetched, 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 waytoggleItemFavoritedoes (added/removed/updated by id, right inline). 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 conflictServerProvider.moveItems/copyItemsneed to detect without a separate existence-check request per item.ServerProviderattempts 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 (
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.