Files
noo/.claude/context/server.md
T
ayushyaandClaude Sonnet 5 49ae712f5e
Build APK / build (push) Successful in 5m35s
Fix device sync: folder status badges, remove-deletes-local, multi-select, header summary
- 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>
2026-09-20 17:20:14 -04:00

528 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Server integration
## Auth: Login Flow v2
The app **never** collects a Nextcloud password directly. It implements
[Login Flow v2](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2)
via [`LoginFlowService`](../../lib/services/login_flow_service.dart):
1. `LoginFlowService.initiate(serverUrl)` POSTs to
`{server}/index.php/login/v2`, gets back a browser login URL + a poll
endpoint/token.
2. The app opens the login URL for the user to authenticate/authorize in
[`LoginWebViewView`](../../lib/views/login_webview_view.dart), a normal
screen this app owns (`package:webview_flutter`) - pushed by `LoginView`
the moment `loginFlowStatus` flips to `awaitingBrowser`, 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_launcher` is 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).
`LoginWebViewView` also clears cookies (`WebViewCookieManager().
clearCookies()`) before every load, not just once - Android's WebView
`CookieManager` is 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).
3. `SessionController` polls `LoginFlowService.poll(pollEndpoint, token)` every
2 seconds (`Timer.periodic`, see `_pollTimer`/`_pollTimeoutTimer` in
`session_controller.dart`) until it gets a 200 with `server`/`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.
4. 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`](../../lib/services/nextcloud_service.dart) 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`/`PUT` etc. against
`/remote.php/dav/files/{username}/...`) via raw `http`/`dio` calls with a
hand-rolled XML request body and `package:xml` for parsing responses —
there is no WebDAV client dependency. `_parseDavDate`/`_davPath` in this
file exist because WebDAV responses use RFC 1123 dates and either bare
paths or full URLs for `href`; 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 the `OCS-APIRequest: true` header required on every OCS call.
*Toggling* a favorite is OCS; *listing* every favorite (`fetchFavorites`,
for the Favorites tab) is WebDAV instead - the same `SEARCH` mechanism
`fetchAllMedia`/`fetchRecentFiles` use, filtered by `oc:favorite` instead
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-sync `FavoritesController._allFavorites` afterward via
`FavoritesController.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 `_allFavorites` in
place the way `ItemOperations.toggleItemFavorite` does (added/removed/
updated by id, right inline via `FavoritesController.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 as `authHeaders` for 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 (`fetchBytes` via
`package:http`) and "open externally" (`FileViewerScreen._downloadToTemp`
streams to the app's own cache dir so `open_file` can 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 to
`ShareUploadService.kt` (see that section below) rather than downloading
in Dart and prompting `file_saver` per file: a real Service survives the
app being closed mid-download, with one cancellable notification for the
whole batch. `DownloadService.kt` re-implements a plain WebDAV GET in
Kotlin for the same reason `ShareUploadService.kt`'s PUT does - keep
`NextcloudService.downloadToFile` in sync manually if download semantics
change - and writes straight into the device's public Downloads
collection via `MediaStore.Downloads` (API 29+; a background Service
can't prompt `file_saver`'s SAF picker the way the Flutter/Activity side
can, so this is the direct equivalent) with a legacy
`Environment.DIRECTORY_DOWNLOADS` file-write fallback pre-Android-10.
Folders are filtered out client-side before handing off (no recursive/
zip download support); `DetailsVersionsTab`'s version-restore download
and `ShareSheet`'s "Share file directly" (native OS share, not saved to
Downloads) keep using `downloadToFile` directly instead, since both need
the bytes in-app rather than saved to Downloads.
- **Move/Copy** (`moveItem`/`copyItem(itemPath, destFolderPath, {overwrite,
newName})`) share one private `_moveOrCopy` helper with `renameItem`
(itself just a same-folder MOVE) - WebDAV `MOVE`/`COPY` are the same
request shape, just a different verb, and both are recursive by default
for a folder ("collection"), so no extra `Depth` header is needed. They
return the raw HTTP status rather than a bool: `412 Precondition Failed`
is WebDAV's standard signal for "something's already there" when
`Overwrite: F`, which is exactly the conflict `ItemOperations.moveItems`/
`copyItems` need to detect without a separate existence-check request
per item. `ItemOperations` attempts every item in the batch first,
collects conflicts into `MoveCopyResult.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
`_nextAvailableName` against a fresh listing of the destination, fetched
once up front, not per item)/skip per item via `resolveConflicts`. The
destination-picker screen behind this
(`views/move_copy_destination_picker.dart`) is covered in
`architecture.md`, including why it can't reuse the Files tab's shared
navigation state the way `ShareUploadView` does.
- **Uploads**: there's no more in-app-only upload path - `NextcloudService`'s
own `uploadFileFromPath` was 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 through `ShareUploadView` (caller-chosen
destination via `FilesController.navigateToAbsoluteFolder`, then
`UploadService`/`ShareUploadService.kt`).
- **Receiving a shared file from another app**: hand-rolled in
`MainActivity.kt` (Android `ACTION_SEND`/`ACTION_SEND_MULTIPLE`,
`android:launchMode` `singleTask` in the manifest so a second share while
running hits `onNewIntent` instead of spawning a new instance) plus
[`ShareIntentService`](../../lib/services/share_intent_service.dart) on
the Dart side - not the `receive_sharing_intent` plugin, which this used
to be. That plugin resolves a shared `content://` 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`/`onNewShare` only ever query cheap Uri
metadata (name/size/mime, not content) so `ShareUploadView`'s destination
picker - which mirrors the Files tab's own controls/filters/listing,
reusing the same `FilesController` fields and `widgets/item_icon.dart` -
always appears instantly regardless of file size.
- **Uploading a shared file**: once the user picks a destination in
`ShareUploadView`, [`UploadService`](../../lib/services/upload_service.dart)
hands the whole batch off to `ShareUploadService.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 Dart `Future` (even one kept alive by a
singleton service class) stops running once the Flutter engine/Activity
are gone, only an actual Android `Service` survives that. The service
re-implements the WebDAV PUT itself in Kotlin (`HttpURLConnection`, no
new HTTP dependency) since it can't reach the Dart-side `NextcloudService`/
Dio from a separate process lifecycle - `UploadService.startUpload` passes
everything the Kotlin side needs (the pre-built `Authorization` header
from `NextcloudService.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 an
`AtomicBoolean` the 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.kt` matches `ACTION_GET_CONTENT` (`OPENABLE`, any
mimeType - a single `*/*` filter, since Android matches it against
whatever the caller actually requested) alongside its existing
`ACTION_SEND`/`SEND_MULTIPLE` filters, and exposes the caller's requested
mimeType/multi-select flag/app label via the
`dev.ayushya.noo/pick_intent` method+event channel pair
([`PickIntentService`](../../lib/services/pick_intent_service.dart)/
[`PickRequest`](../../lib/models/pick_request.dart)) - same
cold-start-vs-already-running split as the share-intent channels.
- `PickController.pickRequest`/`isPicking` drive picking mode app-wide once
`MainShellView` learns about a request at startup or via
`onNewPickRequest`. While picking, `MainShellView` restricts the visible
bottom-nav tabs to just Files and Photos (see `architecture.md`) -
`FilesView`/`PhotosView` route taps through
`itemMatchesPickFilter`/`confirmPick` instead 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 on `PickRequest.allowMultiple`).
- `PickController.confirmPick` downloads the selected item(s) to a
`picker/` scratch subfolder in the app's cache dir (`downloadToFile`,
same as any other download) - each item into its own `picker/<item.id>/`
subfolder, keeping the on-disk filename as plain `item.name` rather 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.jpg`
instead of `photo.jpg`) - then hands the local paths to
`PickIntentService.finishPick`, which calls back into
`MainActivity.kt.finishPick`: it wraps each file in a `content://` Uri
via this app's own `FileProvider` (`${applicationId}.picker.fileprovider`,
scoped to just that cache subfolder - see `android/app/src/main/res/xml/
file_paths.xml`) and returns it to the caller via `setResult`. Single
file uses `setDataAndType` (never `.data =`/`.type =` as two separate
calls - each one silently nulls out the other field on a plain
`Intent`); multiple files use `ClipData`. `cancelPick` mirrors this for
backing out (system back while picking, or a picked-item mismatch) with
`RESULT_CANCELED` instead.
## 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.kt`](../../android/app/src/main/kotlin/dev/ayushya/noo/SyncEngine.kt)
holds the shared primitives, reused by both workers below: a Depth-1 (or
Depth-0, for refreshing one item) PROPFIND (`propfindChildren`/
`propfindSelf`) requesting `d:getetag` alongside the usual props - unlike
every PROPFIND in `nextcloud_service.dart`, which never requests it
(`etag` is a dead field on `NextcloudItem` today); plain
`HttpURLConnection` GET/PUT (`downloadFile`/`uploadFile`, same style as
`DownloadService.kt`/`ShareUploadService.kt`); and `diffFolder`, which
compares one folder's freshly-walked manifest against the persisted
per-account sync-state map (native `SharedPreferences`, JSON keyed by
`oc:fileid` - stable across renames/moves, unlike `path`) to decide, per
file: download (new, or server `etag` changed), 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`](../../android/app/src/main/kotlin/dev/ayushya/noo/SyncWorker.kt)
(`CoroutineWorker`) is both the periodic job and the one-off "Sync now":
for each configured path, `propfindSelf`s it first to check whether it's
a file or a folder - a folder gets the full recursive
`walkRemoteTree`, a file is diffed directly as a one-item list (nothing
else about `diffFolder`/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) - applies `diffFolder`'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.kt`](../../android/app/src/main/kotlin/dev/ayushya/noo/SyncStatusBus.kt)
is a plain in-process pub/sub (no IPC needed - the workers and
`MainActivity` share one process) that `SyncWorker`/`ConflictResolveWorker`
publish into (syncing started/stopped, which `fileId`s are mid-transfer
right now, new/resolved conflicts) and `MainActivity.kt`'s
`dev.ayushya.noo/sync_service/status` `EventChannel` forwards 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
from `SyncEngine`'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, seeds `SyncStatusController`
right after login/account-switch) and `SyncService.statusStream` (live)
both return the same snapshot shape. `SyncStatusController.syncHeaderStatus`
(off/syncing/done/alert - drives `SyncedHeaderScaffold`'s persistent
chip/panel, replacing what used to be the WebDAV-refresh-loading
indicator there) and `syncStatusFor(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 (`diffFolder` only ever tracks
individual files - `if (entry.isFolder) continue`), so `syncStatusFor`
derives a folder's badge differently than a file's: `synced` once the
folder's own path (or an ancestor of it) is in sync scope
(`_isPathInSyncScope`, shared with `localSyncedFilePath`), `syncing`
while any sync pass is running, `conflict` if a pending conflict's
`remotePath` falls under it - not from `syncedFileIds`, which only ever
contains individual files.
- **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) and `MainActivity.kt`'s `resolveConflict` MethodChannel 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.getBroadcast`s (same shape as the
Cancel action on upload/download notifications, just broadcast instead of
service-targeted) to
[`SyncConflictReceiver`](../../android/app/src/main/kotlin/dev/ayushya/noo/SyncConflictReceiver.kt)
- 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-shot
[`ConflictResolveWorker`](../../android/app/src/main/kotlin/dev/ayushya/noo/ConflictResolveWorker.kt)
to actually push the local copy up or pull the server copy down and
refresh that file's recorded state.
- `MainActivity.kt`'s `dev.ayushya.noo/sync_service` channel
(`reschedule`/`cancel`/`syncNow`/`removeLocalSync`) is the only bridge
from Dart: a periodic `WorkRequest`'s input `Data` and `Constraints` are
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`](../../lib/services/sync_service.dart)
(Dart) wraps this - `SyncStatusController` calls `SyncService.reschedule`
after every successful login/account switch and every synced-folder/
`syncOnCellular` change, and `SyncService.cancel` on logout/last-account-
removed. The network constraint is `NetworkType.UNMETERED` by default
(`!syncOnCellular`, Wi-Fi only) or `NetworkType.CONNECTED` if the user's
opted into cellular sync. Turning sync off for a path
(`SyncStatusController.removeSyncedPath`) also calls
`SyncService.removeLocalSync`, which runs `SyncEngine.removeLocalSync` on
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 to `diffFolder`, 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/native `Data` key,
which stayed `ui_synced_folders`/`folders` to avoid a storage-key
migration for a rename) follows the standard per-account-pref pattern
(JSON-encoded string list, in `AccountStore.perAccountPrefKeys`); a
parallel `_syncedPathTypes` map (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, `SyncService` sends `['/']` as the path list instead
of `syncedPaths`, mirroring the whole account rather than requiring
per-item opt-in. `syncOnCellular` is 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.pro` exists specifically for this feature,
and keeps `androidx.work.**` wholesale rather than naming individual
classes.** Flutter's own Gradle plugin auto-enables R8 minification for
release builds (`FlutterPlugin.kt` sets `isMinifyEnabled = true`
unconditionally for the `release` build type, and auto-wires this exact
file if it exists - nothing in this project's own `build.gradle.kts`
opts into it), which broke device sync on real-device testing **twice**
in a row: first `WorkDatabase` (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 every
`enqueueUniqueWork` call, including "Sync now", with zero indication
beyond a `WM-InputMerger` `NoSuchMethodException` in 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
class` with 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 just `flutter 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 via `adb 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 (mirrors `SyncEngine.kt`'s `syncRoot` layout exactly, so Dart
never needs to read the native sync-state `SharedPreferences`) that
returns the local mirror path if it exists on disk. `ShareSheet`'s "Share
file directly" and `FilesView._downloadSelected` (only when *every*
selected file is already synced - a mixed selection still goes through
the normal `DownloadService` batch) both check it first.
## Multi-account storage & session persistence
[`AccountStore`](../../lib/services/account_store.dart) owns everything
account-identity-related; `SessionController` owns everything about which
account is *currently* live (see `architecture.md`).
- **Per-account secrets**: one `flutter_secure_storage` key per account,
`nc_app_password_<accountId>` — never `shared_preferences`. `accountId` is
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_preferences` as `accounts_list`
(JSON array) and `active_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_preferences` keys — 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 namespaced `acct_<accountId>_<key>`
and owned by whichever controller that domain belongs to (e.g.
`FilesController` for grid/list/storage-scope/sort,
`PhotosController` for Photos' own sort/filter). Each reloads its own
slice in its `_onAccountActivated` listener, registered via
`SessionController.addAccountActivatedListener` in its constructor -
there's no longer one central method that reloads every domain's prefs at
once; `SessionController` just fires the notification, and every sibling
controller reacts independently.
- **Legacy migration**: `AccountStore.migrateLegacyIfNeeded` runs once ever
(guarded by the `account_migration_v1_done` flag), 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 `_activateAccount`
engine: bump `sessionGeneration`, cancel any pending login flow, call
`_notifyAccountCleared()` (every sibling controller's registered
`addAccountClearedListener` callback resets that controller's own state)
*without* ever setting `isLoggedIn` false (that's the detail that keeps
`main.dart`'s root routing from bouncing through `LoginView` mid-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()` vs `removeAccount()`** 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 in `accounts` all untouched) —
always lands on `LoginView` even if other accounts are saved (it does
*not* fall back to one of them the way `removeAccount` does). This
exists so `LoginView` can offer a "Continue as ..." one-tap resume list
(`_SavedAccountsSection` in `login_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, its `accounts` entry) 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
(besides `logout()`) that can set `isLoggedIn` false, 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.isLoggedIn` afterward and `Navigator.popUntil((r) =>
r.isFirst)` if so — otherwise a screen pushed on top (Settings) is left
stranded over a root route that's silently swapped to `LoginView`
underneath 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`](../../lib/services/app_lock_service.dart) 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.