From bb1688ce3a6b97f16323de2f5b66b26d1cc6872d Mon Sep 17 00:00:00 2001 From: ayushya Date: Wed, 16 Sep 2026 16:09:32 -0400 Subject: [PATCH] Update docs for app lock, multi-account, and local install guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/context/architecture.md | 27 ++++++++++++++---- .claude/context/server.md | 50 +++++++++++++++++++++++++++++++-- .claude/context/standards.md | 26 +++++++++++++++++ 3 files changed, 94 insertions(+), 9 deletions(-) diff --git a/.claude/context/architecture.md b/.claude/context/architecture.md index b89367b..50f05ce 100644 --- a/.claude/context/architecture.md +++ b/.claude/context/architecture.md @@ -10,12 +10,12 @@ lib/ # SavedAccount, AppTab, ...) providers/ # ServerProvider — the single app-wide ChangeNotifier services/ # network/IO: NextcloudService, LoginFlowService, - # AccountStore + # AccountStore, AppLockService theme/ # AppTheme (Material 3 ThemeData) views/ # one screen each (FilesView, PhotosView, TrashView, # SharesView, RecentView, ActivityView, SearchView, # AccountView, LoginView, FileViewerScreen, - # ShareUploadView) + # ShareUploadView, LockScreenView) widgets/ # reusable pieces shared across views details/ # the file-details bottom sheet and its tabs ``` @@ -54,9 +54,23 @@ There is exactly one `ChangeNotifier`: [`ServerProvider`](../../lib/providers/se - UI settings that persist across launches — split into **global** (theme mode, seed color, dynamic-color toggle, bottom-bar opacity/blur, tap-tab-to-scroll-top, seek bar style, tab order/visibility/default, swipe - actions) and **per-account** (grid/list view, favorites-only, show-hidden, - storage scope, Photos sort, Files' per-folder sort map, cache policy) — see - `server.md` for exactly which is which and why + actions, login lock — see below) and **per-account** (grid/list view, + favorites-only, show-hidden, storage scope, Photos sort, Files' + per-folder sort map, cache policy) — see `server.md` for exactly which is + which and why +- **Login lock** (`loginLockEnabled`/`lockAccountSwitching`/ + `lockHiddenFiles`/`needsUnlock`): an app-wide PIN/biometric gate via + `AppLockService` (a thin wrapper over `local_auth` — this app never + stores or hashes a PIN itself, it delegates entirely to whatever + credential the OS already has configured). `needsUnlock` is + `loginLockEnabled && !_isUnlocked`, where `_isUnlocked` is transient + (never persisted) and reset to `false` on every backgrounding + (`didChangeAppLifecycleState`, `AppLifecycleState.paused`) so the lock has + real value rather than only firing once per cold start. `switchAccount`/ + `cycleToNextAccount`/`cycleToPreviousAccount` and *enabling* (not + disabling) `showHiddenFiles`/`showHiddenPhotos` each call the shared + `_passGate` helper, which no-ops unless both `loginLockEnabled` and the + relevant per-feature toggle are on. New **global** state belongs on `ServerProvider` as a private field + getter + a method that mutates it, calls `notifyListeners()`, and persists via @@ -70,7 +84,7 @@ split. There's no separate repository/data layer: views call `ServerProvider` methods directly, which call `NextcloudService`/`LoginFlowService`/ -`AccountStore`. +`AccountStore`/`AppLockService`. ## Navigation / screen flow @@ -85,6 +99,7 @@ state, no named routes: Flow v2) — `isLoggedIn` is only ever false here or after the last saved account is removed; switching between multiple saved accounts never routes through this screen (see `server.md`) +- else `provider.needsUnlock` → `LockScreenView` (login lock — see above) - else `MainShellView` `MainShellView` is a bottom-nav `IndexedStack` over up to 6 tabs — Files, diff --git a/.claude/context/server.md b/.claude/context/server.md index 20e4179..d4b77ed 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -121,9 +121,53 @@ account is *currently* live (see `architecture.md`). 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()` is just `removeAccount(activeAccountId)` — with other accounts - saved it falls back to one of them instead of ending the session; - `isLoggedIn` only ever becomes `false` when the *last* account is removed. +- **`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 `!provider.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 `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 `` (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 diff --git a/.claude/context/standards.md b/.claude/context/standards.md index ed4f073..651c2d7 100644 --- a/.claude/context/standards.md +++ b/.claude/context/standards.md @@ -66,6 +66,32 @@ class/method already makes obvious. of failing fast. See `test/widget_test.dart` for the reference setup. - Run with `flutter test`. +## Local install/deploy + +Never use `flutter install` to push a build to a test device — it always +does a full **uninstall-then-install** (prints "Uninstalling old +version..."), and Android deletes all app data (SharedPreferences, secure +storage — every saved account/preference) on uninstall. This wipes the app +clean on every single deploy, which looks like an account/settings-loss bug +but is actually just the install method. + +Instead, build then install with `adb`'s replace flag, which updates the +APK in place and preserves app data: + +```bash +flutter build apk --release +adb install -r build/app/outputs/flutter-apk/app-release.apk +``` + +This only preserves data if the new APK's signature matches what's already +on the device — a local build's release variant currently reuses the debug +signing config (`android/app/build.gradle.kts`), so consecutive local +builds share a key and `adb install -r` works cleanly. Installing a build +signed with a different key (e.g. a CI-signed release APK from the Gitea +release pipeline) over a differently-signed local build forces Android to +require a full uninstall regardless of the install method used — there's no +way around that from the tooling side. + ## Dependencies Networking is deliberately split: `package:http` for simple JSON/XML