Compare commits
1
Commits
3f156cbc13
...
RC2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb1688ce3a |
@@ -10,12 +10,12 @@ lib/
|
|||||||
# SavedAccount, AppTab, ...)
|
# SavedAccount, AppTab, ...)
|
||||||
providers/ # ServerProvider — the single app-wide ChangeNotifier
|
providers/ # ServerProvider — the single app-wide ChangeNotifier
|
||||||
services/ # network/IO: NextcloudService, LoginFlowService,
|
services/ # network/IO: NextcloudService, LoginFlowService,
|
||||||
# AccountStore
|
# AccountStore, AppLockService
|
||||||
theme/ # AppTheme (Material 3 ThemeData)
|
theme/ # AppTheme (Material 3 ThemeData)
|
||||||
views/ # one screen each (FilesView, PhotosView, TrashView,
|
views/ # one screen each (FilesView, PhotosView, TrashView,
|
||||||
# SharesView, RecentView, ActivityView, SearchView,
|
# SharesView, RecentView, ActivityView, SearchView,
|
||||||
# AccountView, LoginView, FileViewerScreen,
|
# AccountView, LoginView, FileViewerScreen,
|
||||||
# ShareUploadView)
|
# ShareUploadView, LockScreenView)
|
||||||
widgets/ # reusable pieces shared across views
|
widgets/ # reusable pieces shared across views
|
||||||
details/ # the file-details bottom sheet and its tabs
|
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
|
- UI settings that persist across launches — split into **global** (theme
|
||||||
mode, seed color, dynamic-color toggle, bottom-bar opacity/blur,
|
mode, seed color, dynamic-color toggle, bottom-bar opacity/blur,
|
||||||
tap-tab-to-scroll-top, seek bar style, tab order/visibility/default, swipe
|
tap-tab-to-scroll-top, seek bar style, tab order/visibility/default, swipe
|
||||||
actions) and **per-account** (grid/list view, favorites-only, show-hidden,
|
actions, login lock — see below) and **per-account** (grid/list view,
|
||||||
storage scope, Photos sort, Files' per-folder sort map, cache policy) — see
|
favorites-only, show-hidden, storage scope, Photos sort, Files'
|
||||||
`server.md` for exactly which is which and why
|
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
|
New **global** state belongs on `ServerProvider` as a private field + getter
|
||||||
+ a method that mutates it, calls `notifyListeners()`, and persists via
|
+ 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`
|
There's no separate repository/data layer: views call `ServerProvider`
|
||||||
methods directly, which call `NextcloudService`/`LoginFlowService`/
|
methods directly, which call `NextcloudService`/`LoginFlowService`/
|
||||||
`AccountStore`.
|
`AccountStore`/`AppLockService`.
|
||||||
|
|
||||||
## Navigation / screen flow
|
## Navigation / screen flow
|
||||||
|
|
||||||
@@ -85,6 +99,7 @@ state, no named routes:
|
|||||||
Flow v2) — `isLoggedIn` is only ever false here or after the last saved
|
Flow v2) — `isLoggedIn` is only ever false here or after the last saved
|
||||||
account is removed; switching between multiple saved accounts never
|
account is removed; switching between multiple saved accounts never
|
||||||
routes through this screen (see `server.md`)
|
routes through this screen (see `server.md`)
|
||||||
|
- else `provider.needsUnlock` → `LockScreenView` (login lock — see above)
|
||||||
- else `MainShellView`
|
- else `MainShellView`
|
||||||
|
|
||||||
`MainShellView` is a bottom-nav `IndexedStack` over up to 6 tabs — Files,
|
`MainShellView` is a bottom-nav `IndexedStack` over up to 6 tabs — Files,
|
||||||
|
|||||||
@@ -121,9 +121,53 @@ account is *currently* live (see `architecture.md`).
|
|||||||
its credentials and refetch everything. This is a full teardown-and-reload
|
its credentials and refetch everything. This is a full teardown-and-reload
|
||||||
every time — there is deliberately no simultaneous multi-account state or
|
every time — there is deliberately no simultaneous multi-account state or
|
||||||
background sync; only one account's content is ever live.
|
background sync; only one account's content is ever live.
|
||||||
- `logout()` is just `removeAccount(activeAccountId)` — with other accounts
|
- **`logout()` vs `removeAccount()`** are deliberately different actions,
|
||||||
saved it falls back to one of them instead of ending the session;
|
both funneling into a shared `_deactivateSession()` helper for the
|
||||||
`isLoggedIn` only ever becomes `false` when the *last* account is removed.
|
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 `<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
|
`isRestoringSession` still gates the splash screen until the above resolves
|
||||||
— see `standards.md` for why widget tests must mock both storage channels
|
— see `standards.md` for why widget tests must mock both storage channels
|
||||||
|
|||||||
@@ -66,6 +66,32 @@ class/method already makes obvious.
|
|||||||
of failing fast. See `test/widget_test.dart` for the reference setup.
|
of failing fast. See `test/widget_test.dart` for the reference setup.
|
||||||
- Run with `flutter test`.
|
- 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
|
## Dependencies
|
||||||
|
|
||||||
Networking is deliberately split: `package:http` for simple JSON/XML
|
Networking is deliberately split: `package:http` for simple JSON/XML
|
||||||
|
|||||||
Reference in New Issue
Block a user