Author SHA1 Message Date
MineTecandClaude Opus 5.5 5a8fed24bb added ios privacy manifests, fixed permission texts and updated the xcode project
Privacy manifests for the app, widget and share extension declare the
App Group UserDefaults access (CA92.1, 1C8F.1) required for App Store
submission. Photo library and camera usage texts corrected. Project
format and SwiftPM pins (Firebase 12.19) as updated by Xcode 27 / Flutter
3.47.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 12:51:28 +02:00
MineTecandClaude Opus 5.5 b413fe05de fixed timetable ruler and day header overflowing at large system text sizes
Both have fixed geometry, so their text scaling is capped at 1.3. Ruler
times shrink instead of wrapping into the period number and are only
shown when they fit at the current scale.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 12:51:28 +02:00
MineTecandClaude Opus 5.5 38a651f916 fixed missing period times in the ios widget ruler and unreadable tiles on tinted and clear home screens
A .systemLarge widget only gets ~27pt per period, below the 30pt threshold
shared with Android, so iOS now shows the times from 24pt with a tighter
cell. In non-full-color rendering modes the opaque tiles and the today
circle flattened into solid slabs hiding their text; they are drawn
translucent with an outline there and use the light-text palette.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 12:51:28 +02:00
MineTec f98a6979fb merged develop 2026-09-27 14:58:07 +02:00
MineTec 3e856d0b1c updated project notes to the current backends 2026-09-27 14:57:49 +02:00
MineTec 8eddc8f771 fixed widget background refresh loading custom events from the legacy backend 2026-09-27 14:54:10 +02:00
MineTec 27563a6dc1 added support for multiple accounts with an account switcher in settings 2026-09-27 14:54:10 +02:00
MineTec 5e65c4671a replaced account data singleton with a session model 2026-09-27 14:49:38 +02:00
MineTec 79b33ab248 reworked login screen texts and extracted shared login form parts 2026-09-27 14:40:04 +02:00
MineTec 1fd6264d10 fixed notification taps being ignored when they cold-started the app 2026-09-27 14:38:48 +02:00
MineTec a71f4f379b ios package-update 2026-09-25 11:02:01 +02:00
MineTec 84098af7e2 added support for multiple accounts, guardian login bugfixes, ui changes 2026-09-23 20:50:12 +02:00
MineTec 630497abdd fixed guardian login edge cases and misleading placeholders on failed loads 2026-09-20 19:03:45 +02:00
MineTec 2423c1a75e added guardian letters with chat and multiple answer functionalities 2026-09-20 16:12:45 +02:00
MineTec 67c935c05b added guardian login with views for their assigned childs 2026-09-20 11:11:58 +02:00
211 changed files with 15266 additions and 2039 deletions

No files matched your search

+18 -6
View File
@@ -1,6 +1,6 @@
# MarianumMobile Client # MarianumMobile Client
Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Files, Custom MHSL-Backend (Breaker, Custom Events, Push). Flutter-App für die Schul-Community: Stundenplan, Ticker, Newsletter & Co. über das MarianumConnect-Backend, Nextcloud Talk + Files. Zwei Kontoarten: Schul-Konto (Schüler/Lehrer, Benutzername + Passwort) und Eltern-Konto (passwortlos per E-Mail-Code/App-Link, sieht die Stundenpläne der zugeordneten Kinder und erhält Elternbriefe, kein Talk/Files).
## Stack ## Stack
@@ -16,7 +16,10 @@ Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Fil
``` ```
lib/ lib/
├── api/ HTTP-Layer pro Backend (mhsl/, marianumcloud/, webuntis/, holidays/) ├── api/ HTTP-Layer pro Backend (marianumconnect/, marianumcloud/, mhsl/ Legacy, demo/)
├── session/ Session-Modell (CredentialSession / GuardianSession), SessionManager, SessionLifecycle
├── access/ UserRole, AccessRequirement (Gating von Modulen/Settings)
├── auth_link/ Eltern-Login: App-Link-Listener, Link-Parser, Geräte-Bindung
├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...) ├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...)
├── state/app/infrastructure LoadableState<T>, DataLoader, geteilte BLoC-Bausteine ├── state/app/infrastructure LoadableState<T>, DataLoader, geteilte BLoC-Bausteine
├── view/ Screens ├── view/ Screens
@@ -51,6 +54,16 @@ lib/
**Settings:** Pro Feature ein Freezed-Modell unter `lib/storage/`, persistiert via HydratedBloc. **Settings:** Pro Feature ein Freezed-Modell unter `lib/storage/`, persistiert via HydratedBloc.
**Session:** Die aktive Sitzung liegt in `SessionManager().current` (`lib/session/`). Nextcloud-Zugriffe nur über `SessionManager().requireNextcloud()` – Eltern-Sessions haben keine Nextcloud-Identität. Abmelden ausschließlich über `SessionLifecycle.signOut()`. Die Keychain-Keys in `SessionKeys` sind eingefroren (Bestandsinstallationen, iOS-NSE).
**Mehrere Konten:** Die `SessionKeys`-Slots, der MC-Token und die Group-Keychain spiegeln immer das *aktive* Konto; inaktive Konten liegen im Tresor (`accounts_index` + `account_vault_<id>`, `lib/session/account_codec.dart`). Wechseln/Hinzufügen/Entfernen nur über `SessionLifecycle` (`switchTo`, `beginAddAccount`/`finishLogin`/`cancelAddAccount`, `removeInactive`), danach `AccountBloc.activated(id)`. Lokale Daten sind pro Konto getrennt (`AccountStorage`: eigene HydratedBloc-Box; der Request-Cache `CacheStore` folgt dem aktiven Konto mit eigenem Verzeichnis); nur die Settings liegen in der globalen Box. `SessionManager().sessionEpoch` steigt bei jedem Kontowechsel – asynchrone Arbeit prüft damit, ob ihr Ergebnis noch zum aktiven Konto gehört. Was ein Konto außerhalb seines Speichers hinterlässt (Tray, Push-Schlüssel, Downloads), räumt `SessionWipe` auf. Alle Pro-Konto-Blocs gehören in `AccountScope` – er wird per Konto-ID neu aufgebaut (samt `MaterialApp`/Navigator). Push ist nur für das aktive Konto registriert.
**Rollen & Zuschnitt:** Views verzweigen nie auf Rollen. Module und Settings-Sections deklarieren `AccessRequirement`s (`nextcloud`, `guardian`; `AppModule.requirements`, `Settings._sections`); Views bekommen ein Subjekt + eine Policy, die an genau einer Stelle als pure Funktion aufgelöst wird (Vorbild: `TimetableSubject` + `TimetablePolicy.resolve`, `AbsenceFormPolicy`). `UserRole` nur für Anzeige/Policy-Ableitung.
**Stundenplan:** Ein `TimetableBloc(subject: …)` für alle Fälle (eigener Plan, Fremdplan, Kind). Den globalen Bloc stellt `PrimaryTimetableScope` bereit und tauscht ihn beim Kindwechsel aus; page-scoped Fremdpläne nutzen `ScopedTimetableBloc`. Die Kinderauswahl (`ChildSelectionCubit`) ist modulübergreifend.
**Elternbriefe:** Modul `parentLetters` (nur Eltern-Sessions, `AccessRequirement.guardian`): Lehrer-Mitteilungen aus MarianumConnect mit Gelesen-Status, Kenntnisnahme/Auswahl/Unterschrift pro Kind, Thread und Anhängen. Der Posteingang (`ParentLettersBloc`) ist global (Modul-Badge, Push-Refresh), ein Brief ist page-scoped (`ParentLetterBloc`). Ob und wie geantwortet werden darf, entscheidet der Server (`editable`); `ParentLetterFormPolicy.resolve` macht daraus das Formular. Push: Connect-Direct-Push `type: parent-letter`, Tap-Routing allein über `parentLetterId`. Alle Benachrichtigungs-Taps (lokal gerendert wie FCM) werden von `resolvePushTarget` (`lib/push/push_target.dart`) aufgelöst und in `NotificationTasks.openPushTarget` navigiert – neue Push-Ziele nur dort ergänzen.
## Build / Run ## Build / Run
```bash ```bash
@@ -65,13 +78,12 @@ flutter test # Tests (siehe test
| Backend | Pfad | Zweck | | Backend | Pfad | Zweck |
|---------------------------|-----------------------|----------------------------------------| |---------------------------|-----------------------|----------------------------------------|
| Webuntis | `lib/api/webuntis/` | Stundenplan, Klassen, Räume, Lehrer | | MarianumConnect (Bearer) | `lib/api/marianumconnect/` | Auth, Stundenplan (Webuntis-Proxy), Ticker, Newsletter, Ferien, Abwesenheit, Elternbriefe, Capabilities, Push |
| Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung | | Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung |
| Custom MHSL-Server | `lib/api/mhsl/` | Breaker, Custom Events, Notify, Noten | | MHSL (Legacy) | `lib/api/mhsl/` | nur noch Einmal-Migration der Custom Events |
| Holiday-Calendar | `lib/api/holidays/` | Ferien |
`nextcloud`-Paket ist auf einen Custom-Fork gepinnt (siehe `pubspec.yaml` `dependency_overrides`). `nextcloud`-Paket ist auf einen Custom-Fork gepinnt (siehe `pubspec.yaml` `dependency_overrides`).
## Tests ## Tests
`test/` deckt aktuell nur Kern-Funktionen ab (DateTime-Extensions, AsyncActionController, LessonResolver). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu. `test/` deckt vor allem pure Funktionen ab (DateTime-Extensions, Stundenplan-Logik, Session-Codec, Policies, Eltern-Login-Controller). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu.
+17
View File
@@ -41,6 +41,23 @@
<data android:mimeType="video/*" /> <data android:mimeType="video/*" />
<data android:mimeType="application/*" /> <data android:mimeType="application/*" />
</intent-filter> </intent-filter>
<!-- Guardian login mail link (App Link). Verified via
/.well-known/assetlinks.json on both hosts. -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="connect.marianum-fulda.de" />
<data android:host="connect-beta.marianum-fulda.de" />
<data android:path="/app/guardian-login" />
</intent-filter>
<!-- The app routes links itself (GuardianLinkListener); Flutter's
built-in handling would push the path as a named route onto a
MaterialApp that only has `home`. -->
<meta-data
android:name="flutter_deeplinking_enabled"
android:value="false" />
</activity> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
+3 -1
View File
@@ -106,12 +106,14 @@ void _log(String message) => debugPrint('SHOTS: $message');
Future<void> _login(WidgetTester tester) async { Future<void> _login(WidgetTester tester) async {
final loginVisible = await _pumpUntil( final loginVisible = await _pumpUntil(
tester, tester,
find.byKey(const Key('login-username-field')), find.byKey(const Key('login-audience-school')),
); );
if (!loginVisible) { if (!loginVisible) {
_log('kein Login-Screen sichtbar – bereits angemeldet, überspringe Login'); _log('kein Login-Screen sichtbar – bereits angemeldet, überspringe Login');
return; return;
} }
await tester.tap(find.byKey(const Key('login-audience-school')));
await _pumpUntil(tester, find.byKey(const Key('login-username-field')));
await tester.enterText( await tester.enterText(
find.byKey(const Key('login-username-field')), find.byKey(const Key('login-username-field')),
'demo@screenshots', 'demo@screenshots',
+2 -2
View File
@@ -180,11 +180,11 @@ AppDelegate) exakt so ab: `GenericPassword` + `kSecAttrAccount = <key>` +
| `push_server_public_key_pem` | **Server**-Public-Key (per User), SPKI-PEM | `push_registration_store.dart` | **NSE** (Signatur prüfen) | | `push_server_public_key_pem` | **Server**-Public-Key (per User), SPKI-PEM | `push_registration_store.dart` | **NSE** (Signatur prüfen) |
| `push_device_identifier` | NC Device-Identifier | `push_registration_store.dart` | Dart | | `push_device_identifier` | NC Device-Identifier | `push_registration_store.dart` | Dart |
| `push_registered_fcm_token` | FCM-Token der Registrierung | `push_registration_store.dart` | Dart | | `push_registered_fcm_token` | FCM-Token der Registrierung | `push_registration_store.dart` | Dart |
| `nextcloud_app_password` | NC App-Password | `account_data.dart` | **AppDelegate** (Basic-Auth) | | `nextcloud_app_password` | NC App-Password | `session_manager.dart` | **AppDelegate** (Basic-Auth) |
| `nextcloud_username` | NC Username | `push_registration_store.dart` (**neu**) | **AppDelegate** (Basic-Auth) | | `nextcloud_username` | NC Username | `push_registration_store.dart` (**neu**) | **AppDelegate** (Basic-Auth) |
| `nextcloud_base_url` | z.B. `https://cloud.marianum-fulda.de` | `push_registration_store.dart` (**neu**) | **AppDelegate** (OCS-URL) | | `nextcloud_base_url` | z.B. `https://cloud.marianum-fulda.de` | `push_registration_store.dart` (**neu**) | **AppDelegate** (OCS-URL) |
> `username` und `password` (Realpasswort) liegen in AccountDatas **Default**-Storage > `username` und `password` (Realpasswort) liegen im **Default**-Storage des SessionManagers
> **ohne** `groupId` → nur im primären Access-Group der App, **für die NSE > **ohne** `groupId` → nur im primären Access-Group der App, **für die NSE
> unsichtbar**. Deshalb werden `nextcloud_username` + `nextcloud_base_url` bei > unsichtbar**. Deshalb werden `nextcloud_username` + `nextcloud_base_url` bei
> jeder `register()`-Runde zusätzlich **group-scoped** geschrieben. Das > jeder `register()`-Runde zusätzlich **group-scoped** geschrieben. Das
+2 -8
View File
@@ -12,8 +12,6 @@ PODS:
- PhoneNumberKit/PhoneNumberKitCore (3.7.11) - PhoneNumberKit/PhoneNumberKitCore (3.7.11)
- PhoneNumberKit/UIKit (3.7.11): - PhoneNumberKit/UIKit (3.7.11):
- PhoneNumberKit/PhoneNumberKitCore - PhoneNumberKit/PhoneNumberKitCore
- workmanager_apple (0.0.1):
- Flutter
DEPENDENCIES: DEPENDENCIES:
- eraser (from `.symlinks/plugins/eraser/ios`) - eraser (from `.symlinks/plugins/eraser/ios`)
@@ -21,7 +19,6 @@ DEPENDENCIES:
- flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`) - flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`)
- open_filex (from `.symlinks/plugins/open_filex/ios`) - open_filex (from `.symlinks/plugins/open_filex/ios`)
- PhoneNumberKit (~> 3.7.6) - PhoneNumberKit (~> 3.7.6)
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
SPEC REPOS: SPEC REPOS:
trunk: trunk:
@@ -36,17 +33,14 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_app_badge/ios" :path: ".symlinks/plugins/flutter_app_badge/ios"
open_filex: open_filex:
:path: ".symlinks/plugins/open_filex/ios" :path: ".symlinks/plugins/open_filex/ios"
workmanager_apple:
:path: ".symlinks/plugins/workmanager_apple/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
eraser: 83a4b06985f3702aa3d8dec816f9693266012937 eraser: 83a4b06985f3702aa3d8dec816f9693266012937
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6 PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6
COCOAPODS: 1.16.2 COCOAPODS: 1.17.0
+9 -6
View File
@@ -3,11 +3,12 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 54; objectVersion = 70;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
034BD2FF7A860C6DC2FED514 /* Pods_Share_Extension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */; }; 034BD2FF7A860C6DC2FED514 /* Pods_Share_Extension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */; };
0B36AA42294848D99E4E0742 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = AFE007D963441C0650CD3ED9 /* PrivacyInfo.xcprivacy */; };
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3321F8052FB1C00C0011C712 /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3321F8052FB1C00C0011C712 /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */; }; 33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */; };
@@ -77,19 +78,19 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
12B96C78930441C73F123636 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = Info.plist; path = Info.plist; sourceTree = "<group>"; }; 12B96C78930441C73F123636 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Release.xcconfig"; path = "Flutter/NotificationServiceExtension-Release.xcconfig"; sourceTree = "<group>"; }; 17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Release.xcconfig"; path = "Flutter/NotificationServiceExtension-Release.xcconfig"; sourceTree = "<group>"; };
2960F029246C39E2A03F6D87 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NotificationService.swift; path = NotificationService.swift; sourceTree = "<group>"; }; 2960F029246C39E2A03F6D87 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
3321F8052FB1C00C0011C712 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 3321F8052FB1C00C0011C712 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; }; 33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Debug.xcconfig"; path = "Flutter/NotificationServiceExtension-Debug.xcconfig"; sourceTree = "<group>"; }; 36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Debug.xcconfig"; path = "Flutter/NotificationServiceExtension-Debug.xcconfig"; sourceTree = "<group>"; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
4509EC31CB08BA9BF367AF6C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; }; 4509EC31CB08BA9BF367AF6C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Share_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Share_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
509DCCD474353408FE5806C5 /* PEM.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PEM.swift; path = PEM.swift; sourceTree = "<group>"; }; 509DCCD474353408FE5806C5 /* PEM.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PEM.swift; sourceTree = "<group>"; };
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; name = NotificationServiceExtension.entitlements; path = NotificationServiceExtension.entitlements; sourceTree = "<group>"; }; 5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = NotificationServiceExtension.entitlements; sourceTree = "<group>"; };
60E1803A3FB28FCC6F435E99 /* Pods-Share Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.release.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"; sourceTree = "<group>"; }; 60E1803A3FB28FCC6F435E99 /* Pods-Share Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.release.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"; sourceTree = "<group>"; };
64801C012A9112D500E8B558 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; }; 64801C012A9112D500E8B558 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
@@ -107,6 +108,7 @@
AA0101020000000011111111 /* TimetableWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TimetableWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; AA0101020000000011111111 /* TimetableWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TimetableWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
AA0102020000000022222222 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; }; AA0102020000000022222222 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
AA6B03D1433E7395021F7730 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; }; AA6B03D1433E7395021F7730 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
AFE007D963441C0650CD3ED9 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
BB0001010000000011111111 /* ShareExtension-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "ShareExtension-Debug.xcconfig"; path = "Flutter/ShareExtension-Debug.xcconfig"; sourceTree = "<group>"; }; BB0001010000000011111111 /* ShareExtension-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "ShareExtension-Debug.xcconfig"; path = "Flutter/ShareExtension-Debug.xcconfig"; sourceTree = "<group>"; };
BB0001020000000011111111 /* ShareExtension-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "ShareExtension-Release.xcconfig"; path = "Flutter/ShareExtension-Release.xcconfig"; sourceTree = "<group>"; }; BB0001020000000011111111 /* ShareExtension-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "ShareExtension-Release.xcconfig"; path = "Flutter/ShareExtension-Release.xcconfig"; sourceTree = "<group>"; };
BB0001030000000011111111 /* ShareExtension-Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "ShareExtension-Profile.xcconfig"; path = "Flutter/ShareExtension-Profile.xcconfig"; sourceTree = "<group>"; }; BB0001030000000011111111 /* ShareExtension-Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "ShareExtension-Profile.xcconfig"; path = "Flutter/ShareExtension-Profile.xcconfig"; sourceTree = "<group>"; };
@@ -222,7 +224,6 @@
12B96C78930441C73F123636 /* Info.plist */, 12B96C78930441C73F123636 /* Info.plist */,
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */, 5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */,
); );
name = NotificationServiceExtension;
path = NotificationServiceExtension; path = NotificationServiceExtension;
sourceTree = SOURCE_ROOT; sourceTree = SOURCE_ROOT;
}; };
@@ -304,6 +305,7 @@
74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
AA0102020000000022222222 /* SceneDelegate.swift */, AA0102020000000022222222 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
AFE007D963441C0650CD3ED9 /* PrivacyInfo.xcprivacy */,
); );
path = Runner; path = Runner;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -469,6 +471,7 @@
33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */, 33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
0B36AA42294848D99E4E0742 /* PrivacyInfo.xcprivacy in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -50,8 +50,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk", "location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : { "state" : {
"revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270", "revision" : "27eaab3918e0bf78711cf1abf240577176326432",
"version" : "12.15.0" "version" : "12.19.0"
} }
}, },
{ {
@@ -59,8 +59,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : { "state" : {
"revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55", "revision" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee",
"version" : "3.6.1" "version" : "3.7.0"
} }
}, },
{ {
@@ -68,8 +68,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git", "location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : { "state" : {
"revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b", "revision" : "b8455dd2ff55cc269fda7a4f9eac6c1e3588c53f",
"version" : "12.15.0" "version" : "12.19.2"
} }
}, },
{ {
@@ -86,8 +86,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git", "location" : "https://github.com/google/GoogleUtilities.git",
"state" : { "state" : {
"revision" : "9f183ae842be978784f2963a343682e0c46d8fb3", "revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3",
"version" : "8.1.2" "version" : "8.1.3"
} }
}, },
{ {
@@ -50,8 +50,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk", "location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : { "state" : {
"revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270", "revision" : "27eaab3918e0bf78711cf1abf240577176326432",
"version" : "12.15.0" "version" : "12.19.0"
} }
}, },
{ {
@@ -59,8 +59,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : { "state" : {
"revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64", "revision" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee",
"version" : "3.6.0" "version" : "3.7.0"
} }
}, },
{ {
@@ -68,8 +68,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git", "location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : { "state" : {
"revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b", "revision" : "b8455dd2ff55cc269fda7a4f9eac6c1e3588c53f",
"version" : "12.15.0" "version" : "12.19.2"
} }
}, },
{ {
@@ -86,8 +86,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git", "location" : "https://github.com/google/GoogleUtilities.git",
"state" : { "state" : {
"revision" : "c46e5f8b7c23265f17c24ca7f9fa1b13ded7a822", "revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3",
"version" : "8.1.1" "version" : "8.1.3"
} }
}, },
{ {
+4 -2
View File
@@ -37,12 +37,14 @@
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>FlutterDeepLinkingEnabled</key>
<false/>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>Um Fotos direkt aus der App aufnehmen und teilen zu können wird Zugriff auf die Kamera benötigt.</string> <string>Um Fotos direkt aus der App aufnehmen und teilen zu können, wird Zugriff auf die Kamera benötigt.</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>Um Medien mit anderen zu teilen wird Zugriff zu deine Dateien benötigt.</string> <string>Um Fotos und Videos mit anderen zu teilen, wird Zugriff auf deine Mediathek benötigt.</string>
<key>UIApplicationSceneManifest</key> <key>UIApplicationSceneManifest</key>
<dict> <dict>
<key>UIApplicationSupportsMultipleScenes</key> <key>UIApplicationSupportsMultipleScenes</key>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
<string>1C8F.1</string>
</array>
</dict>
</array>
</dict>
</plist>
+5
View File
@@ -4,6 +4,11 @@
<dict> <dict>
<key>aps-environment</key> <key>aps-environment</key>
<string>development</string> <string>development</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:connect.marianum-fulda.de</string>
<string>applinks:connect-beta.marianum-fulda.de</string>
</array>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.eu.mhsl.marianum.mobile.client.widget</string> <string>group.eu.mhsl.marianum.mobile.client.widget</string>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
<string>1C8F.1</string>
</array>
</dict>
</array>
</dict>
</plist>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
<string>1C8F.1</string>
</array>
</dict>
</array>
</dict>
</plist>
@@ -147,7 +147,8 @@ struct PeriodRuler: View {
.font(.system(size: timeFont)) .font(.system(size: timeFont))
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.lineLimit(1) .lineLimit(1)
.padding(.vertical, 2) .minimumScaleFactor(0.8)
.padding(.vertical, 1)
} }
} }
.frame(width: RULER_WIDTH, height: height) .frame(width: RULER_WIDTH, height: height)
@@ -304,6 +305,8 @@ private struct LessonTile: View {
let metrics: TileMetrics let metrics: TileMetrics
let layout: TileTextLayout let layout: TileTextLayout
@Environment(\.widgetRenderingMode) private var renderingMode
private static let cancelledRed = private static let cancelledRed =
Color(red: 244 / 255, green: 67 / 255, blue: 54 / 255).opacity(200 / 255) Color(red: 244 / 255, green: 67 / 255, blue: 54 / 255).opacity(200 / 255)
@@ -326,7 +329,7 @@ private struct LessonTile: View {
.padding(.vertical, TILE_PADDING_V) .padding(.vertical, TILE_PADDING_V)
.frame(maxWidth: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .topLeading)
.frame(height: height, alignment: .topLeading) .frame(height: height, alignment: .topLeading)
.background(lesson.tileColor) .background { tileBackground(shape) }
.clipShape(shape) .clipShape(shape)
.overlay(alignment: .bottomTrailing) { .overlay(alignment: .bottomTrailing) {
if let count = lesson.siblingCount, count > 0 { if let count = lesson.siblingCount, count > 0 {
@@ -357,6 +360,22 @@ private struct LessonTile: View {
.padding(.horizontal, TILE_MARGIN_H) .padding(.horizontal, TILE_MARGIN_H)
} }
/// Tinted / clear home screens flatten an opaque colour block into a
/// solid slab the white text disappears on; a translucent fill with an
/// outline keeps tile and text apart in every rendering mode.
@ViewBuilder
private func tileBackground(_ shape: RoundedRectangle) -> some View {
if renderingMode == .fullColor {
lesson.tileColor
} else {
ZStack {
shape.fill(Color.white.opacity(0.18))
shape.strokeBorder(Color.white.opacity(0.55), lineWidth: 1)
}
.widgetAccentable()
}
}
private var titleRow: some View { private var titleRow: some View {
HStack(alignment: .center, spacing: 3) { HStack(alignment: .center, spacing: 3) {
Text(layout.title) Text(layout.title)
@@ -47,7 +47,10 @@ let TITLE_WRAP_BELOW_FONT: CGFloat = 8
let BADGE_WIDTH: CGFloat = 16 let BADGE_WIDTH: CGFloat = 16
let BADGE_HEIGHT: CGFloat = 11 let BADGE_HEIGHT: CGFloat = 11
let RULER_TIMES_MIN_CELL: CGFloat = 30 /// Lower than Android's 30dp: a .systemLarge widget only gets ~27pt per
/// period, so iOS packs the ruler cell tighter (see PeriodRuler.cell)
/// instead of dropping the times.
let RULER_TIMES_MIN_CELL: CGFloat = 24
let BREAK_ICON_MIN_HEIGHT: CGFloat = 12 let BREAK_ICON_MIN_HEIGHT: CGFloat = 12
/// Space the holiday column keeps free above and below the rotated name for /// Space the holiday column keeps free above and below the rotated name for
@@ -4,6 +4,7 @@ import WidgetKit
struct TimetableWeekView: View { struct TimetableWeekView: View {
let entry: TimetableEntry let entry: TimetableEntry
@Environment(\.widgetPalette) private var palette @Environment(\.widgetPalette) private var palette
@Environment(\.widgetRenderingMode) private var renderingMode
var body: some View { var body: some View {
ZStack { ZStack {
@@ -115,13 +116,23 @@ struct TimetableWeekView: View {
.lineLimit(1) .lineLimit(1)
Text("\(Calendar.current.component(.day, from: day))") Text("\(Calendar.current.component(.day, from: day))")
.font(.system(size: 11, weight: isToday ? .bold : .regular)) .font(.system(size: 11, weight: isToday ? .bold : .regular))
.foregroundStyle(isToday ? palette.onAccent : palette.textPrimary) .foregroundStyle(isToday ? todayNumberColor : palette.textPrimary)
.lineLimit(1) .lineLimit(1)
.frame(width: 18, height: 18) .frame(width: 18, height: 18)
.background(Circle().fill(isToday ? palette.accent : Color.clear)) .background(Circle().fill(isToday ? todayCircleColor : Color.clear))
} }
} }
// An opaque accent circle flattens to the same shade as its number on
// tinted / clear home screens; keep the circle translucent there.
private var todayCircleColor: Color {
renderingMode == .fullColor ? palette.accent : Color.white.opacity(0.3)
}
private var todayNumberColor: Color {
renderingMode == .fullColor ? palette.onAccent : palette.textPrimary
}
private func placeholder(_ message: String) -> some View { private func placeholder(_ message: String) -> some View {
VStack(spacing: 4) { VStack(spacing: 4) {
Text("Marianum Stundenplan") Text("Marianum Stundenplan")
@@ -201,12 +201,15 @@ private struct WidgetSurface<Content: View>: View {
let entry: TimetableEntry let entry: TimetableEntry
@ViewBuilder let content: () -> Content @ViewBuilder let content: () -> Content
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@Environment(\.widgetRenderingMode) private var renderingMode
var body: some View { var body: some View {
let palette = WidgetPalette.resolve( // Tinted / clear home screens map every colour to its luminance, so
themeMode: entry.themeMode, // the light palette's near-black text would vanish — use the dark
colorScheme: colorScheme // palette's light text there regardless of the theme setting.
) let palette = renderingMode == .fullColor
? WidgetPalette.resolve(themeMode: entry.themeMode, colorScheme: colorScheme)
: .dark
return AnyView( return AnyView(
background(content: content(), palette: palette) background(content: content(), palette: palette)
.environment(\.widgetPalette, palette) .environment(\.widgetPalette, palette)
+17
View File
@@ -0,0 +1,17 @@
import '../session/session.dart';
/// Backend identity a feature needs. Modules and settings sections declare
/// these; anything whose requirements the session does not meet is hidden.
enum AccessRequirement {
nextcloud,
guardian;
bool isMetBy(Session? session) => switch (this) {
AccessRequirement.nextcloud => session?.nextcloud != null,
AccessRequirement.guardian => session is GuardianSession,
};
}
extension AccessRequirements on Set<AccessRequirement> {
bool areMetBy(Session? session) => every((r) => r.isMetBy(session));
}
+20
View File
@@ -0,0 +1,20 @@
/// Account role as reported by MarianumConnect. Only used for display and for
/// deriving policies; features gate on capabilities and requirements instead
/// of comparing roles.
enum UserRole {
student,
teacher,
staff,
parent,
unknown;
/// Unknown or missing values map to [unknown] so a new server-side role
/// never breaks older app versions.
static UserRole parse(String? wire) => switch (wire) {
'STUDENT' => UserRole.student,
'TEACHER' => UserRole.teacher,
'STAFF' => UserRole.staff,
'PARENT' => UserRole.parent,
_ => UserRole.unknown,
};
}
+62 -19
View File
@@ -5,6 +5,7 @@ import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import '../session/session_manager.dart';
import '../utils/directory_size.dart'; import '../utils/directory_size.dart';
/// One cached response: the raw JSON payload and when it was stored. /// One cached response: the raw JSON payload and when it was stored.
@@ -22,6 +23,9 @@ class CacheEntry {
/// in memory for the whole session and JSON-encodes payloads a second time. /// in memory for the whole session and JSON-encodes payloads a second time.
/// Here every access is async and touches exactly one file, and the payload is /// Here every access is async and touches exactly one file, and the payload is
/// stored verbatim behind a one-line header (`<lastUpdate>\n<json>`). /// stored verbatim behind a one-line header (`<lastUpdate>\n<json>`).
///
/// Every account has its own directory; all access goes to the one of the
/// active account, in every isolate.
class CacheStore { class CacheStore {
CacheStore._(); CacheStore._();
@@ -30,15 +34,30 @@ class CacheStore {
/// Payloads above this size are decoded on a background isolate. /// Payloads above this size are decoded on a background isolate.
static const int isolateDecodeThreshold = 64 * 1024; static const int isolateDecodeThreshold = 64 * 1024;
Future<Directory>? _dir; static const _directoryPrefix = 'request_cache';
final Map<String, Future<Directory>> _dirs = {};
final Map<String, Future<void>> _writes = {}; final Map<String, Future<void>> _writes = {};
Future<Directory> _directory() => _dir ??= () async { /// Directory name for an account's storage [namespace]; null while no
/// account is active. The account from before multi-account support
/// (namespace '') keeps the original directory.
@visibleForTesting
static String directoryName(String? namespace) => switch (namespace) {
null => '$_directoryPrefix-signed-out',
'' => _directoryPrefix,
_ => '$_directoryPrefix-$namespace',
};
Future<Directory> _directory() {
final name = directoryName(SessionManager().activeAccount?.namespace);
return _dirs[name] ??= () async {
final base = await getApplicationCacheDirectory(); final base = await getApplicationCacheDirectory();
final dir = Directory('${base.path}/request_cache'); final dir = Directory('${base.path}/$name');
await dir.create(recursive: true); await dir.create(recursive: true);
return dir; return dir;
}(); }();
}
Future<File> _file(String key) async => Future<File> _file(String key) async =>
File('${(await _directory()).path}/${_safeName(key)}'); File('${(await _directory()).path}/${_safeName(key)}');
@@ -49,8 +68,9 @@ class CacheStore {
Future<CacheEntry?> read(String key) async { Future<CacheEntry?> read(String key) async {
try { try {
await _writes[key]; final file = await _file(key);
return parse(await (await _file(key)).readAsString()); await _writes[file.path];
return parse(await file.readAsString());
} on Object { } on Object {
// Missing (PathNotFoundException) or unreadable: a cache miss. // Missing (PathNotFoundException) or unreadable: a cache miss.
return null; return null;
@@ -64,18 +84,21 @@ class CacheStore {
return {for (var i = 0; i < keys.length; i++) keys[i]: ?entries[i]}; return {for (var i = 0; i < keys.length; i++) keys[i]: ?entries[i]};
} }
Future<void> write(String key, String json) { Future<void> write(String key, String json) async {
final previous = _writes[key] ?? Future<void>.value(); // Resolved up front: a queued write must not follow an account switch
// into the next account's directory.
final file = await _file(key);
final path = file.path;
final previous = _writes[path] ?? Future<void>.value();
late final Future<void> current; late final Future<void> current;
current = previous.then((_) => _write(key, json)).whenComplete(() { current = previous.then((_) => _write(file, json)).whenComplete(() {
if (identical(_writes[key], current)) _writes.remove(key); if (identical(_writes[path], current)) _writes.remove(path);
}); });
return _writes[key] = current; return _writes[path] = current;
} }
Future<void> _write(String key, String json) async { Future<void> _write(File file, String json) async {
try { try {
final file = await _file(key);
// Write-then-rename so a reader (or the widget background isolate) // Write-then-rename so a reader (or the widget background isolate)
// never sees a half-written file. // never sees a half-written file.
final tmp = File('${file.path}.tmp'); final tmp = File('${file.path}.tmp');
@@ -85,19 +108,21 @@ class CacheStore {
); );
await tmp.rename(file.path); await tmp.rename(file.path);
} on Object catch (e) { } on Object catch (e) {
debugPrint('CacheStore.write($key) failed: $e'); debugPrint('CacheStore.write(${file.path}) failed: $e');
} }
} }
Future<void> delete(String key) async { Future<void> delete(String key) async {
try { try {
await _writes[key]; final file = await _file(key);
await (await _file(key)).delete(); await _writes[file.path];
await file.delete();
} on Object { } on Object {
// A missing or locked file is as good as deleted for a cache. // A missing or locked file is as good as deleted for a cache.
} }
} }
/// Empties the active account's cache.
Future<void> clear() async { Future<void> clear() async {
try { try {
final dir = await _directory(); final dir = await _directory();
@@ -105,7 +130,20 @@ class CacheStore {
} on Object catch (e) { } on Object catch (e) {
debugPrint('CacheStore.clear failed: $e'); debugPrint('CacheStore.clear failed: $e');
} }
_dir = null; _dirs.clear();
}
/// Removes the cache of the account with [namespace], active or not.
Future<void> deleteNamespace(String namespace) async {
final name = directoryName(namespace);
try {
final base = await getApplicationCacheDirectory();
final dir = Directory('${base.path}/$name');
if (dir.existsSync()) await dir.delete(recursive: true);
} on Object catch (e) {
debugPrint('CacheStore.deleteNamespace failed: $e');
}
_dirs.removeWhere((key, _) => key == name);
} }
/// All stored keys, optionally filtered by [prefix]. /// All stored keys, optionally filtered by [prefix].
@@ -124,18 +162,23 @@ class CacheStore {
} }
} }
/// Removes entries not written for [maxAge], judged by file mtime so nothing /// Removes entries not written for [maxAge] from every account's cache,
/// has to be read or decoded. /// judged by file mtime so nothing has to be read or decoded.
Future<void> deleteOlderThan(Duration maxAge) async { Future<void> deleteOlderThan(Duration maxAge) async {
try { try {
final dir = await _directory(); final base = await getApplicationCacheDirectory();
final cutoff = DateTime.now().subtract(maxAge); final cutoff = DateTime.now().subtract(maxAge);
await for (final dir in base.list()) {
if (dir is! Directory) continue;
final name = dir.uri.pathSegments.lastWhere((s) => s.isNotEmpty);
if (!name.startsWith(_directoryPrefix)) continue;
await for (final entity in dir.list()) { await for (final entity in dir.list()) {
if (entity is! File) continue; if (entity is! File) continue;
// ignore: avoid_slow_async_io // ignore: avoid_slow_async_io
final modified = (await entity.stat()).modified; final modified = (await entity.stat()).modified;
if (modified.isBefore(cutoff)) await entity.delete(); if (modified.isBefore(cutoff)) await entity.delete();
} }
}
} on Object catch (e) { } on Object catch (e) {
debugPrint('CacheStore.deleteOlderThan failed: $e'); debugPrint('CacheStore.deleteOlderThan failed: $e');
} }
+23
View File
@@ -1,5 +1,7 @@
import '../../../state/app/modules/capabilities/bloc/capabilities_state.dart'; import '../../../state/app/modules/capabilities/bloc/capabilities_state.dart';
import '../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_state.dart'; import '../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_state.dart';
import '../../marianumconnect/queries/get_capabilities/guardian_child.dart';
import '../demo_persona.dart';
/// Demo fixtures for the mobile capability flags — everything granted so the /// Demo fixtures for the mobile capability flags — everything granted so the
/// demo persona sees every feature (incl. push) as available and the timetable /// demo persona sees every feature (incl. push) as available and the timetable
@@ -17,6 +19,27 @@ class DemoCapabilities {
userType: 'STUDENT', userType: 'STUDENT',
loaded: true, loaded: true,
); );
/// Guardian persona with two children, so the child switcher is visible.
static CapabilitiesState guardianState() => const CapabilitiesState(
pushNotifications: true,
userType: 'PARENT',
children: [
GuardianChild(
id: 'demo-child-1',
firstName: DemoPersona.studentFirstName,
lastName: 'Hoffmann',
className: DemoPersona.className,
),
GuardianChild(
id: 'demo-child-2',
firstName: 'Jonas',
lastName: 'Hoffmann',
className: '6a',
),
],
loaded: true,
);
} }
/// Demo fixtures for the Nextcloud `files_sharing` capabilities — a permissive /// Demo fixtures for the Nextcloud `files_sharing` capabilities — a permissive
+273
View File
@@ -0,0 +1,273 @@
import '../../marianumconnect/queries/parent_letters/parent_letter_models.dart';
import '../demo_persona.dart';
/// Demo inbox of the guardian persona: one letter per variant (information,
/// acknowledgement, choice, signature, answered, expired, thread, attachment).
/// Child ids match [DemoCapabilities.guardianState].
class DemoParentLetters {
const DemoParentLetters._();
static const _lena = 'demo-child-1';
static const _jonas = 'demo-child-2';
static final Set<String> _readInSession = {};
static void markRead(String letterId) => _readInSession.add(letterId);
static ParentLetterListResponse list() {
final items = [for (final letter in _letters()) letter.summary];
return ParentLetterListResponse(
items: items,
unreadCount: items.where((letter) => !letter.read).length,
openCount: items
.where((letter) => letter.status == ParentLetterStatus.open)
.length,
);
}
static ParentLetterDetail detail(String letterId) {
final letters = _letters();
return letters.firstWhere(
(letter) => letter.summary.id == letterId,
orElse: () => letters.first,
);
}
static ParentLetterPerson _teacher(int index) =>
ParentLetterPerson(displayName: DemoPersona.teachers[index].name);
static const _participation = ParentLetterField(
id: 'participation',
type: ParentLetterFieldType.singleChoice,
label: 'Nimmt Ihr Kind teil?',
isRequired: true,
options: [
ParentLetterOption(id: 'yes', label: 'Ja'),
ParentLetterOption(id: 'no', label: 'Nein'),
],
);
static List<ParentLetterDetail> _letters() {
final now = DateTime.now();
ParentLetterDetail letter({
required String id,
required String subject,
required ParentLetterPerson sender,
required Duration age,
required String body,
required List<String> childIds,
bool read = true,
ParentLetterStatus status = ParentLetterStatus.info,
ParentLetterRequest? request,
List<ParentLetterChildState> children = const [],
List<ParentLetterAttachment> attachments = const [],
ParentLetterThread thread = const ParentLetterThread(enabled: true),
}) => ParentLetterDetail(
summary: ParentLetterSummary(
id: id,
subject: subject,
preview: body.replaceAll('\n', ' ').trim(),
sender: sender,
sentAt: now.subtract(age),
read: read || _readInSession.contains(id),
childIds: childIds,
attachmentCount: attachments.length,
status: status,
deadline: request?.deadline,
),
content: ParentLetterContent(
body: body,
attachments: attachments,
request: request,
children: children,
thread: thread,
),
);
return [
letter(
id: 'demo-letter-hike',
subject: 'Wandertag der ${DemoPersona.className}',
sender: _teacher(0),
age: const Duration(hours: 2),
read: false,
childIds: const [_lena],
status: ParentLetterStatus.open,
body:
'Liebe Eltern,\n\nam Freitag in zwei Wochen findet unser Wandertag '
'statt. Wir fahren mit dem Bus in die Rhön und sind gegen 16 Uhr '
'zurück. Alle Einzelheiten finden Sie im angehängten Schreiben.\n\n'
'Bitte geben Sie uns eine verbindliche Rückmeldung.\n\n'
'Viele Grüße\n${DemoPersona.teachers[0].name}',
attachments: const [
ParentLetterAttachment(
id: 'demo-attachment-hike',
fileName: 'Wandertag.pdf',
mimeType: 'application/pdf',
size: 48211,
),
],
request: ParentLetterRequest(
fields: const [_participation],
signatureRequired: true,
deadline: now.add(const Duration(days: 10)),
),
children: const [
ParentLetterChildState(
childId: _lena,
status: ParentLetterStatus.open,
editable: true,
),
],
),
letter(
id: 'demo-letter-parents-evening',
subject: 'Einladung zum Elternabend der 6a',
sender: _teacher(1),
age: const Duration(days: 1),
read: false,
childIds: const [_jonas],
status: ParentLetterStatus.open,
body:
'Liebe Eltern,\n\nhiermit lade ich Sie herzlich zum ersten '
'Elternabend des Schuljahres ein. Wir treffen uns am kommenden '
'Dienstag um 19 Uhr in Raum ${DemoPersona.rooms[0]}.\n\n'
'Bitte bestätigen Sie kurz den Erhalt dieser Einladung.',
request: const ParentLetterRequest(),
children: const [
ParentLetterChildState(
childId: _jonas,
status: ParentLetterStatus.open,
editable: true,
),
],
),
letter(
id: 'demo-letter-school-festival',
subject: 'Schulfest: Wer kommt mit?',
sender: _teacher(5),
age: const Duration(days: 3),
childIds: const [_lena, _jonas],
status: ParentLetterStatus.open,
body:
'Liebe Eltern,\n\nfür die Planung unseres Schulfestes möchten wir '
'wissen, welche Kinder teilnehmen. Die Angabe können Sie bis zum '
'Ablauf der Frist noch ändern.\n\nWeitere Informationen: '
'https://www.marianum-fulda.de',
request: ParentLetterRequest(
fields: const [_participation],
deadline: now.add(const Duration(days: 5)),
),
children: [
ParentLetterChildState(
childId: _lena,
status: ParentLetterStatus.done,
editable: true,
response: ParentLetterResponse(
respondedAt: now.subtract(const Duration(days: 2)),
respondedBy: const ParentLetterPerson(self: true),
answers: const [
ParentLetterAnswer(
fieldId: 'participation',
optionIds: ['yes'],
),
],
),
),
const ParentLetterChildState(
childId: _jonas,
status: ParentLetterStatus.open,
editable: true,
),
],
),
letter(
id: 'demo-letter-reports',
subject: 'Zeugnisausgabe und Unterrichtsschluss',
sender: _teacher(3),
age: const Duration(days: 7),
childIds: const [_lena, _jonas],
body:
'Liebe Eltern,\n\nam letzten Schultag vor den Ferien endet der '
'Unterricht nach der dritten Stunde mit der Zeugnisausgabe. Die '
'Busse fahren entsprechend früher.',
thread: ParentLetterThread(
enabled: true,
messages: [
ParentLetterThreadMessage(
id: 'demo-thread-1',
author: const ParentLetterPerson(self: true),
body: 'Gilt das auch für die Nachmittagsbetreuung?',
sentAt: now.subtract(const Duration(days: 6, hours: 20)),
),
ParentLetterThreadMessage(
id: 'demo-thread-2',
author: _teacher(3),
body:
'Ja, die Betreuung entfällt an diesem Tag ebenfalls. Bei '
'Bedarf melden Sie sich bitte im Sekretariat.',
sentAt: now.subtract(const Duration(days: 6, hours: 2)),
),
],
),
),
letter(
id: 'demo-letter-swimming',
subject: 'Einverständnis Schwimmunterricht',
sender: _teacher(6),
age: const Duration(days: 21),
childIds: const [_jonas],
status: ParentLetterStatus.done,
body:
'Liebe Eltern,\n\nim zweiten Halbjahr findet der Sportunterricht '
'der 6a im Hallenbad statt. Dafür benötigen wir Ihr '
'Einverständnis.',
request: const ParentLetterRequest(
fields: [_participation],
signatureRequired: true,
),
children: [
ParentLetterChildState(
childId: _jonas,
status: ParentLetterStatus.done,
response: ParentLetterResponse(
respondedAt: now.subtract(const Duration(days: 20)),
respondedBy: const ParentLetterPerson(displayName: 'M. Hoffmann'),
answers: const [
ParentLetterAnswer(
fieldId: 'participation',
optionIds: ['yes'],
),
],
signed: true,
),
),
],
thread: const ParentLetterThread(),
),
letter(
id: 'demo-letter-ski-trip',
subject: 'Anmeldung zur Skifreizeit',
sender: _teacher(6),
age: const Duration(days: 35),
childIds: const [_lena],
status: ParentLetterStatus.expired,
body:
'Liebe Eltern,\n\ndie Anmeldung zur Skifreizeit der Jahrgangsstufe '
'10 ist ab sofort möglich. Die Plätze sind begrenzt.',
request: ParentLetterRequest(
fields: const [_participation],
signatureRequired: true,
deadline: now.subtract(const Duration(days: 14)),
),
children: const [
ParentLetterChildState(
childId: _lena,
status: ParentLetterStatus.expired,
),
],
thread: const ParentLetterThread(),
),
];
}
}
+4 -3
View File
@@ -1,4 +1,4 @@
import '../../../model/account_data.dart'; import '../../../session/session_manager.dart';
import '../../marianumcloud/talk/chat/get_chat_response.dart'; import '../../marianumcloud/talk/chat/get_chat_response.dart';
import '../../marianumcloud/talk/room/get_room_response.dart'; import '../../marianumcloud/talk/room/get_room_response.dart';
import '../demo_persona.dart'; import '../demo_persona.dart';
@@ -204,7 +204,8 @@ class DemoTalk {
id: base + 2, id: base + 2,
token: token, token: token,
ago: const Duration(days: 1, hours: 6), ago: const Duration(days: 1, hours: 6),
message: 'Danke! Können wir Aufgabe 5 nächste Stunde nochmal besprechen?', message:
'Danke! Können wir Aufgabe 5 nächste Stunde nochmal besprechen?',
), ),
_msg( _msg(
id: base + 3, id: base + 3,
@@ -366,7 +367,7 @@ class DemoTalk {
}) => _msg( }) => _msg(
id: id, id: id,
token: token, token: token,
actor: AccountData().getUsername(), actor: SessionManager().requireNextcloud().username,
display: DemoPersona.studentName, display: DemoPersona.studentName,
ago: ago, ago: ago,
message: message, message: message,
+11 -3
View File
@@ -1,4 +1,4 @@
import '../../model/account_data.dart'; import '../../session/session_manager.dart';
/// Central switch for the client-side demo mode. /// Central switch for the client-side demo mode.
/// ///
@@ -8,7 +8,7 @@ import '../../model/account_data.dart';
/// Play reviewers and automated screenshot runs see a fully populated app /// Play reviewers and automated screenshot runs see a fully populated app
/// without a real account and without any network dependency. /// without a real account and without any network dependency.
/// ///
/// The flag is persisted through [AccountData.isDemo], so a demo session /// The flag is persisted through [Session.isDemo], so a demo session
/// survives cold starts exactly like a normal login. It works in release builds /// survives cold starts exactly like a normal login. It works in release builds
/// too — reviewers run the shipped release build. /// too — reviewers run the shipped release build.
class DemoMode { class DemoMode {
@@ -22,6 +22,14 @@ class DemoMode {
static bool matches(String username) => static bool matches(String username) =>
username.trim().toLowerCase().startsWith(usernamePrefix); username.trim().toLowerCase().startsWith(usernamePrefix);
/// Guardian logins are passwordless, so reviewers need a fixed address that
/// skips the mail round-trip. Deliberately not the `demo@` prefix, which a
/// real e-mail address could start with.
static const String guardianEmail = 'demo-eltern@marianum-fulda.de';
static bool matchesGuardian(String email) =>
email.trim().toLowerCase() == guardianEmail;
/// True while the active session is a demo session. /// True while the active session is a demo session.
static bool get active => AccountData().isDemo; static bool get active => SessionManager().isDemo;
} }
+8 -1
View File
@@ -5,6 +5,7 @@ import 'package:dio/dio.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:nextcloud/nextcloud.dart'; import 'package:nextcloud/nextcloud.dart';
import '../../session/session.dart';
import '../api_error.dart'; import '../api_error.dart';
import '../http_errors.dart'; import '../http_errors.dart';
import '../marianumcloud/talk/talk_error.dart'; import '../marianumcloud/talk/talk_error.dart';
@@ -61,7 +62,9 @@ AppException? _dioToAppException(DioException error) {
AppException _dynamiteToAppException(DynamiteApiException error) { AppException _dynamiteToAppException(DynamiteApiException error) {
final status = error.statusCode; final status = error.statusCode;
final preview = previewBody(error.body); final preview = previewBody(error.body);
final detail = preview.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview'; final detail = preview.isEmpty
? 'HTTP $status'
: 'HTTP $status body=$preview';
switch (status) { switch (status) {
case 401: case 401:
return AuthException.unauthorized(technicalDetails: detail); return AuthException.unauthorized(technicalDetails: detail);
@@ -86,6 +89,9 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
if (error is AppException) return error.userMessage; if (error is AppException) return error.userMessage;
if (error is TalkError) return TalkException(error).userMessage; if (error is TalkError) return TalkException(error).userMessage;
if (error is NextcloudUnavailableException) {
return 'Diese Funktion ist mit deinem Konto nicht verfügbar.';
}
if (error is DioException) { if (error is DioException) {
final mapped = _dioToAppException(error); final mapped = _dioToAppException(error);
@@ -136,6 +142,7 @@ String? errorToTechnicalDetails(Object? error) {
bool errorAllowsRetry(Object? error) { bool errorAllowsRetry(Object? error) {
if (error == null) return true; if (error == null) return true;
if (error is AppException) return error.allowRetry; if (error is AppException) return error.allowRetry;
if (error is NextcloudUnavailableException) return false;
if (error is DioException) { if (error is DioException) {
final mapped = _dioToAppException(error); final mapped = _dioToAppException(error);
if (mapped != null) return mapped.allowRetry; if (mapped != null) return mapped.allowRetry;
@@ -16,9 +16,14 @@ class DeleteAppPassword {
Future<void> run({String? authorizationHeader}) async { Future<void> run({String? authorizationHeader}) async {
await _client.delete( await _client.delete(
NextcloudOcs.uri('core/apppassword'), NextcloudOcs.uri('core/apppassword'),
headers: { // An explicit header may belong to an inactive account, so the active
...NextcloudOcs.headers(), // session's headers must not be required then.
'Authorization': ?authorizationHeader, headers: authorizationHeader == null
? NextcloudOcs.headers()
: {
'Accept': 'application/json',
'OCS-APIRequest': 'true',
'Authorization': authorizationHeader,
}, },
); );
} }
@@ -2,13 +2,13 @@ import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../../model/account_data.dart'; import '../../../session/session_manager.dart';
import '../../http_errors.dart'; import '../../http_errors.dart';
import '../nextcloud_ocs.dart'; import '../nextcloud_ocs.dart';
/// Exchanges the user's real Nextcloud password for a scoped app password via /// Exchanges the user's real Nextcloud password for a scoped app password via
/// `GET /ocs/v2.php/core/getapppassword`. All subsequent Nextcloud calls then /// `GET /ocs/v2.php/core/getapppassword`. All subsequent Nextcloud calls then
/// authenticate with the app password (see [AccountData.getBasicAuthHeader]), /// authenticate with the app password (see [NextcloudCredentials.basicAuthHeader]),
/// which is what the push-v2 registration binds to. /// which is what the push-v2 registration binds to.
/// ///
/// Must authenticate with the *real* password — an app password cannot mint /// Must authenticate with the *real* password — an app password cannot mint
@@ -33,7 +33,9 @@ class GetAppPassword {
// Deliberately NOT the shared Authorization value: that one prefers // Deliberately NOT the shared Authorization value: that one prefers
// the app password, but an app password cannot mint another one — // the app password, but an app password cannot mint another one —
// this endpoint requires the real password. // this endpoint requires the real password.
'Authorization': AccountData().getRealPasswordBasicAuthHeader(), 'Authorization': SessionManager()
.requireNextcloud()
.realPasswordBasicAuthHeader,
}, },
), ),
))!; ))!;
@@ -4,8 +4,8 @@ import 'dart:typed_data';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../../model/account_data.dart';
import '../../../model/endpoint_data.dart'; import '../../../model/endpoint_data.dart';
import '../../../session/session_manager.dart';
import '../../errors/parse_exception.dart'; import '../../errors/parse_exception.dart';
import '../../http_errors.dart'; import '../../http_errors.dart';
import '../nextcloud_ocs.dart'; import '../nextcloud_ocs.dart';
@@ -27,12 +27,12 @@ Uri _coreAvatarUri() {
return Uri.https(endpoint.domain, '${endpoint.path}/avatar/'); return Uri.https(endpoint.domain, '${endpoint.path}/avatar/');
} }
Uri _userInfoUri() => Uri _userInfoUri() => NextcloudOcs.uri(
NextcloudOcs.uri('cloud/users/${AccountData().getUsername()}'); 'cloud/users/${SessionManager().requireNextcloud().username}',
);
Future<http.Response> _send( Future<http.Response> _send(
Future<http.Response> Function(Uri uri, Map<String, String> headers) Future<http.Response> Function(Uri uri, Map<String, String> headers) perform,
perform,
Uri uri, Uri uri,
) async { ) async {
final headers = NextcloudOcs.headers(); final headers = NextcloudOcs.headers();
@@ -98,16 +98,13 @@ class GetUserInfo {
try { try {
final root = jsonDecode(response.body) as Map<String, dynamic>; final root = jsonDecode(response.body) as Map<String, dynamic>;
final data = final data =
(root['ocs'] as Map<String, dynamic>)['data'] (root['ocs'] as Map<String, dynamic>)['data'] as Map<String, dynamic>;
as Map<String, dynamic>;
return CloudUserInfo( return CloudUserInfo(
userId: data['id'] as String, userId: data['id'] as String,
displayName: (data['displayname'] as String?) ?? '', displayName: (data['displayname'] as String?) ?? '',
); );
} catch (e) { } catch (e) {
throw ParseException( throw ParseException(technicalDetails: 'Cloud $uri user info parse: $e');
technicalDetails: 'Cloud $uri user info parse: $e',
);
} }
} }
} }
+2 -2
View File
@@ -1,7 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import '../../model/account_data.dart';
import '../../model/endpoint_data.dart'; import '../../model/endpoint_data.dart';
import '../../session/session_manager.dart';
/// Shared headers and URI builder for Nextcloud OCS v2 endpoints. Used by /// Shared headers and URI builder for Nextcloud OCS v2 endpoints. Used by
/// TalkApi, AutocompleteApi, FileSharingApi. /// TalkApi, AutocompleteApi, FileSharingApi.
@@ -16,7 +16,7 @@ class NextcloudOcs {
static Map<String, String> headers() => { static Map<String, String> headers() => {
'Accept': 'application/json', 'Accept': 'application/json',
'OCS-APIRequest': 'true', 'OCS-APIRequest': 'true',
'Authorization': AccountData().getBasicAuthHeader(), 'Authorization': SessionManager().requireNextcloud().basicAuthHeader,
}; };
static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) { static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) {
+8 -8
View File
@@ -1,7 +1,7 @@
import 'package:nextcloud/nextcloud.dart'; import 'package:nextcloud/nextcloud.dart';
import '../../../model/account_data.dart';
import '../../../model/endpoint_data.dart'; import '../../../model/endpoint_data.dart';
import '../../../session/session_manager.dart';
import '../../api_response.dart'; import '../../api_response.dart';
abstract class WebdavApi<T> { abstract class WebdavApi<T> {
@@ -20,8 +20,8 @@ abstract class WebdavApi<T> {
static Future<WebDavClient> get webdav { static Future<WebDavClient> get webdav {
// Keyed by user too: two accounts may share a password (no app password // Keyed by user too: two accounts may share a password (no app password
// minted), and the client would keep the previous login name. // minted), and the client would keep the previous login name.
final secret = final nextcloud = SessionManager().requireNextcloud();
'${AccountData().getUsername()}:${AccountData().getNextcloudSecret()}'; final secret = '${nextcloud.username}:${nextcloud.secret}';
if (_webdav == null || _webdavSecret != secret) { if (_webdav == null || _webdavSecret != secret) {
_webdavSecret = secret; _webdavSecret = secret;
_webdav = establishWebdavConnection(); _webdav = establishWebdavConnection();
@@ -33,13 +33,13 @@ abstract class WebdavApi<T> {
NextcloudClient( NextcloudClient(
Uri.parse('https://${EndpointData().nextcloud().full()}'), Uri.parse('https://${EndpointData().nextcloud().full()}'),
// App password preferred — with 2FA the real password is not accepted // App password preferred — with 2FA the real password is not accepted
// by Nextcloud at all (see AccountData.usesLoginFlow). // by Nextcloud at all (see NextcloudCredentials.usesLoginFlow).
password: AccountData().getNextcloudSecret(), password: SessionManager().requireNextcloud().secret,
loginName: AccountData().getUsername(), loginName: SessionManager().requireNextcloud().username,
).webdav; ).webdav;
/// Builds the WebDAV download URL without embedded credentials. Callers must /// Builds the WebDAV download URL without embedded credentials. Callers must
/// authenticate via the [AccountData.authHeaders] header instead. /// authenticate via the [NextcloudCredentials.authHeaders] header instead.
static String buildWebdavUrl() => static String buildWebdavUrl() =>
'https://${EndpointData().nextcloud().full()}/remote.php/dav/files/${AccountData().getUsername()}/'; 'https://${EndpointData().nextcloud().full()}/remote.php/dav/files/${SessionManager().requireNextcloud().username}/';
} }
@@ -1,12 +1,14 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../../../model/account_data.dart'; import '../../../session/session.dart';
import '../../../session/session_manager.dart';
import '../queries/auth_login/auth_login.dart'; import '../queries/auth_login/auth_login.dart';
import 'device_token_name.dart'; import 'device_token_name.dart';
import 'token_storage.dart'; import 'token_storage.dart';
/// Adds the bearer token to outgoing Marianum-Connect requests and, on 401, /// Adds the bearer token to outgoing Marianum-Connect requests and, on 401,
/// re-logs in once with the credentials in [AccountData] before retrying. /// renews the token once before retrying. Only password accounts can renew
/// silently; passwordless accounts surface the 401.
class MarianumConnectAuthInterceptor extends Interceptor { class MarianumConnectAuthInterceptor extends Interceptor {
static const _retriedKey = 'mc_auth_retried'; static const _retriedKey = 'mc_auth_retried';
@@ -18,6 +20,15 @@ class MarianumConnectAuthInterceptor extends Interceptor {
// each spawning a fresh row in api_tokens. // each spawning a fresh row in api_tokens.
Future<bool>? _pendingReLogin; Future<bool>? _pendingReLogin;
static Future<bool>? _anyPendingReLogin;
/// Resolves once no silent re-login is running. An account switch waits for
/// it — the renewed token would otherwise land in the next account's slot.
static Future<void> idle() async {
final pending = _anyPendingReLogin;
if (pending != null) await pending;
}
MarianumConnectAuthInterceptor({ MarianumConnectAuthInterceptor({
MarianumConnectTokenStorage tokenStorage = MarianumConnectTokenStorage tokenStorage =
const MarianumConnectTokenStorage(), const MarianumConnectTokenStorage(),
@@ -64,6 +75,9 @@ class MarianumConnectAuthInterceptor extends Interceptor {
} }
final refreshed = await _attemptReLogin(); final refreshed = await _attemptReLogin();
if (!refreshed) { if (!refreshed) {
if (SessionManager().current is GuardianSession) {
SessionManager().reportUnauthorized();
}
handler.next(err); handler.next(err);
return; return;
} }
@@ -80,32 +94,35 @@ class MarianumConnectAuthInterceptor extends Interceptor {
if (inFlight != null) return inFlight; if (inFlight != null) return inFlight;
final fresh = _performReLogin(); final fresh = _performReLogin();
_pendingReLogin = fresh; _pendingReLogin = fresh;
_anyPendingReLogin = fresh;
fresh.whenComplete(() { fresh.whenComplete(() {
if (identical(_pendingReLogin, fresh)) _pendingReLogin = null; if (identical(_pendingReLogin, fresh)) _pendingReLogin = null;
if (identical(_anyPendingReLogin, fresh)) _anyPendingReLogin = null;
}); });
return fresh; return fresh;
} }
Future<bool> _performReLogin() async { Future<bool> _performReLogin() async {
if (!AccountData().isPopulated()) return false; final session = SessionManager().current;
final username = AccountData().getUsername(); if (session is! CredentialSession) return false;
final username = session.username;
// A background engine (widget task) keeps the account it loaded. When // A background engine (widget task) keeps the account it loaded. When
// the app signed that account out, its revoked token answers 401 — a // the app signed that account out, its revoked token answers 401 — a
// re-login would mint a fresh token for it into the shared keystore. // re-login would mint a fresh token for it into the shared keystore.
if (await AccountData().readStoredUsername() != username) return false; if (await SessionManager().readStoredUsername() != username) return false;
try { try {
await _loginClient.run( await _loginClient.run(
username: username, username: username,
password: AccountData().getPassword(), password: session.password,
tokenName: await DeviceTokenName.resolve(), tokenName: await DeviceTokenName.resolve(),
); );
} catch (_) { } catch (_) {
if (await AccountData().readStoredUsername() == username) { if (await SessionManager().readStoredUsername() == username) {
await _tokenStorage.clear(); await _tokenStorage.clear();
} }
return false; return false;
} }
final stored = await AccountData().readStoredUsername(); final stored = await SessionManager().readStoredUsername();
if (stored == username) return true; if (stored == username) return true;
// Signed out during the login: drop the orphaned token, unless another // Signed out during the login: drop the orphaned token, unless another
// account already stored its own. // account already stored its own.
@@ -1,39 +1,50 @@
import 'dart:developer'; import 'dart:developer';
import '../../../model/account_data.dart'; import '../../../session/session.dart';
import '../../../session/session_lifecycle.dart';
import '../../../session/session_manager.dart';
import '../../errors/auth_exception.dart'; import '../../errors/auth_exception.dart';
import '../queries/auth_logout/auth_logout.dart'; import '../queries/auth_me/auth_me.dart';
import '../queries/auth_verify/auth_verify.dart'; import '../queries/auth_verify/auth_verify.dart';
import 'token_storage.dart';
/// Background credential probe — a server-side password rotation forces a /// Credential probe. For password accounts a server-side password rotation
/// re-login on the next cold start even when the bearer token would still /// forces a re-login on the next cold start even when the bearer token would
/// be accepted. /// still be accepted; for guardians it confirms a rejected token before the
/// session is dropped. Another stored account then takes over.
class SessionValidator { class SessionValidator {
static Future<void> probeStored({ static Future<void> probeStored({
required Future<void> Function() onInvalidated, required Future<void> Function(String? nextAccountId) onInvalidated,
}) async { }) async {
if (!AccountData().isPopulated()) return; final session = SessionManager().current;
// AuthVerify uses its own dio (bypassing the demo interceptor), so a demo // The probes use their own dio (bypassing the demo interceptor), so a demo
// session must be skipped here or its missing token would 401 into a logout. // session must be skipped or its missing token would 401 into a logout.
if (AccountData().isDemo) return; if (session == null || session.isDemo) return;
final username = AccountData().getUsername(); final epoch = SessionManager().sessionEpoch;
final password = AccountData().getPassword();
final epoch = AccountData().sessionEpoch;
try { try {
switch (session) {
case CredentialSession(:final username, :final password):
await AuthVerify().run(username: username, password: password); await AuthVerify().run(username: username, password: password);
case GuardianSession():
await AuthMe().run();
}
} on AuthException catch (e) { } on AuthException catch (e) {
if (e.statusCode != 401) return; if (e.statusCode != 401) return;
// The probed account already signed out; the 401 must not sign out // The probed account is no longer the active one; the 401 must not
// whoever logged in meanwhile. // sign out whoever took over meanwhile.
if (!AccountData().isCurrentSession(epoch)) return; if (!SessionManager().isCurrentSession(epoch)) return;
log('MC: stored credentials rejected — forcing re-login'); log('MC: stored session rejected — forcing re-login');
await AuthLogout().run(); final next = await SessionLifecycle.signOut(
await const MarianumConnectTokenStorage().clear(); notice: switch (session) {
await AccountData().removeData(); CredentialSession() =>
await onInvalidated(); 'Deine Zugangsdaten wurden vom Server abgelehnt. Vermutlich '
'wurde dein Passwort geändert. Bitte melde dich erneut an.',
GuardianSession() =>
'Deine Anmeldung ist abgelaufen. Bitte melde dich erneut an.',
},
);
await onInvalidated(next);
} catch (e) { } catch (e) {
log('MC: background credential check failed (transient): $e'); log('MC: background session check failed (transient): $e');
} }
} }
} }
@@ -1,5 +1,8 @@
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../errors/auth_exception.dart';
/// `first_unlock` accessibility so the token can be read during background /// `first_unlock` accessibility so the token can be read during background
/// requests (telemetry heartbeat, push-triggered syncs) after the first device /// requests (telemetry heartbeat, push-triggered syncs) after the first device
/// unlock following a reboot. The keychain default (`whenUnlocked`) throws /// unlock following a reboot. The keychain default (`whenUnlocked`) throws
@@ -9,7 +12,7 @@ const IOSOptions _mcIosOptions = IOSOptions(
); );
/// Persists the Marianum-Connect bearer token in the platform keystore. Kept /// Persists the Marianum-Connect bearer token in the platform keystore. Kept
/// separate from `AccountData` because the username/password live on (Nextcloud /// separate from `SessionManager` because the username/password live on (Nextcloud
/// + MHSL still need them) while the MC token is short-lived and per-endpoint. /// + MHSL still need them) while the MC token is short-lived and per-endpoint.
class MarianumConnectTokenStorage { class MarianumConnectTokenStorage {
static const _tokenKey = 'mc_bearer_token'; static const _tokenKey = 'mc_bearer_token';
@@ -24,6 +27,18 @@ class MarianumConnectTokenStorage {
Future<String?> readToken() => _storage.read(key: _tokenKey); Future<String?> readToken() => _storage.read(key: _tokenKey);
/// Request options carrying the stored token, for probes that bypass the
/// auth interceptor. Throws [AuthException] when no token is stored.
Future<Options> requireBearerOptions(String caller) async {
final token = await readToken();
if (token == null || token.isEmpty) {
throw AuthException.unauthorized(
technicalDetails: '$caller: no bearer token in storage',
);
}
return Options(headers: {'Authorization': 'Bearer $token'});
}
Future<String?> readTokenId() => _storage.read(key: _tokenIdKey); Future<String?> readTokenId() => _storage.read(key: _tokenIdKey);
Future<DateTime?> readExpiresAt() async { Future<DateTime?> readExpiresAt() async {
@@ -45,6 +60,26 @@ class MarianumConnectTokenStorage {
); );
} }
static const bearerKey = _tokenKey;
static const fieldKeys = [_tokenKey, _tokenIdKey, _expiresAtKey];
/// Raw stored fields, for parking the token of an inactive account.
Future<Map<String, String>> readAll() async => {
for (final key in fieldKeys) key: ?await _storage.read(key: key),
};
/// Restores fields from [readAll]; missing ones are deleted.
Future<void> writeAll(Map<String, String> fields) async {
for (final key in fieldKeys) {
final value = fields[key];
if (value == null) {
await _storage.delete(key: key);
} else {
await _storage.write(key: key, value: value);
}
}
}
Future<void> clear() async { Future<void> clear() async {
await _storage.delete(key: _tokenKey); await _storage.delete(key: _tokenKey);
await _storage.delete(key: _tokenIdKey); await _storage.delete(key: _tokenIdKey);
@@ -6,6 +6,22 @@ import '../../errors/network_exception.dart';
import '../../errors/parse_exception.dart'; import '../../errors/parse_exception.dart';
import '../../errors/server_exception.dart'; import '../../errors/server_exception.dart';
/// The error code of a rejected call: the server answers either with JSON
/// `{"error": "<code>"}` or with the plain text `Fehler: <code>` of its
/// generic error handler.
String? marianumConnectErrorCode(Object? body) {
if (body is Map) {
final code = body['error'];
return code is String ? code : null;
}
if (body is String) {
const prefix = 'Fehler: ';
return (body.startsWith(prefix) ? body.substring(prefix.length) : body)
.trim();
}
return null;
}
/// Converts a DioException raised against the Marianum-Connect API into one of /// Converts a DioException raised against the Marianum-Connect API into one of
/// the app's typed AppExceptions. Keeps the dio dependency out of call sites /// the app's typed AppExceptions. Keeps the dio dependency out of call sites
/// that just want to render an error message. /// that just want to render an error message.
@@ -1,5 +1,8 @@
import 'dart:typed_data';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../errors/app_exception.dart';
import 'errors/marianumconnect_error.dart'; import 'errors/marianumconnect_error.dart';
import 'marianumconnect_api.dart'; import 'marianumconnect_api.dart';
import 'marianumconnect_endpoint.dart'; import 'marianumconnect_endpoint.dart';
@@ -23,10 +26,14 @@ abstract class MarianumConnectQuery {
try { try {
return await body(); return await body();
} on DioException catch (e) { } on DioException catch (e) {
throw mapMarianumConnectError(e); throw mapError(e);
} }
} }
/// The AppException a failed call surfaces as. Query families with domain
/// error codes override this instead of re-implementing [guard].
AppException mapError(DioException error) => mapMarianumConnectError(error);
/// GETs [path] and parses the JSON object body with [fromJson]. /// GETs [path] and parses the JSON object body with [fromJson].
Future<T> getObject<T>( Future<T> getObject<T>(
String path, String path,
@@ -55,6 +62,16 @@ abstract class MarianumConnectQuery {
.toList(); .toList();
}); });
/// GETs the raw bytes of [path] (files that need the bearer token).
Future<Uint8List> getBytes(String path) => guard(() async {
final response = await dio.get<List<int>>(
endpoint(path),
options: Options(responseType: ResponseType.bytes),
);
final bytes = response.data!;
return bytes is Uint8List ? bytes : Uint8List.fromList(bytes);
});
/// Formats [d] as an ISO `yyyy-MM-dd` day for MarianumConnect query params. /// Formats [d] as an ISO `yyyy-MM-dd` day for MarianumConnect query params.
String isoDate(DateTime d) => String isoDate(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
@@ -1,10 +1,30 @@
import '../../../errors/app_exception.dart';
import '../../marianumconnect_query.dart'; import '../../marianumconnect_query.dart';
import 'absence_prefill_response.dart'; import 'absence_prefill_response.dart';
/// GETs the absence-report form prefill (`absence/prefill`, bearer-auth). /// GETs the absence-report form prefill (`absence/prefill`, bearer-auth).
/// Guardians pass the [childId] the report is for; the identity then comes
/// from that child.
class AbsencePrefill extends MarianumConnectQuery { class AbsencePrefill extends MarianumConnectQuery {
AbsencePrefill({super.dio}); AbsencePrefill({super.dio});
Future<AbsencePrefillResponse> run() => Future<AbsencePrefillResponse> run({String? childId}) => getObject(
getObject('absence/prefill', AbsencePrefillResponse.fromJson); 'absence/prefill',
AbsencePrefillResponse.fromJson,
queryParameters: {'childId': ?childId},
);
}
/// The prefill of a guardian's report is missing a field the form cannot ask
/// for: name and class come from the child and are not editable there, so an
/// incomplete prefill would leave a locked, unsubmittable form behind.
class AbsencePrefillIncompleteException extends AppException {
const AbsencePrefillIncompleteException({super.technicalDetails})
: super(
userMessage:
'Für dieses Kind sind in der Schulverwaltung noch nicht alle '
'Angaben hinterlegt, die für eine Abwesenheitsmeldung nötig sind. '
'Bitte wende dich an das Sekretariat.',
allowRetry: false,
);
} }
@@ -3,7 +3,8 @@ import '../../marianumconnect_query.dart';
/// Submits an absence report (`POST absence`, bearer-auth, `source=mobile`). /// Submits an absence report (`POST absence`, bearer-auth, `source=mobile`).
/// Empty identity fields are backfilled from LDAP server-side; validation /// Empty identity fields are backfilled from LDAP server-side; validation
/// (all fields required, class must exist, no past start date, end >= start) /// (all fields required, class must exist, no past start date, end >= start)
/// also runs server-side and mirrors the client checks. /// also runs server-side and mirrors the client checks. For guardians the
/// server takes name and class from the child given by [childId].
class AbsenceSubmit extends MarianumConnectQuery { class AbsenceSubmit extends MarianumConnectQuery {
AbsenceSubmit({super.dio}); AbsenceSubmit({super.dio});
@@ -15,6 +16,7 @@ class AbsenceSubmit extends MarianumConnectQuery {
required DateTime absentUntil, required DateTime absentUntil,
required String phone, required String phone,
required String note, required String note,
String? childId,
}) => guard(() async { }) => guard(() async {
await dio.post<void>( await dio.post<void>(
endpoint('absence'), endpoint('absence'),
@@ -26,6 +28,7 @@ class AbsenceSubmit extends MarianumConnectQuery {
'absentUntil': isoDate(absentUntil), 'absentUntil': isoDate(absentUntil),
'phone': phone, 'phone': phone,
'note': note, 'note': note,
'childId': ?childId,
}, },
); );
}); });
@@ -0,0 +1,60 @@
import 'package:dio/dio.dart';
import '../../../../auth_link/pending_guardian_request.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_query.dart';
import 'guardian_login_exception.dart';
class AuthGuardianRequestResponse {
final String requestId;
final DateTime expiresAt;
final DateTime resendAvailableAt;
final int codeLength;
const AuthGuardianRequestResponse({
required this.requestId,
required this.expiresAt,
required this.resendAvailableAt,
required this.codeLength,
});
factory AuthGuardianRequestResponse.fromJson(Map<String, dynamic> json) =>
AuthGuardianRequestResponse(
requestId: json['requestId'] as String,
expiresAt: DateTime.parse(json['expiresAt'] as String),
resendAvailableAt: DateTime.parse(json['resendAvailableAt'] as String),
codeLength:
json['codeLength'] as int? ??
PendingGuardianRequest.defaultCodeLength,
);
}
/// Starts a passwordless guardian login: the server mails a code and a link.
/// An unknown address is reported as such (`email_not_registered`) instead of
/// being answered like a known one — a deliberate trade: without it guardians
/// wait for a mail that never arrives. Registered addresses are therefore
/// enumerable; keep that in mind when changing the server contract.
class AuthGuardianRequest extends MarianumConnectQuery {
AuthGuardianRequest({Dio? dio})
: super(dio: dio ?? MarianumConnectApi.plainDio());
Future<AuthGuardianRequestResponse> run({
required String email,
required String deviceChallenge,
required String tokenName,
}) async {
try {
final response = await dio.post<Map<String, dynamic>>(
endpoint('auth/guardian/request'),
data: {
'email': email,
'deviceChallenge': deviceChallenge,
'tokenName': tokenName,
},
);
return AuthGuardianRequestResponse.fromJson(response.data!);
} on DioException catch (e) {
throw GuardianLoginException.fromDio(e, verifying: false);
}
}
}
@@ -0,0 +1,51 @@
import 'package:dio/dio.dart';
import '../../auth/token_storage.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_query.dart';
import '../auth_login/auth_login_response.dart';
import 'guardian_login_exception.dart';
/// Completes a guardian login with the mailed code or link token and stores
/// the issued bearer token.
class AuthGuardianVerify extends MarianumConnectQuery {
final MarianumConnectTokenStorage _tokenStorage;
AuthGuardianVerify({
MarianumConnectTokenStorage tokenStorage =
const MarianumConnectTokenStorage(),
Dio? dio,
}) : _tokenStorage = tokenStorage,
super(dio: dio ?? MarianumConnectApi.plainDio());
Future<AuthLoginResponse> run({
required String requestId,
required String deviceVerifier,
required String tokenName,
String? code,
String? linkToken,
}) async {
assert((code == null) != (linkToken == null));
try {
final response = await dio.post<Map<String, dynamic>>(
endpoint('auth/guardian/verify'),
data: {
'requestId': requestId,
'deviceVerifier': deviceVerifier,
'tokenName': tokenName,
'code': ?code,
'linkToken': ?linkToken,
},
);
final payload = AuthLoginResponse.fromJson(response.data!);
await _tokenStorage.write(
token: payload.token,
tokenId: payload.tokenId,
expiresAt: payload.expiresAt,
);
return payload;
} on DioException catch (e) {
throw GuardianLoginException.fromDio(e);
}
}
}
@@ -0,0 +1,120 @@
import 'package:dio/dio.dart';
import '../../../errors/app_exception.dart';
import '../../errors/marianumconnect_error.dart';
enum GuardianLoginError {
invalidRequest,
emailNotRegistered,
accountDisabled,
invalidCode,
deviceMismatch,
requestConsumed,
requestExpired,
tooManyAttempts,
rateLimited,
unsupportedServer,
}
/// A rejected guardian login step with the reason the server reported.
class GuardianLoginException extends AppException {
final GuardianLoginError error;
final int? attemptsLeft;
const GuardianLoginException(
this.error, {
required super.userMessage,
this.attemptsLeft,
super.technicalDetails,
}) : super(allowRetry: false);
/// Maps a failed guardian auth call. Only 4xx answers that carry a guardian
/// login reason become [GuardianLoginException]; everything else keeps the
/// generic MarianumConnect mapping (network, 5xx, …).
///
/// [verifying] false marks the e-mail step, where no code exists yet: a bare
/// 401 (reverse proxy, gateway) must not be reported as a wrong code there.
static AppException fromDio(DioException e, {bool verifying = true}) {
final response = e.response;
final status = response?.statusCode;
if (status == null || status < 400 || status >= 500) {
return mapMarianumConnectError(e);
}
final (code, attemptsLeft) = _parseBody(response!.data);
final error = _errorFor(code, status, verifying: verifying);
if (error == null) return mapMarianumConnectError(e);
return GuardianLoginException(
error,
attemptsLeft: attemptsLeft,
userMessage: messageFor(error, attemptsLeft: attemptsLeft),
technicalDetails: 'MC $status: ${response.data}',
);
}
static String messageFor(
GuardianLoginError error, {
int? attemptsLeft,
}) => switch (error) {
GuardianLoginError.invalidRequest =>
'Bitte gib eine gültige E-Mail-Adresse ein.',
GuardianLoginError.emailNotRegistered =>
'Unter dieser E-Mail-Adresse ist kein Eltern-Zugang hinterlegt. '
'Bitte prüfe, ob die korrekte Adresse verwendet wurde. Bitte wende dich an das Sekretariat, '
'wenn du nicht weißt, welche Adresse für dich registriert ist.',
GuardianLoginError.accountDisabled =>
'Dieser Eltern-Zugang ist derzeit deaktiviert. Bitte wende dich an '
'das Sekretariat.',
GuardianLoginError.invalidCode =>
attemptsLeft == null
? 'Der Code ist falsch.'
: 'Der Code ist falsch. Noch $attemptsLeft '
'${attemptsLeft == 1 ? 'Versuch' : 'Versuche'}.',
GuardianLoginError.deviceMismatch =>
'Dieser Anmeldelink wurde auf einem anderen Gerät angefordert. '
'Bitte gib stattdessen den Code aus der E-Mail ein.',
GuardianLoginError.requestConsumed =>
'Diese Anmeldung wurde bereits verwendet. Bitte fordere einen neuen '
'Code an.',
GuardianLoginError.requestExpired =>
'Der Code ist abgelaufen. Bitte fordere einen neuen an.',
GuardianLoginError.tooManyAttempts =>
'Zu viele Fehlversuche. Bitte fordere einen neuen Code an.',
GuardianLoginError.rateLimited =>
'Zu viele Anfragen. Bitte warte einige Stunden und versuche es '
'erneut.',
GuardianLoginError.unsupportedServer =>
'Die Eltern-Anmeldung ist auf diesem Server noch nicht verfügbar. '
'Bitte versuche es später erneut.',
};
static (String?, int?) _parseBody(Object? data) {
final attempts = data is Map ? data['attemptsLeft'] : null;
return (marianumConnectErrorCode(data), attempts is int ? attempts : null);
}
static GuardianLoginError? _errorFor(
String? code,
int status, {
required bool verifying,
}) => switch (code) {
'invalid_request' => GuardianLoginError.invalidRequest,
'email_not_registered' => GuardianLoginError.emailNotRegistered,
'account_disabled' => GuardianLoginError.accountDisabled,
'invalid_code' => GuardianLoginError.invalidCode,
'device_mismatch' => GuardianLoginError.deviceMismatch,
'request_consumed' => GuardianLoginError.requestConsumed,
'request_expired' => GuardianLoginError.requestExpired,
'too_many_attempts' => GuardianLoginError.tooManyAttempts,
_ => switch (status) {
401 when verifying => GuardianLoginError.invalidCode,
// The server names an unknown address explicitly
// (`email_not_registered`); a bare 404/405 means the endpoint itself
// is missing, i.e. a server version without guardian login.
404 || 405 => GuardianLoginError.unsupportedServer,
409 => GuardianLoginError.requestConsumed,
410 => GuardianLoginError.requestExpired,
429 => GuardianLoginError.rateLimited,
_ => null,
},
};
}
@@ -1,6 +1,8 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../../auth/token_storage.dart'; import '../../auth/token_storage.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart'; import '../../marianumconnect_query.dart';
/// Revokes the stored MC bearer token both server-side and locally. Best-effort /// Revokes the stored MC bearer token both server-side and locally. Best-effort
@@ -24,4 +26,17 @@ class AuthLogout extends MarianumConnectQuery {
await _tokenStorage.clear(); await _tokenStorage.clear();
} }
} }
/// Revokes the token of an inactive account; the stored (active) token is
/// left alone. Best-effort.
static Future<void> revoke(String token) async {
try {
await MarianumConnectApi.plainDio().post<void>(
MarianumConnectEndpoint.resolve('auth/logout'),
options: Options(headers: {'Authorization': 'Bearer $token'}),
);
} on DioException catch (_) {
// ignore
}
}
} }
@@ -0,0 +1,40 @@
import 'package:dio/dio.dart';
import '../../../errors/auth_exception.dart';
import '../../auth/token_storage.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_query.dart';
import '../auth_login/auth_login_response.dart';
/// Probes that the stored bearer token is still accepted. Used for accounts
/// without a password (guardians), whose token cannot be renewed silently.
///
/// Bypasses the shared dio singleton so the auth interceptor does not react
/// to the 401 this probe is meant to observe.
class AuthMe extends MarianumConnectQuery {
final MarianumConnectTokenStorage _tokenStorage;
AuthMe({
MarianumConnectTokenStorage tokenStorage =
const MarianumConnectTokenStorage(),
Dio? dio,
}) : _tokenStorage = tokenStorage,
super(dio: dio ?? MarianumConnectApi.plainDio());
/// Throws [AuthException] when the token is missing or rejected.
Future<void> run() async {
await user();
}
/// The signed-in user (names, type). Throws like [run].
Future<AuthLoginUser> user() async {
final options = await _tokenStorage.requireBearerOptions('AuthMe');
return guard(() async {
final response = await dio.get<Map<String, dynamic>>(
endpoint('auth/me'),
options: options,
);
return AuthLoginUser.fromJson(response.data!);
});
}
}
@@ -29,17 +29,12 @@ class AuthVerify extends MarianumConnectQuery {
required String username, required String username,
required String password, required String password,
}) async { }) async {
final token = await _tokenStorage.readToken(); final options = await _tokenStorage.requireBearerOptions('AuthVerify');
if (token == null || token.isEmpty) {
throw AuthException.unauthorized(
technicalDetails: 'AuthVerify: no bearer token in storage',
);
}
return guard(() async { return guard(() async {
await dio.post<void>( await dio.post<void>(
endpoint('auth/verify'), endpoint('auth/verify'),
data: {'username': username, 'password': password}, data: {'username': username, 'password': password},
options: Options(headers: {'Authorization': 'Bearer $token'}), options: options,
); );
}); });
} }
@@ -1,5 +1,7 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'guardian_child.dart';
part 'get_capabilities_response.g.dart'; part 'get_capabilities_response.g.dart';
/// Slimmed-down capability flags the mobile UI gates features on. The backend /// Slimmed-down capability flags the mobile UI gates features on. The backend
@@ -23,16 +25,21 @@ class CapabilitiesResponse {
final int? timetableFutureDays; final int? timetableFutureDays;
/// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'). Null when the backend /// User type ('TEACHER' | 'STUDENT' | 'STAFF' | 'PARENT'). Null when the
/// predates the field or has no LDAP record for the user. /// backend predates the field or has no record for the user.
final String? userType; final String? userType;
/// Students linked to a guardian account; empty for everyone else.
@JsonKey(defaultValue: <GuardianChild>[])
final List<GuardianChild> children;
CapabilitiesResponse({ CapabilitiesResponse({
required this.viewForeignTimetables, required this.viewForeignTimetables,
required this.pushNotifications, required this.pushNotifications,
this.timetablePastDays, this.timetablePastDays,
this.timetableFutureDays, this.timetableFutureDays,
this.userType, this.userType,
this.children = const [],
}); });
factory CapabilitiesResponse.fromJson(Map<String, dynamic> json) => factory CapabilitiesResponse.fromJson(Map<String, dynamic> json) =>
@@ -14,6 +14,11 @@ CapabilitiesResponse _$CapabilitiesResponseFromJson(
timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(), timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(),
timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(), timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(),
userType: json['userType'] as String?, userType: json['userType'] as String?,
children:
(json['children'] as List<dynamic>?)
?.map((e) => GuardianChild.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
); );
Map<String, dynamic> _$CapabilitiesResponseToJson( Map<String, dynamic> _$CapabilitiesResponseToJson(
@@ -24,4 +29,5 @@ Map<String, dynamic> _$CapabilitiesResponseToJson(
'timetablePastDays': instance.timetablePastDays, 'timetablePastDays': instance.timetablePastDays,
'timetableFutureDays': instance.timetableFutureDays, 'timetableFutureDays': instance.timetableFutureDays,
'userType': instance.userType, 'userType': instance.userType,
'children': instance.children,
}; };
@@ -0,0 +1,27 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'guardian_child.freezed.dart';
part 'guardian_child.g.dart';
/// A student linked to the signed-in guardian. [id] is an opaque server id,
/// not a WebUntis id.
@freezed
abstract class GuardianChild with _$GuardianChild {
const GuardianChild._();
const factory GuardianChild({
required String id,
required String firstName,
required String lastName,
@Default('') String className,
/// Login of the child, also its Nextcloud id (avatar). Null on servers
/// that do not send it yet.
String? username,
}) = _GuardianChild;
factory GuardianChild.fromJson(Map<String, Object?> json) =>
_$GuardianChildFromJson(json);
String get displayName => '$firstName $lastName'.trim();
}
@@ -0,0 +1,301 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'guardian_child.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$GuardianChild {
String get id; String get firstName; String get lastName; String get className;/// Login of the child, also its Nextcloud id (avatar). Null on servers
/// that do not send it yet.
String? get username;
/// Create a copy of GuardianChild
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$GuardianChildCopyWith<GuardianChild> get copyWith => _$GuardianChildCopyWithImpl<GuardianChild>(this as GuardianChild, _$identity);
/// Serializes this GuardianChild to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
final _this = this as GuardianChild;
return identical(this, other) || (other.runtimeType == runtimeType&&other is GuardianChild&&(identical(other.id, _this.id) || other.id == _this.id)&&(identical(other.firstName, _this.firstName) || other.firstName == _this.firstName)&&(identical(other.lastName, _this.lastName) || other.lastName == _this.lastName)&&(identical(other.className, _this.className) || other.className == _this.className)&&(identical(other.username, _this.username) || other.username == _this.username));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode {
final _this = this as GuardianChild;
return Object.hash(runtimeType,_this.id,_this.firstName,_this.lastName,_this.className,_this.username);
}
@override
String toString() {
final _this = this as GuardianChild;
return 'GuardianChild(id: ${_this.id}, firstName: ${_this.firstName}, lastName: ${_this.lastName}, className: ${_this.className}, username: ${_this.username})';
}
}
/// @nodoc
abstract mixin class $GuardianChildCopyWith<$Res> {
factory $GuardianChildCopyWith(GuardianChild value, $Res Function(GuardianChild) _then) = _$GuardianChildCopyWithImpl;
@useResult
$Res call({
String id, String firstName, String lastName, String className, String? username
});
}
/// @nodoc
class _$GuardianChildCopyWithImpl<$Res>
implements $GuardianChildCopyWith<$Res> {
_$GuardianChildCopyWithImpl(this._self, this._then);
final GuardianChild _self;
final $Res Function(GuardianChild) _then;
/// Create a copy of GuardianChild
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? firstName = null,Object? lastName = null,Object? className = null,Object? username = freezed,}) {
return _then(GuardianChild(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,firstName: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
as String,lastName: null == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable
as String,className: null == className ? _self.className : className // ignore: cast_nullable_to_non_nullable
as String,username: freezed == username ? _self.username : username // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// Adds pattern-matching-related methods to [GuardianChild].
extension GuardianChildPatterns on GuardianChild {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _GuardianChild value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _GuardianChild() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _GuardianChild value) $default,){
final _that = this;
switch (_that) {
case _GuardianChild():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _GuardianChild value)? $default,){
final _that = this;
switch (_that) {
case _GuardianChild() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String firstName, String lastName, String className, String? username)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _GuardianChild() when $default != null:
return $default(_that.id,_that.firstName,_that.lastName,_that.className,_that.username);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String firstName, String lastName, String className, String? username) $default,) {final _that = this;
switch (_that) {
case _GuardianChild():
return $default(_that.id,_that.firstName,_that.lastName,_that.className,_that.username);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String firstName, String lastName, String className, String? username)? $default,) {final _that = this;
switch (_that) {
case _GuardianChild() when $default != null:
return $default(_that.id,_that.firstName,_that.lastName,_that.className,_that.username);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _GuardianChild extends GuardianChild {
const _GuardianChild({required this.id, required this.firstName, required this.lastName, this.className = '', this.username}): super._();
factory _GuardianChild.fromJson(Map<String, dynamic> json) => _$GuardianChildFromJson(json);
@override final String id;
@override final String firstName;
@override final String lastName;
@override@JsonKey() final String className;
/// Login of the child, also its Nextcloud id (avatar). Null on servers
/// that do not send it yet.
@override final String? username;
/// Create a copy of GuardianChild
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$GuardianChildCopyWith<_GuardianChild> get copyWith => __$GuardianChildCopyWithImpl<_GuardianChild>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$GuardianChildToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _GuardianChild&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.className, className) || other.className == className)&&(identical(other.username, username) || other.username == username));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode {
return Object.hash(runtimeType,id,firstName,lastName,className,username);
}
@override
String toString() {
return 'GuardianChild(id: $id, firstName: $firstName, lastName: $lastName, className: $className, username: $username)';
}
}
/// @nodoc
abstract mixin class _$GuardianChildCopyWith<$Res> implements $GuardianChildCopyWith<$Res> {
factory _$GuardianChildCopyWith(_GuardianChild value, $Res Function(_GuardianChild) _then) = __$GuardianChildCopyWithImpl;
@override @useResult
$Res call({
String id, String firstName, String lastName, String className, String? username
});
}
/// @nodoc
class __$GuardianChildCopyWithImpl<$Res>
implements _$GuardianChildCopyWith<$Res> {
__$GuardianChildCopyWithImpl(this._self, this._then);
final _GuardianChild _self;
final $Res Function(_GuardianChild) _then;
/// Create a copy of GuardianChild
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? firstName = null,Object? lastName = null,Object? className = null,Object? username = freezed,}) {
return _then(_GuardianChild(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,firstName: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
as String,lastName: null == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable
as String,className: null == className ? _self.className : className // ignore: cast_nullable_to_non_nullable
as String,username: freezed == username ? _self.username : username // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
// dart format on
@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'guardian_child.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_GuardianChild _$GuardianChildFromJson(Map<String, dynamic> json) =>
_GuardianChild(
id: json['id'] as String,
firstName: json['firstName'] as String,
lastName: json['lastName'] as String,
className: json['className'] as String? ?? '',
username: json['username'] as String?,
);
Map<String, dynamic> _$GuardianChildToJson(_GuardianChild instance) =>
<String, dynamic>{
'id': instance.id,
'firstName': instance.firstName,
'lastName': instance.lastName,
'className': instance.className,
'username': instance.username,
};
@@ -1,7 +1,5 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:dio/dio.dart';
import '../../marianumconnect_query.dart'; import '../../marianumconnect_query.dart';
/// Downloads the raw PDF bytes of a Marianum Message from /// Downloads the raw PDF bytes of a Marianum Message from
@@ -15,11 +13,6 @@ class GetNewsletterFile extends MarianumConnectQuery {
GetNewsletterFile(this.id, {super.dio}); GetNewsletterFile(this.id, {super.dio});
Future<Uint8List> run() => guard(() async { Future<Uint8List> run() =>
final response = await dio.get<List<int>>( getBytes('newsletter/${Uri.encodeComponent(id)}/file');
endpoint('newsletter/${Uri.encodeComponent(id)}/file'),
options: Options(responseType: ResponseType.bytes),
);
return Uint8List.fromList(response.data!);
});
} }
@@ -1,7 +1,5 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:dio/dio.dart';
import '../../marianumconnect_query.dart'; import '../../marianumconnect_query.dart';
/// Downloads the raw bytes of a PROXIED_FILE ticker page from /// Downloads the raw bytes of a PROXIED_FILE ticker page from
@@ -15,11 +13,6 @@ class GetTickerPageFile extends MarianumConnectQuery {
GetTickerPageFile(this.slug, {super.dio}); GetTickerPageFile(this.slug, {super.dio});
Future<Uint8List> run() => guard(() async { Future<Uint8List> run() =>
final response = await dio.get<List<int>>( getBytes('ticker/pages/${Uri.encodeComponent(slug)}/file');
endpoint('ticker/pages/${Uri.encodeComponent(slug)}/file'),
options: Options(responseType: ResponseType.bytes),
);
return Uint8List.fromList(response.data!);
});
} }
@@ -0,0 +1,9 @@
import 'parent_letter_models.dart';
import 'parent_letter_query.dart';
class GetParentLetter extends ParentLetterQuery {
GetParentLetter({super.dio});
Future<ParentLetterDetail> run(String letterId) =>
getObject(letterPath(letterId), ParentLetterDetail.fromJson);
}
@@ -0,0 +1,14 @@
import 'dart:typed_data';
import 'parent_letter_query.dart';
class GetParentLetterAttachment extends ParentLetterQuery {
GetParentLetterAttachment({super.dio});
Future<Uint8List> run({
required String letterId,
required String attachmentId,
}) => getBytes(
letterPath(letterId, '/attachments/${Uri.encodeComponent(attachmentId)}'),
);
}
@@ -0,0 +1,16 @@
import 'parent_letter_models.dart';
import 'parent_letter_query.dart';
/// `GET parent-letters`: the guardian's inbox across all children, newest
/// first. [before] continues after the given letter (keyset paging).
class GetParentLetters extends ParentLetterQuery {
static const int pageSize = 50;
GetParentLetters({super.dio});
Future<ParentLetterListResponse> run({String? before}) => getObject(
'parent-letters',
ParentLetterListResponse.fromJson,
queryParameters: {'limit': pageSize, 'before': ?before},
);
}
@@ -0,0 +1,11 @@
import 'parent_letter_query.dart';
/// `POST parent-letters/{id}/read`: marks the letter and its thread as read
/// for this guardian. Idempotent.
class MarkParentLetterRead extends ParentLetterQuery {
MarkParentLetterRead({super.dio});
Future<void> run(String letterId) => guard(() async {
await dio.post<void>(endpoint(letterPath(letterId, '/read')));
});
}
@@ -0,0 +1,90 @@
import 'package:dio/dio.dart';
import '../../../errors/app_exception.dart';
import '../../errors/marianumconnect_error.dart';
enum ParentLetterError {
guardianRequired,
letterNotFound,
childNotFound,
attachmentNotFound,
invalidAnswers,
signatureRequired,
responseFinal,
deadlinePassed,
threadDisabled,
invalidRequest,
}
/// A parent-letter call the server rejected with a domain reason.
class ParentLetterException extends AppException {
final ParentLetterError error;
const ParentLetterException(
this.error, {
required super.userMessage,
super.technicalDetails,
}) : super(allowRetry: false);
/// Only 4xx answers with a parent-letter reason become a
/// [ParentLetterException]; everything else (network, 401, 5xx) keeps the
/// generic MarianumConnect mapping.
static AppException fromDio(DioException e) {
final response = e.response;
final status = response?.statusCode;
if (status == null || status < 400 || status >= 500 || status == 401) {
return mapMarianumConnectError(e);
}
final error = _errorFor(marianumConnectErrorCode(response!.data), status);
if (error == null) return mapMarianumConnectError(e);
return ParentLetterException(
error,
userMessage: messageFor(error),
technicalDetails: 'MC $status: ${response.data}',
);
}
static String messageFor(ParentLetterError error) => switch (error) {
ParentLetterError.guardianRequired =>
'Elternbriefe sind nur mit einem Eltern-Konto verfügbar.',
ParentLetterError.letterNotFound =>
'Dieser Elternbrief ist nicht mehr verfügbar.',
ParentLetterError.childNotFound =>
'Dieser Elternbrief betrifft das ausgewählte Kind nicht.',
ParentLetterError.attachmentNotFound =>
'Der Anhang ist nicht mehr verfügbar.',
ParentLetterError.invalidAnswers =>
'Die Rückmeldung ist unvollständig. Bitte prüfe deine Angaben.',
ParentLetterError.signatureRequired =>
'Für diese Rückmeldung wird eine Unterschrift benötigt.',
ParentLetterError.responseFinal =>
'Die Rückmeldung wurde bereits endgültig abgegeben und kann nicht mehr '
'geändert werden.',
ParentLetterError.deadlinePassed =>
'Die Frist für diese Rückmeldung ist abgelaufen.',
ParentLetterError.threadDisabled =>
'Auf diesen Elternbrief kann nicht geantwortet werden.',
ParentLetterError.invalidRequest =>
'Die Anfrage konnte nicht verarbeitet werden.',
};
static ParentLetterError? _errorFor(String? code, int status) =>
switch (code) {
'guardian_required' => ParentLetterError.guardianRequired,
'letter_not_found' => ParentLetterError.letterNotFound,
'child_not_found' => ParentLetterError.childNotFound,
'attachment_not_found' => ParentLetterError.attachmentNotFound,
'invalid_answers' => ParentLetterError.invalidAnswers,
'signature_required' => ParentLetterError.signatureRequired,
'response_final' => ParentLetterError.responseFinal,
'deadline_passed' => ParentLetterError.deadlinePassed,
'thread_disabled' => ParentLetterError.threadDisabled,
'invalid_request' => ParentLetterError.invalidRequest,
_ => switch (status) {
404 => ParentLetterError.letterNotFound,
409 => ParentLetterError.responseFinal,
410 => ParentLetterError.deadlinePassed,
_ => null,
},
};
}
@@ -0,0 +1,226 @@
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'parent_letter_models.freezed.dart';
part 'parent_letter_models.g.dart';
enum ParentLetterStatus { info, open, done, expired }
enum ParentLetterFieldType {
@JsonValue('single_choice')
singleChoice,
unknown,
}
/// Sender, thread author or responding guardian. [self] marks the signed-in
/// guardian.
@freezed
abstract class ParentLetterPerson with _$ParentLetterPerson {
const factory ParentLetterPerson({
@Default('') String displayName,
@Default(false) bool self,
}) = _ParentLetterPerson;
factory ParentLetterPerson.fromJson(Map<String, dynamic> json) =>
_$ParentLetterPersonFromJson(json);
}
/// Inbox entry from `GET parent-letters`. [childIds] are the opaque guardian
/// child ids from `me/capabilities`.
@freezed
abstract class ParentLetterSummary with _$ParentLetterSummary {
const factory ParentLetterSummary({
required String id,
@Default('') String subject,
@Default('') String preview,
@Default(ParentLetterPerson()) ParentLetterPerson sender,
required DateTime sentAt,
DateTime? editedAt,
@Default(true) bool read,
@Default([]) List<String> childIds,
@Default(0) int attachmentCount,
@JsonKey(unknownEnumValue: ParentLetterStatus.info)
@Default(ParentLetterStatus.info)
ParentLetterStatus status,
DateTime? deadline,
}) = _ParentLetterSummary;
factory ParentLetterSummary.fromJson(Map<String, dynamic> json) =>
_$ParentLetterSummaryFromJson(json);
}
@freezed
abstract class ParentLetterListResponse with _$ParentLetterListResponse {
const factory ParentLetterListResponse({
@Default([]) List<ParentLetterSummary> items,
@Default(false) bool hasMore,
@Default(0) int unreadCount,
@Default(0) int openCount,
}) = _ParentLetterListResponse;
factory ParentLetterListResponse.fromJson(Map<String, dynamic> json) =>
_$ParentLetterListResponseFromJson(json);
}
@freezed
abstract class ParentLetterAttachment with _$ParentLetterAttachment {
const factory ParentLetterAttachment({
required String id,
@Default('') String fileName,
@Default('') String mimeType,
@Default(0) int size,
}) = _ParentLetterAttachment;
factory ParentLetterAttachment.fromJson(Map<String, dynamic> json) =>
_$ParentLetterAttachmentFromJson(json);
}
@freezed
abstract class ParentLetterOption with _$ParentLetterOption {
const factory ParentLetterOption({
required String id,
@Default('') String label,
}) = _ParentLetterOption;
factory ParentLetterOption.fromJson(Map<String, dynamic> json) =>
_$ParentLetterOptionFromJson(json);
}
@freezed
abstract class ParentLetterField with _$ParentLetterField {
const factory ParentLetterField({
required String id,
@JsonKey(unknownEnumValue: ParentLetterFieldType.unknown)
@Default(ParentLetterFieldType.unknown)
ParentLetterFieldType type,
@Default('') String label,
@JsonKey(name: 'required') @Default(false) bool isRequired,
@Default([]) List<ParentLetterOption> options,
}) = _ParentLetterField;
factory ParentLetterField.fromJson(Map<String, dynamic> json) =>
_$ParentLetterFieldFromJson(json);
}
/// What the sender asks for. No fields and no signature means a plain
/// acknowledgement.
@freezed
abstract class ParentLetterRequest with _$ParentLetterRequest {
const factory ParentLetterRequest({
@Default([]) List<ParentLetterField> fields,
@Default(false) bool signatureRequired,
DateTime? deadline,
}) = _ParentLetterRequest;
factory ParentLetterRequest.fromJson(Map<String, dynamic> json) =>
_$ParentLetterRequestFromJson(json);
}
@freezed
abstract class ParentLetterAnswer with _$ParentLetterAnswer {
const factory ParentLetterAnswer({
required String fieldId,
@Default([]) List<String> optionIds,
String? text,
}) = _ParentLetterAnswer;
factory ParentLetterAnswer.fromJson(Map<String, dynamic> json) =>
_$ParentLetterAnswerFromJson(json);
}
@freezed
abstract class ParentLetterResponse with _$ParentLetterResponse {
const factory ParentLetterResponse({
DateTime? respondedAt,
@Default(ParentLetterPerson()) ParentLetterPerson respondedBy,
@Default([]) List<ParentLetterAnswer> answers,
@Default(false) bool signed,
}) = _ParentLetterResponse;
factory ParentLetterResponse.fromJson(Map<String, dynamic> json) =>
_$ParentLetterResponseFromJson(json);
}
/// Per-child state of a letter. [editable] is the server's verdict on whether
/// this guardian may (re)submit right now.
@freezed
abstract class ParentLetterChildState with _$ParentLetterChildState {
const factory ParentLetterChildState({
required String childId,
@JsonKey(unknownEnumValue: ParentLetterStatus.info)
@Default(ParentLetterStatus.info)
ParentLetterStatus status,
@Default(false) bool editable,
ParentLetterResponse? response,
}) = _ParentLetterChildState;
factory ParentLetterChildState.fromJson(Map<String, dynamic> json) =>
_$ParentLetterChildStateFromJson(json);
}
@freezed
abstract class ParentLetterThreadMessage with _$ParentLetterThreadMessage {
const factory ParentLetterThreadMessage({
required String id,
@Default(ParentLetterPerson()) ParentLetterPerson author,
@Default('') String body,
required DateTime sentAt,
}) = _ParentLetterThreadMessage;
factory ParentLetterThreadMessage.fromJson(Map<String, dynamic> json) =>
_$ParentLetterThreadMessageFromJson(json);
}
@freezed
abstract class ParentLetterThread with _$ParentLetterThread {
const factory ParentLetterThread({
@Default(false) bool enabled,
@Default([]) List<ParentLetterThreadMessage> messages,
}) = _ParentLetterThread;
factory ParentLetterThread.fromJson(Map<String, dynamic> json) =>
_$ParentLetterThreadFromJson(json);
}
/// The detail-only part of `GET parent-letters/{id}`.
@freezed
abstract class ParentLetterContent with _$ParentLetterContent {
const factory ParentLetterContent({
@Default('') String body,
@Default([]) List<ParentLetterAttachment> attachments,
ParentLetterRequest? request,
@Default([]) List<ParentLetterChildState> children,
@Default(ParentLetterThread()) ParentLetterThread thread,
}) = _ParentLetterContent;
factory ParentLetterContent.fromJson(Map<String, dynamic> json) =>
_$ParentLetterContentFromJson(json);
}
/// A full letter. The server sends the inbox fields and the detail fields in
/// one flat object; they are parsed into [summary] and [content] so the inbox
/// entry can be replaced from a detail answer without copying fields.
@immutable
class ParentLetterDetail {
final ParentLetterSummary summary;
final ParentLetterContent content;
const ParentLetterDetail({required this.summary, required this.content});
factory ParentLetterDetail.fromJson(Map<String, dynamic> json) =>
ParentLetterDetail(
summary: ParentLetterSummary.fromJson(json),
content: ParentLetterContent.fromJson(json),
);
Map<String, dynamic> toJson() => {...summary.toJson(), ...content.toJson()};
@override
bool operator ==(Object other) =>
other is ParentLetterDetail &&
other.summary == summary &&
other.content == content;
@override
int get hashCode => Object.hash(summary, content);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,331 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'parent_letter_models.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_ParentLetterPerson _$ParentLetterPersonFromJson(Map<String, dynamic> json) =>
_ParentLetterPerson(
displayName: json['displayName'] as String? ?? '',
self: json['self'] as bool? ?? false,
);
Map<String, dynamic> _$ParentLetterPersonToJson(_ParentLetterPerson instance) =>
<String, dynamic>{
'displayName': instance.displayName,
'self': instance.self,
};
_ParentLetterSummary _$ParentLetterSummaryFromJson(Map<String, dynamic> json) =>
_ParentLetterSummary(
id: json['id'] as String,
subject: json['subject'] as String? ?? '',
preview: json['preview'] as String? ?? '',
sender: json['sender'] == null
? const ParentLetterPerson()
: ParentLetterPerson.fromJson(json['sender'] as Map<String, dynamic>),
sentAt: DateTime.parse(json['sentAt'] as String),
editedAt: json['editedAt'] == null
? null
: DateTime.parse(json['editedAt'] as String),
read: json['read'] as bool? ?? true,
childIds:
(json['childIds'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const [],
attachmentCount: (json['attachmentCount'] as num?)?.toInt() ?? 0,
status:
$enumDecodeNullable(
_$ParentLetterStatusEnumMap,
json['status'],
unknownValue: ParentLetterStatus.info,
) ??
ParentLetterStatus.info,
deadline: json['deadline'] == null
? null
: DateTime.parse(json['deadline'] as String),
);
Map<String, dynamic> _$ParentLetterSummaryToJson(
_ParentLetterSummary instance,
) => <String, dynamic>{
'id': instance.id,
'subject': instance.subject,
'preview': instance.preview,
'sender': instance.sender,
'sentAt': instance.sentAt.toIso8601String(),
'editedAt': instance.editedAt?.toIso8601String(),
'read': instance.read,
'childIds': instance.childIds,
'attachmentCount': instance.attachmentCount,
'status': _$ParentLetterStatusEnumMap[instance.status]!,
'deadline': instance.deadline?.toIso8601String(),
};
const _$ParentLetterStatusEnumMap = {
ParentLetterStatus.info: 'info',
ParentLetterStatus.open: 'open',
ParentLetterStatus.done: 'done',
ParentLetterStatus.expired: 'expired',
};
_ParentLetterListResponse _$ParentLetterListResponseFromJson(
Map<String, dynamic> json,
) => _ParentLetterListResponse(
items:
(json['items'] as List<dynamic>?)
?.map((e) => ParentLetterSummary.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
hasMore: json['hasMore'] as bool? ?? false,
unreadCount: (json['unreadCount'] as num?)?.toInt() ?? 0,
openCount: (json['openCount'] as num?)?.toInt() ?? 0,
);
Map<String, dynamic> _$ParentLetterListResponseToJson(
_ParentLetterListResponse instance,
) => <String, dynamic>{
'items': instance.items,
'hasMore': instance.hasMore,
'unreadCount': instance.unreadCount,
'openCount': instance.openCount,
};
_ParentLetterAttachment _$ParentLetterAttachmentFromJson(
Map<String, dynamic> json,
) => _ParentLetterAttachment(
id: json['id'] as String,
fileName: json['fileName'] as String? ?? '',
mimeType: json['mimeType'] as String? ?? '',
size: (json['size'] as num?)?.toInt() ?? 0,
);
Map<String, dynamic> _$ParentLetterAttachmentToJson(
_ParentLetterAttachment instance,
) => <String, dynamic>{
'id': instance.id,
'fileName': instance.fileName,
'mimeType': instance.mimeType,
'size': instance.size,
};
_ParentLetterOption _$ParentLetterOptionFromJson(Map<String, dynamic> json) =>
_ParentLetterOption(
id: json['id'] as String,
label: json['label'] as String? ?? '',
);
Map<String, dynamic> _$ParentLetterOptionToJson(_ParentLetterOption instance) =>
<String, dynamic>{'id': instance.id, 'label': instance.label};
_ParentLetterField _$ParentLetterFieldFromJson(Map<String, dynamic> json) =>
_ParentLetterField(
id: json['id'] as String,
type:
$enumDecodeNullable(
_$ParentLetterFieldTypeEnumMap,
json['type'],
unknownValue: ParentLetterFieldType.unknown,
) ??
ParentLetterFieldType.unknown,
label: json['label'] as String? ?? '',
isRequired: json['required'] as bool? ?? false,
options:
(json['options'] as List<dynamic>?)
?.map(
(e) => ParentLetterOption.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
);
Map<String, dynamic> _$ParentLetterFieldToJson(_ParentLetterField instance) =>
<String, dynamic>{
'id': instance.id,
'type': _$ParentLetterFieldTypeEnumMap[instance.type]!,
'label': instance.label,
'required': instance.isRequired,
'options': instance.options,
};
const _$ParentLetterFieldTypeEnumMap = {
ParentLetterFieldType.singleChoice: 'single_choice',
ParentLetterFieldType.unknown: 'unknown',
};
_ParentLetterRequest _$ParentLetterRequestFromJson(Map<String, dynamic> json) =>
_ParentLetterRequest(
fields:
(json['fields'] as List<dynamic>?)
?.map(
(e) => ParentLetterField.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
signatureRequired: json['signatureRequired'] as bool? ?? false,
deadline: json['deadline'] == null
? null
: DateTime.parse(json['deadline'] as String),
);
Map<String, dynamic> _$ParentLetterRequestToJson(
_ParentLetterRequest instance,
) => <String, dynamic>{
'fields': instance.fields,
'signatureRequired': instance.signatureRequired,
'deadline': instance.deadline?.toIso8601String(),
};
_ParentLetterAnswer _$ParentLetterAnswerFromJson(Map<String, dynamic> json) =>
_ParentLetterAnswer(
fieldId: json['fieldId'] as String,
optionIds:
(json['optionIds'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const [],
text: json['text'] as String?,
);
Map<String, dynamic> _$ParentLetterAnswerToJson(_ParentLetterAnswer instance) =>
<String, dynamic>{
'fieldId': instance.fieldId,
'optionIds': instance.optionIds,
'text': instance.text,
};
_ParentLetterResponse _$ParentLetterResponseFromJson(
Map<String, dynamic> json,
) => _ParentLetterResponse(
respondedAt: json['respondedAt'] == null
? null
: DateTime.parse(json['respondedAt'] as String),
respondedBy: json['respondedBy'] == null
? const ParentLetterPerson()
: ParentLetterPerson.fromJson(
json['respondedBy'] as Map<String, dynamic>,
),
answers:
(json['answers'] as List<dynamic>?)
?.map((e) => ParentLetterAnswer.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
signed: json['signed'] as bool? ?? false,
);
Map<String, dynamic> _$ParentLetterResponseToJson(
_ParentLetterResponse instance,
) => <String, dynamic>{
'respondedAt': instance.respondedAt?.toIso8601String(),
'respondedBy': instance.respondedBy,
'answers': instance.answers,
'signed': instance.signed,
};
_ParentLetterChildState _$ParentLetterChildStateFromJson(
Map<String, dynamic> json,
) => _ParentLetterChildState(
childId: json['childId'] as String,
status:
$enumDecodeNullable(
_$ParentLetterStatusEnumMap,
json['status'],
unknownValue: ParentLetterStatus.info,
) ??
ParentLetterStatus.info,
editable: json['editable'] as bool? ?? false,
response: json['response'] == null
? null
: ParentLetterResponse.fromJson(json['response'] as Map<String, dynamic>),
);
Map<String, dynamic> _$ParentLetterChildStateToJson(
_ParentLetterChildState instance,
) => <String, dynamic>{
'childId': instance.childId,
'status': _$ParentLetterStatusEnumMap[instance.status]!,
'editable': instance.editable,
'response': instance.response,
};
_ParentLetterThreadMessage _$ParentLetterThreadMessageFromJson(
Map<String, dynamic> json,
) => _ParentLetterThreadMessage(
id: json['id'] as String,
author: json['author'] == null
? const ParentLetterPerson()
: ParentLetterPerson.fromJson(json['author'] as Map<String, dynamic>),
body: json['body'] as String? ?? '',
sentAt: DateTime.parse(json['sentAt'] as String),
);
Map<String, dynamic> _$ParentLetterThreadMessageToJson(
_ParentLetterThreadMessage instance,
) => <String, dynamic>{
'id': instance.id,
'author': instance.author,
'body': instance.body,
'sentAt': instance.sentAt.toIso8601String(),
};
_ParentLetterThread _$ParentLetterThreadFromJson(Map<String, dynamic> json) =>
_ParentLetterThread(
enabled: json['enabled'] as bool? ?? false,
messages:
(json['messages'] as List<dynamic>?)
?.map(
(e) => ParentLetterThreadMessage.fromJson(
e as Map<String, dynamic>,
),
)
.toList() ??
const [],
);
Map<String, dynamic> _$ParentLetterThreadToJson(_ParentLetterThread instance) =>
<String, dynamic>{
'enabled': instance.enabled,
'messages': instance.messages,
};
_ParentLetterContent _$ParentLetterContentFromJson(Map<String, dynamic> json) =>
_ParentLetterContent(
body: json['body'] as String? ?? '',
attachments:
(json['attachments'] as List<dynamic>?)
?.map(
(e) =>
ParentLetterAttachment.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
request: json['request'] == null
? null
: ParentLetterRequest.fromJson(
json['request'] as Map<String, dynamic>,
),
children:
(json['children'] as List<dynamic>?)
?.map(
(e) =>
ParentLetterChildState.fromJson(e as Map<String, dynamic>),
)
.toList() ??
const [],
thread: json['thread'] == null
? const ParentLetterThread()
: ParentLetterThread.fromJson(json['thread'] as Map<String, dynamic>),
);
Map<String, dynamic> _$ParentLetterContentToJson(
_ParentLetterContent instance,
) => <String, dynamic>{
'body': instance.body,
'attachments': instance.attachments,
'request': instance.request,
'children': instance.children,
'thread': instance.thread,
};
@@ -0,0 +1,18 @@
import 'package:dio/dio.dart';
import '../../../errors/app_exception.dart';
import '../../marianumconnect_query.dart';
import 'parent_letter_exception.dart';
/// Base for the parent-letter calls: domain rejections surface as
/// [ParentLetterException].
abstract class ParentLetterQuery extends MarianumConnectQuery {
ParentLetterQuery({super.dio});
String letterPath(String letterId, [String suffix = '']) =>
'parent-letters/${Uri.encodeComponent(letterId)}$suffix';
@override
AppException mapError(DioException error) =>
ParentLetterException.fromDio(error);
}
@@ -0,0 +1,20 @@
import 'parent_letter_models.dart';
import 'parent_letter_query.dart';
/// `POST parent-letters/{id}/thread`. [clientMessageId] makes a retry
/// idempotent on the server.
class PostParentLetterThreadMessage extends ParentLetterQuery {
PostParentLetterThreadMessage({super.dio});
Future<ParentLetterThreadMessage> run({
required String letterId,
required String body,
required String clientMessageId,
}) => guard(() async {
final response = await dio.post<Map<String, dynamic>>(
endpoint(letterPath(letterId, '/thread')),
data: {'body': body, 'clientMessageId': clientMessageId},
);
return ParentLetterThreadMessage.fromJson(response.data!);
});
}
@@ -0,0 +1,34 @@
import 'dart:convert';
import 'dart:typed_data';
import 'parent_letter_models.dart';
import 'parent_letter_query.dart';
/// `PUT parent-letters/{id}/responses/{childId}`: creates or (while editable)
/// replaces the response for one child. Answers with the updated letter.
class SubmitParentLetterResponse extends ParentLetterQuery {
SubmitParentLetterResponse({super.dio});
Future<ParentLetterDetail> run({
required String letterId,
required String childId,
required List<ParentLetterAnswer> answers,
Uint8List? signaturePng,
}) => guard(() async {
final response = await dio.put<Map<String, dynamic>>(
endpoint(
letterPath(letterId, '/responses/${Uri.encodeComponent(childId)}'),
),
data: {
'answers': [
for (final answer in answers)
{'fieldId': answer.fieldId, 'optionIds': answer.optionIds},
],
'signaturePng': signaturePng == null
? null
: base64Encode(signaturePng),
},
);
return ParentLetterDetail.fromJson(response.data!);
});
}
@@ -1,16 +1,18 @@
import '../../marianumconnect_query.dart'; import '../../marianumconnect_query.dart';
/// Registers (upserts) this device's push subscription with MarianumConnect via /// Registers (upserts) this device's push subscription with MarianumConnect via
/// `PUT /api/mobile/v1/me/push-device`. The backend verifies the Nextcloud /// `PUT /api/mobile/v1/me/push-device`. For Nextcloud registrations the backend
/// device-identifier signature, stores the routing metadata and starts /// verifies the device-identifier signature and forwards Nextcloud pushes to
/// forwarding Nextcloud pushes to this device's FCM token. Responds 204. /// this device's FCM token; `direct` registrations (accounts without
/// Nextcloud) carry no signature and only receive MarianumConnect pushes.
/// Responds 204.
class PushDeviceRegister extends MarianumConnectQuery { class PushDeviceRegister extends MarianumConnectQuery {
PushDeviceRegister({super.dio}); PushDeviceRegister({super.dio});
Future<void> run({ Future<void> run({
required String deviceIdentifier, required String deviceIdentifier,
required String deviceIdentifierSignature, String? deviceIdentifierSignature,
required String userPublicKey, String? userPublicKey,
required String pushToken, required String pushToken,
required String platform, required String platform,
required String registrationType, required String registrationType,
@@ -20,12 +22,13 @@ class PushDeviceRegister extends MarianumConnectQuery {
endpoint('me/push-device'), endpoint('me/push-device'),
data: { data: {
'deviceIdentifier': deviceIdentifier, 'deviceIdentifier': deviceIdentifier,
'deviceIdentifierSignature': deviceIdentifierSignature, 'deviceIdentifierSignature': ?deviceIdentifierSignature,
'userPublicKey': userPublicKey, 'userPublicKey': ?userPublicKey,
'pushToken': pushToken, 'pushToken': pushToken,
'platform': platform, 'platform': platform,
// 'general' | 'talk' — the backend derives the NC hash comparison // 'general' | 'talk' — the backend derives the NC hash comparison
// value from it (general = sha512(token), talk = sha512(token+'#talk')). // value from it (general = sha512(token), talk = sha512(token+'#talk')).
// 'direct' — no Nextcloud subscription behind it.
'registrationType': registrationType, 'registrationType': registrationType,
'appVersion': ?appVersion, 'appVersion': ?appVersion,
}, },
@@ -1,7 +1,7 @@
import 'dart:math';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../../../utils/random_id.dart';
/// A stable, anonymous per-install identifier for telemetry. Generated once on /// A stable, anonymous per-install identifier for telemetry. Generated once on
/// first use (128 bits from a cryptographic RNG) and persisted in the secure /// first use (128 bits from a cryptographic RNG) and persisted in the secure
/// keystore, so a device stays a single row across password rotations and FCM /// keystore, so a device stays a single row across password rotations and FCM
@@ -21,15 +21,9 @@ class TelemetryDeviceId {
_cached = existing; _cached = existing;
return existing; return existing;
} }
final generated = _generate(); final generated = randomHexId();
await _storage.write(key: _key, value: generated); await _storage.write(key: _key, value: generated);
_cached = generated; _cached = generated;
return generated; return generated;
} }
static String _generate() {
final random = Random.secure();
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
} }
@@ -2,8 +2,8 @@ import 'dart:developer';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../../../../model/account_data.dart'; import '../../../../session/session.dart';
import '../../../demo/demo_mode.dart'; import '../../../../session/session_manager.dart';
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event.dart'; import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event.dart';
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart'; import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart';
import '../../../mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart'; import '../../../mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart';
@@ -26,28 +26,33 @@ class CustomEventsMigration {
const CustomEventsMigration._(); const CustomEventsMigration._();
static Future<void> runOnce() async { static Future<void> runOnce() async {
if (DemoMode.active) return; // Guardians never had MHSL events; only password accounts can derive the
// legacy identity.
final session = SessionManager().current;
if (session is! CredentialSession || session.isDemo) return;
if (await _isDone()) return; if (await _isDone()) return;
final epoch = AccountData().sessionEpoch; final epoch = SessionManager().sessionEpoch;
try { try {
final response = await GetCustomTimetableEvent( final response = await GetCustomTimetableEvent(
GetCustomTimetableEventParams(AccountData().getUserSecret()), GetCustomTimetableEventParams(session.legacyUserSecret),
).run(); ).run();
for (final event in response.events) { for (final event in response.events) {
// The POST authenticates with whoever is signed in now; after a // The POST authenticates with whoever is signed in now; after a
// sign-out the previous account's events would land in the next one. // sign-out the previous account's events would land in the next one.
if (!AccountData().isCurrentSession(epoch)) return; if (!SessionManager().isCurrentSession(epoch)) return;
await TimetableCustomEventsAdd().run(event); await TimetableCustomEventsAdd().run(event);
await RemoveCustomTimetableEvent( await RemoveCustomTimetableEvent(
RemoveCustomTimetableEventParams(event.id), RemoveCustomTimetableEventParams(event.id),
).run(); ).run();
} }
if (!AccountData().isCurrentSession(epoch)) return; if (!SessionManager().isCurrentSession(epoch)) return;
await _markDone(); await _markDone();
log('Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.'); log(
'Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.',
);
} catch (e) { } catch (e) {
// Leave the flag unset so the next launch retries; the delete-after-post // Leave the flag unset so the next launch retries; the delete-after-post
// above keeps a partial run duplicate-free. // above keeps a partial run duplicate-free.
@@ -0,0 +1,18 @@
import '../../marianumconnect_query.dart';
import '../timetable_get_week/timetable_get_week_response.dart';
/// Fetches the weekly timetable of a guardian's child from
/// `timetable/child/{childId}`. Same response shape as `timetable/me`.
class TimetableGetChildWeek extends MarianumConnectQuery {
TimetableGetChildWeek({super.dio});
Future<TimetableGetWeekResponse> run({
required String childId,
required DateTime from,
required DateTime until,
}) => getObject(
'timetable/child/${Uri.encodeComponent(childId)}',
TimetableGetWeekResponse.fromJson,
queryParameters: {'from': isoDate(from), 'until': isoDate(until)},
);
}
+3 -3
View File
@@ -1,7 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import '../model/account_data.dart'; import '../session/session_manager.dart';
import 'api_response.dart'; import 'api_response.dart';
import 'cache_store.dart'; import 'cache_store.dart';
import 'errors/parse_exception.dart'; import 'errors/parse_exception.dart';
@@ -47,7 +47,7 @@ abstract class RequestCache<T extends ApiResponse?> {
static void ignore(Exception e) {} static void ignore(Exception e) {}
Future<void> start(String document) async { Future<void> start(String document) async {
final epoch = AccountData().sessionEpoch; final epoch = SessionManager().sessionEpoch;
try { try {
final entry = await CacheStore.instance.read(document); final entry = await CacheStore.instance.read(document);
var lastUpdate = entry?.lastUpdate ?? 0; var lastUpdate = entry?.lastUpdate ?? 0;
@@ -85,7 +85,7 @@ abstract class RequestCache<T extends ApiResponse?> {
final newValue = await onLoad(); final newValue = await onLoad();
// The cache is shared, so a late response of a signed-out // The cache is shared, so a late response of a signed-out
// account would otherwise be cached for the next one. // account would otherwise be cached for the next one.
if (!AccountData().isCurrentSession(epoch)) { if (!SessionManager().isCurrentSession(epoch)) {
onError(const StaleSessionException()); onError(const StaleSessionException());
return; return;
} }
+73 -66
View File
@@ -10,14 +10,16 @@ import 'api/marianumconnect/marianumconnect_api.dart';
import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart'; import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart';
import 'main.dart'; import 'main.dart';
import 'model/account_data.dart';
import 'model/data_cleaner.dart'; import 'model/data_cleaner.dart';
import 'notification/notification_controller.dart'; import 'notification/notification_controller.dart';
import 'notification/notification_service.dart';
import 'notification/notification_tasks.dart'; import 'notification/notification_tasks.dart';
import 'push/push_registration.dart'; import 'push/push_registration.dart';
import 'push/push_tap_router.dart'; import 'push/push_tap_router.dart';
import 'routing/app_routes.dart'; import 'routing/app_routes.dart';
import 'session/session_manager.dart';
import 'share_intent/share_intent_listener.dart'; import 'share_intent/share_intent_listener.dart';
import 'state/app/infrastructure/loadable_state/loadable_state.dart';
import 'state/app/modules/app_modules.dart'; import 'state/app/modules/app_modules.dart';
import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; import 'state/app/modules/breaker/bloc/breaker_bloc.dart';
import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart'; import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart';
@@ -25,13 +27,16 @@ import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import 'state/app/modules/settings/bloc/settings_cubit.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart';
import 'state/app/modules/timetable/bloc/timetable_bloc.dart'; import 'state/app/modules/timetable/bloc/timetable_bloc.dart';
import 'state/app/modules/timetable/bloc/timetable_state.dart'; import 'state/app/modules/timetable/bloc/timetable_state.dart';
import 'state/app/modules/timetable/policy/timetable_policy.dart';
import 'storage/settings.dart' as model; import 'storage/settings.dart' as model;
import 'utils/debouncer.dart'; import 'utils/debouncer.dart';
import 'utils/haptics.dart'; import 'utils/haptics.dart';
import 'view/pages/overhang.dart'; import 'view/pages/overhang.dart';
import 'widget/breaker/breaker.dart'; import 'widget/breaker/breaker.dart';
import 'widget/info_dialog.dart';
import 'widget_data/widget_navigation.dart'; import 'widget_data/widget_navigation.dart';
import 'widget_data/widget_publisher.dart'; import 'widget_data/widget_publisher.dart';
import 'widget_data/widget_sync.dart';
class App extends StatefulWidget { class App extends StatefulWidget {
const App({super.key}); const App({super.key});
@@ -41,7 +46,6 @@ class App extends StatefulWidget {
} }
class _AppState extends State<App> with WidgetsBindingObserver { class _AppState extends State<App> with WidgetsBindingObserver {
StreamSubscription<dynamic>? _timetableWidgetSync;
StreamSubscription<RemoteMessage>? _onMessageSub; StreamSubscription<RemoteMessage>? _onMessageSub;
StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub; StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
StreamSubscription<String>? _fcmTokenRefreshSub; StreamSubscription<String>? _fcmTokenRefreshSub;
@@ -113,18 +117,11 @@ class _AppState extends State<App> with WidgetsBindingObserver {
} }
} }
void _onPushTapPending() { void _onPushTargetPending() {
final token = PushTapRouter.pendingChatToken.value; final target = PushTapRouter.pendingTarget.value;
if (token == null || !mounted) return; if (target == null || !mounted) return;
PushTapRouter.pendingChatToken.value = null; PushTapRouter.pendingTarget.value = null;
NotificationTasks.navigateToTalk(context, chatToken: token); NotificationTasks.openPushTarget(context, target);
}
void _onNewsletterTapPending() {
final id = PushTapRouter.pendingNewsletterId.value;
if (id == null || !mounted) return;
PushTapRouter.pendingNewsletterId.value = null;
AppRoutes.openNewsletterById(context, id: id);
} }
Future<void> _handlePendingWidgetNavigation() async { Future<void> _handlePendingWidgetNavigation() async {
@@ -140,12 +137,43 @@ class _AppState extends State<App> with WidgetsBindingObserver {
AppRoutes.goToTab(context, Modules.timetable); AppRoutes.goToTab(context, Modules.timetable);
} }
/// Mirrors the primary plan into the home-screen widget without waiting
/// for the periodic background refresh.
void _publishWidget(TimetableBloc bloc) {
final data = bloc.state.data;
if (!mounted || data is! TimetableState) return;
if (WidgetSync.encodeSubject(bloc.subject) == null) return;
unawaited(
WidgetPublisher.publishFromBlocState(
data,
subject: bloc.subject,
settings: context.read<SettingsCubit>().val(),
showClassInsteadOfTeacher: TimetablePolicy.resolve(
subject: bloc.subject,
capabilities: context.read<CapabilitiesCubit>().state,
).showClassInsteadOfTeacher,
),
);
}
void _handlePendingShare() { void _handlePendingShare() {
if (!mounted) return; if (!mounted) return;
final share = ShareIntentListener.pending.value; final share = ShareIntentListener.pending.value;
if (share == null || ShareIntentListener.instance.isShown(share)) return; if (share == null || ShareIntentListener.instance.isShown(share)) return;
// A second share would otherwise leave the previous share-flow page // A second share would otherwise leave the previous share-flow page
// on top with stale (already-cleared) file paths. // on top with stale (already-cleared) file paths.
// Sharing targets Talk chats and Files folders only.
final session = SessionManager().current;
if (!AppModule.isAvailableFor(Modules.talk, session) &&
!AppModule.isAvailableFor(Modules.files, session)) {
ShareIntentListener.instance.clear();
InfoDialog.show(
context,
'Mit diesem Konto können keine Inhalte in die App geteilt werden.',
title: 'Teilen nicht möglich',
);
return;
}
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
if (navigator.canPop()) { if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst || route is PopupRoute); navigator.popUntil((route) => route.isFirst || route is PopupRoute);
@@ -167,53 +195,13 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (!mounted) return; if (!mounted) return;
context.read<BreakerBloc>().refresh(); context.read<BreakerBloc>().refresh();
context.read<ChatListBloc>().refresh(); context.read<ChatListBloc>().refresh();
// Re-mounts on every login, so this also covers post-logout state reset. // Initial publish in case hydrated storage already has data. No refresh
final timetable = context.read<TimetableBloc>(); // needed: PrimaryTimetableScope hands out a freshly loading bloc. The
timetable.refresh(); // widget still shows its last snapshot, so this waits until the
// Mirror BLoC updates into the home-screen widget without waiting // cold-start frames are done (a bloc emit in the meantime supersedes it).
// for the periodic background refresh. Debouncer.debounce('widgetPublish', const Duration(seconds: 3), () {
final settingsCubit = context.read<SettingsCubit>(); if (mounted) _publishWidget(context.read<TimetableBloc>());
final capabilitiesCubit = context.read<CapabilitiesCubit>();
_timetableWidgetSync?.cancel();
// Debounced: a refresh or week swipe emits several times in a row, and
// only the settled state is worth mirroring.
_timetableWidgetSync = timetable.stream.listen((state) {
final data = state.data;
if (data is TimetableState && !state.isLoading) {
final epoch = AccountData().sessionEpoch;
Debouncer.debounce(
'widgetPublish',
const Duration(seconds: 1),
() => unawaited(
WidgetPublisher.publishFromBlocState(
data,
settings: settingsCubit.val(),
isTeacher: capabilitiesCubit.isTeacher,
epoch: epoch,
),
),
);
}
}); });
// Initial publish in case hydrated storage already has data. The widget
// still shows its last snapshot, so this waits until the cold-start
// frames are done (a fresh bloc emit in the meantime supersedes it).
final initialData = timetable.state.data;
if (initialData is TimetableState) {
final epoch = AccountData().sessionEpoch;
Debouncer.debounce(
'widgetPublish',
const Duration(seconds: 3),
() => unawaited(
WidgetPublisher.publishFromBlocState(
initialData,
settings: settingsCubit.val(),
isTeacher: capabilitiesCubit.isTeacher,
epoch: epoch,
),
),
);
}
unawaited(_handlePendingWidgetNavigation()); unawaited(_handlePendingWidgetNavigation());
ShareIntentListener.pending.addListener(_handlePendingShare); ShareIntentListener.pending.addListener(_handlePendingShare);
_handlePendingShare(); _handlePendingShare();
@@ -237,8 +225,12 @@ class _AppState extends State<App> with WidgetsBindingObserver {
// Android renders pushes locally, so a tap arrives via the local // Android renders pushes locally, so a tap arrives via the local
// notifications callback (PushTapRouter) rather than onMessageOpenedApp. // notifications callback (PushTapRouter) rather than onMessageOpenedApp.
PushTapRouter.pendingChatToken.addListener(_onPushTapPending); PushTapRouter.pendingTarget.addListener(_onPushTargetPending);
PushTapRouter.pendingNewsletterId.addListener(_onNewsletterTapPending); unawaited(
PushTapRouter.handleAppLaunch(
NotificationService().flutterLocalNotificationsPlugin,
),
);
_onMessageSub = FirebaseMessaging.onMessage.listen((message) { _onMessageSub = FirebaseMessaging.onMessage.listen((message) {
if (!mounted) return; if (!mounted) return;
@@ -267,12 +259,10 @@ class _AppState extends State<App> with WidgetsBindingObserver {
@override @override
void dispose() { void dispose() {
_timetableWidgetSync?.cancel();
_onMessageSub?.cancel(); _onMessageSub?.cancel();
_onMessageOpenedAppSub?.cancel(); _onMessageOpenedAppSub?.cancel();
_fcmTokenRefreshSub?.cancel(); _fcmTokenRefreshSub?.cancel();
PushTapRouter.pendingChatToken.removeListener(_onPushTapPending); PushTapRouter.pendingTarget.removeListener(_onPushTargetPending);
PushTapRouter.pendingNewsletterId.removeListener(_onNewsletterTapPending);
ShareIntentListener.pending.removeListener(_handlePendingShare); ShareIntentListener.pending.removeListener(_handlePendingShare);
Main.bottomNavigator.removeListener(_onTabControllerChanged); Main.bottomNavigator.removeListener(_onTabControllerChanged);
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
@@ -280,7 +270,24 @@ class _AppState extends State<App> with WidgetsBindingObserver {
} }
@override @override
Widget build( Widget build(BuildContext context) =>
BlocListener<TimetableBloc, LoadableState<TimetableState>>(
// Also follows a swapped instance (child switch), unlike a manual
// stream subscription.
listenWhen: (_, state) => !state.isLoading,
// A week change emits several times (week, prefetched neighbours);
// one widget reload for the burst is enough.
listener: (_, _) => Debouncer.debounce(
'widgetPublish',
const Duration(seconds: 1),
() {
if (mounted) _publishWidget(context.read<TimetableBloc>());
},
),
child: _buildShell(context),
);
Widget _buildShell(
BuildContext context, BuildContext context,
) => BlocSelector<SettingsCubit, model.Settings, Object>( ) => BlocSelector<SettingsCubit, model.Settings, Object>(
// Only the module layout shapes the shell; copied values because the // Only the module layout shapes the shell; copied values because the
+22
View File
@@ -0,0 +1,22 @@
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
/// Binds a guardian login request to the device that started it (PKCE-style):
/// the request carries only [challengeFor] of a secret that never leaves the
/// device, verification sends the secret itself. A mail link opened on another
/// device therefore cannot complete the login.
abstract final class DeviceBinding {
static String generateSecret([Random? random]) {
final rng = random ?? Random.secure();
final bytes = List<int>.generate(32, (_) => rng.nextInt(256));
return _base64UrlNoPad(bytes);
}
static String challengeFor(String secret) =>
_base64UrlNoPad(sha256.convert(utf8.encode(secret)).bytes);
static String _base64UrlNoPad(List<int> bytes) =>
base64Url.encode(bytes).replaceAll('=', '');
}
+45
View File
@@ -0,0 +1,45 @@
import 'package:app_links/app_links.dart';
import 'package:flutter/foundation.dart';
import 'guardian_login_link.dart';
/// Bridges incoming App Links / Universal Links into [pending]; the login
/// screen consumes it. Mirrors ShareIntentListener: [initialize] reads the
/// cold-start link before `runApp` and then follows links while running.
///
/// Links are kept raw and parsed on consumption: the endpoint setting the
/// link is checked against is only applied once the app is built.
class GuardianLinkListener {
GuardianLinkListener._();
static final GuardianLinkListener instance = GuardianLinkListener._();
static final ValueNotifier<Uri?> pending = ValueNotifier(null);
final AppLinks _appLinks = AppLinks();
bool _listening = false;
Future<void> initialize() async {
try {
final initial = await _appLinks.getInitialLink();
if (initial != null) _publish(initial);
} catch (e) {
debugPrint('GuardianLinkListener.initialize failed: $e');
}
if (_listening) return;
_listening = true;
// Kept for the whole process lifetime; links can arrive at any time.
_appLinks.uriLinkStream.listen(
_publish,
onError: (Object e) => debugPrint('GuardianLinkListener error: $e'),
);
}
// Cheap pre-filter; the host check against the active endpoint happens in
// GuardianLoginLink.parse.
void _publish(Uri uri) {
if (!uri.path.endsWith(GuardianLoginLink.path)) return;
pending.value = uri;
}
static void clear() => pending.value = null;
}
+26
View File
@@ -0,0 +1,26 @@
/// A sign-in link from the guardian login mail
/// (`https://<connect-host>/app/guardian-login?rid=…&lt=…`).
class GuardianLoginLink {
static const path = '/app/guardian-login';
final String requestId;
final String linkToken;
const GuardianLoginLink({required this.requestId, required this.linkToken});
/// Parses [uri] if it is a guardian login link for the server at [apiBase].
/// Links of another server (e.g. live link while the app points at beta)
/// are rejected: the request only exists on the server that sent the mail.
static GuardianLoginLink? parse(Uri uri, {required Uri apiBase}) {
if (uri.scheme != 'https' || uri.host != apiBase.host) return null;
final basePath = apiBase.path.endsWith('/')
? apiBase.path.substring(0, apiBase.path.length - 1)
: apiBase.path;
if (uri.path != '$basePath$path') return null;
final requestId = uri.queryParameters['rid'];
final linkToken = uri.queryParameters['lt'];
if (requestId == null || requestId.isEmpty) return null;
if (linkToken == null || linkToken.isEmpty) return null;
return GuardianLoginLink(requestId: requestId, linkToken: linkToken);
}
}
@@ -0,0 +1,83 @@
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// A guardian login request waiting for its code or link. Persisted because
/// Android often kills the app while the user reads the mail.
class PendingGuardianRequest {
static const int defaultCodeLength = 6;
final String requestId;
final String email;
final String deviceSecret;
final DateTime expiresAt;
final DateTime resendAvailableAt;
final int codeLength;
const PendingGuardianRequest({
required this.requestId,
required this.email,
required this.deviceSecret,
required this.expiresAt,
required this.resendAvailableAt,
this.codeLength = defaultCodeLength,
});
bool isExpired(DateTime now) => !now.isBefore(expiresAt);
Map<String, Object> toJson() => {
'requestId': requestId,
'email': email,
'deviceSecret': deviceSecret,
'expiresAt': expiresAt.toIso8601String(),
'resendAvailableAt': resendAvailableAt.toIso8601String(),
'codeLength': codeLength,
};
static PendingGuardianRequest? fromJson(Map<String, dynamic> json) {
try {
return PendingGuardianRequest(
requestId: json['requestId'] as String,
email: json['email'] as String,
deviceSecret: json['deviceSecret'] as String,
expiresAt: DateTime.parse(json['expiresAt'] as String),
resendAvailableAt: DateTime.parse(json['resendAvailableAt'] as String),
codeLength: json['codeLength'] as int? ?? defaultCodeLength,
);
} on Object {
return null;
}
}
}
class PendingGuardianRequestStore {
static const _key = 'guardian_login_pending_request';
static const FlutterSecureStorage _storage = FlutterSecureStorage(
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
const PendingGuardianRequestStore();
Future<PendingGuardianRequest?> read() async {
try {
final raw = await _storage.read(key: _key);
if (raw == null) return null;
return PendingGuardianRequest.fromJson(
jsonDecode(raw) as Map<String, dynamic>,
);
} on Object {
return null;
}
}
Future<void> write(PendingGuardianRequest request) =>
_storage.write(key: _key, value: jsonEncode(request.toJson()));
Future<void> clear() async {
try {
await _storage.delete(key: _key);
} on Object {
// Nothing stored or keystore unavailable.
}
}
}
+53 -22
View File
@@ -6,6 +6,7 @@ import 'package:flutter/widgets.dart';
import 'package:workmanager/workmanager.dart'; import 'package:workmanager/workmanager.dart';
import '../api/marianumconnect/marianumconnect_endpoint.dart'; import '../api/marianumconnect/marianumconnect_endpoint.dart';
import '../api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_get.dart';
import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart'; import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart';
import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart'; import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart';
@@ -14,11 +15,11 @@ import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subj
import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; import '../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid.dart'; import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid.dart';
import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart'; import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week.dart';
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event.dart';
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart';
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
import '../model/account_data.dart'; import '../session/session.dart';
import '../session/session_manager.dart';
import '../state/app/modules/timetable/data_provider/timetable_data_provider.dart';
import '../state/app/modules/timetable/subject/timetable_subject.dart';
import '../widget_data/widget_data_mapper.dart'; import '../widget_data/widget_data_mapper.dart';
import '../widget_data/widget_publisher.dart'; import '../widget_data/widget_publisher.dart';
import '../widget_data/widget_sync.dart'; import '../widget_data/widget_sync.dart';
@@ -81,17 +82,17 @@ class WidgetBackgroundTask {
/// Throws on fetch failure so the worker path can signal a retry. /// Throws on fetch failure so the worker path can signal a retry.
static Future<void> runRefreshNow({bool force = false}) async { static Future<void> runRefreshNow({bool force = false}) async {
await WidgetSync.ensureInitialized(); await WidgetSync.ensureInitialized();
bool populated; Session? session;
try { try {
// Bounded: a hanging keystore read must not stall the caller's budget // Bounded: a hanging keystore read must not stall the caller's budget
// (FCM handler ~25s on iOS) forever. // (FCM handler ~25s on iOS) forever.
populated = await AccountData().waitForPopulation().timeout( session = await SessionManager().waitForLoad().timeout(
const Duration(seconds: 10), const Duration(seconds: 10),
); );
} on TimeoutException { } on TimeoutException {
populated = false; session = null;
} }
if (!populated) { if (session == null) {
// Deliberately does NOT flip the widget to logged-out: a failed or slow // Deliberately does NOT flip the widget to logged-out: a failed or slow
// keychain read (locked iOS device during the 06:00 silent push) is // keychain read (locked iOS device during the 06:00 silent push) is
// indistinguishable from "never logged in" here, and blanking the // indistinguishable from "never logged in" here, and blanking the
@@ -101,11 +102,23 @@ class WidgetBackgroundTask {
return; return;
} }
final fetchedAt = await WidgetSync.getFetchedAt(); final fetchedAt = await WidgetSync.getFetchedAt();
if (shouldSkipRefresh(fetchedAt: fetchedAt, now: DateTime.now(), force: force)) { if (shouldSkipRefresh(
fetchedAt: fetchedAt,
now: DateTime.now(),
force: force,
)) {
log('[widget-refresh] snapshot is fresh, skipping refresh'); log('[widget-refresh] snapshot is fresh, skipping refresh');
return; return;
} }
await _refresh(); final subject = widgetRefreshSubject(
session: session,
stored: await WidgetSync.getSubject(),
);
if (subject == null) {
log('[widget-refresh] no plan selected yet, skipping refresh');
return;
}
await _refresh(subject);
} }
static Future<void> cancelAll() async { static Future<void> cancelAll() async {
@@ -126,6 +139,16 @@ bool shouldSkipRefresh({
return !age.isNegative && age < WidgetBackgroundTask.refreshDebounce; return !age.isNegative && age < WidgetBackgroundTask.refreshDebounce;
} }
/// The plan the background refresh loads. Guardians only get one once the app
/// has published a child; before that there is nothing sensible to fetch.
TimetableSubject? widgetRefreshSubject({
required Session session,
required TimetableSubject? stored,
}) => switch (session) {
CredentialSession() => const OwnTimetable(),
GuardianSession() => stored is ChildTimetable ? stored : null,
};
@pragma('vm:entry-point') @pragma('vm:entry-point')
void _callbackDispatcher() { void _callbackDispatcher() {
Workmanager().executeTask((task, inputData) async { Workmanager().executeTask((task, inputData) async {
@@ -142,7 +165,7 @@ void _callbackDispatcher() {
}); });
} }
Future<void> _refresh() async { Future<void> _refresh(TimetableSubject subject) async {
await WidgetSync.ensureInitialized(); await WidgetSync.ensureInitialized();
// The background isolate doesn't go through main.dart's BlocBuilder, so we // The background isolate doesn't go through main.dart's BlocBuilder, so we
// re-apply the endpoint the foreground last persisted. Without this the // re-apply the endpoint the foreground last persisted. Without this the
@@ -165,9 +188,11 @@ Future<void> _refresh() async {
// latency is the slowest request, not the sum (matters for the push path's // latency is the slowest request, not the sum (matters for the push path's
// hard time budget). Reference-data failures fall through to null in the // hard time budget). Reference-data failures fall through to null in the
// mapper rather than aborting the whole refresh. // mapper rather than aborting the whole refresh.
final timetableFuture = TimetableGetWeek().run( final until = weekEndExclusive.subtract(const Duration(days: 1));
final timetableFuture = TimetableDataProvider.fetchWeek(
subject,
from: weekStart, from: weekStart,
until: weekEndExclusive.subtract(const Duration(days: 1)), until: until,
); );
final subjectsFuture = _runOrNull<TimetableGetSubjectsResponse>( final subjectsFuture = _runOrNull<TimetableGetSubjectsResponse>(
() => TimetableGetSubjects().run(), () => TimetableGetSubjects().run(),
@@ -181,11 +206,11 @@ Future<void> _refresh() async {
final timegridFuture = _runOrNull<TimetableGetTimegridResponse>( final timegridFuture = _runOrNull<TimetableGetTimegridResponse>(
() => TimetableGetTimegrid().run(), () => TimetableGetTimegrid().run(),
); );
final customEventsFuture = _runOrNull<GetCustomTimetableEventResponse>( final customEventsFuture = subject.supportsCustomEvents
() => GetCustomTimetableEvent( ? _runOrNull<GetCustomTimetableEventResponse>(
GetCustomTimetableEventParams(AccountData().getUserSecret()), () => TimetableCustomEventsGet().run(),
).run(), )
); : Future<GetCustomTimetableEventResponse?>.value();
final timetable = await timetableFuture; final timetable = await timetableFuture;
final subjects = await subjectsFuture; final subjects = await subjectsFuture;
final rooms = await roomsFuture; final rooms = await roomsFuture;
@@ -195,9 +220,9 @@ Future<void> _refresh() async {
final lessons = timetable.entries; final lessons = timetable.entries;
final [connectDouble, isTeacher] = await Future.wait([ final [connectDouble, showClassInsteadOfTeacher] = await Future.wait([
WidgetSync.getConnectDoubleLessons(), WidgetSync.getConnectDoubleLessons(),
WidgetSync.getIsTeacher(), WidgetSync.getShowClassInsteadOfTeacher(),
]); ]);
final dayData = WidgetDataMapper.buildDayData( final dayData = WidgetDataMapper.buildDayData(
now: now, now: now,
@@ -208,7 +233,7 @@ Future<void> _refresh() async {
timegrid: timegrid, timegrid: timegrid,
customEvents: customEvents, customEvents: customEvents,
connectDoubleLessons: connectDouble, connectDoubleLessons: connectDouble,
showClassInsteadOfTeacher: isTeacher, showClassInsteadOfTeacher: showClassInsteadOfTeacher,
); );
final weekData = WidgetDataMapper.buildWeekData( final weekData = WidgetDataMapper.buildWeekData(
now: now, now: now,
@@ -219,9 +244,15 @@ Future<void> _refresh() async {
timegrid: timegrid, timegrid: timegrid,
customEvents: customEvents, customEvents: customEvents,
connectDoubleLessons: connectDouble, connectDoubleLessons: connectDouble,
showClassInsteadOfTeacher: isTeacher, showClassInsteadOfTeacher: showClassInsteadOfTeacher,
); );
// The user may have switched the child while the requests ran; writing now
// would show the previous child's plan.
if (await WidgetSync.getSubject() != subject) {
log('[widget-bg] subject changed during refresh, discarding');
return;
}
await WidgetSync.writeDayData(dayData); await WidgetSync.writeDayData(dayData);
await WidgetSync.writeWeekData(weekData); await WidgetSync.writeWeekData(weekData);
await WidgetSync.setLoggedIn(true); await WidgetSync.setLoggedIn(true);
+213 -143
View File
@@ -11,12 +11,12 @@ import 'package:flutter/scheduler.dart' show timeDilation;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:jiffy/jiffy.dart'; import 'package:jiffy/jiffy.dart';
import 'package:loader_overlay/loader_overlay.dart'; import 'package:loader_overlay/loader_overlay.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import 'access/user_role.dart';
import 'api/marianumcloud/webdav/queries/list_files/list_files_cache.dart'; import 'api/marianumcloud/webdav/queries/list_files/list_files_cache.dart';
import 'api/marianumconnect/auth/session_validator.dart'; import 'api/marianumconnect/auth/session_validator.dart';
import 'api/marianumconnect/marianumconnect_endpoint.dart'; import 'api/marianumconnect/marianumconnect_endpoint.dart';
@@ -24,32 +24,36 @@ import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
import 'api/marianumconnect/queries/report_client_error/client_error_reporter.dart'; import 'api/marianumconnect/queries/report_client_error/client_error_reporter.dart';
import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart'; import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart';
import 'app.dart'; import 'app.dart';
import 'auth_link/guardian_link_listener.dart';
import 'background/widget_background_task.dart'; import 'background/widget_background_task.dart';
import 'firebase_options.dart'; import 'firebase_options.dart';
import 'model/account_data.dart';
import 'model/session_wipe.dart'; import 'model/session_wipe.dart';
import 'notification/notification_service.dart'; import 'notification/notification_service.dart';
import 'push/notification_permission_prompt.dart';
import 'push/push_message_handler.dart'; import 'push/push_message_handler.dart';
import 'push/push_registration.dart'; import 'push/push_registration.dart';
import 'push/push_registration_store.dart'; import 'push/push_registration_store.dart';
import 'push/push_renderer.dart'; import 'push/push_renderer.dart';
import 'routing/app_routes.dart'; import 'routing/app_routes.dart';
import 'session/account_codec.dart';
import 'session/session_lifecycle.dart';
import 'session/session_manager.dart';
import 'share_intent/share_intent_listener.dart'; import 'share_intent/share_intent_listener.dart';
import 'state/app/modules/account/account_scope.dart';
import 'state/app/modules/account/bloc/account_bloc.dart'; import 'state/app/modules/account/bloc/account_bloc.dart';
import 'state/app/modules/account/bloc/account_state.dart'; import 'state/app/modules/account/bloc/account_state.dart';
import 'state/app/modules/app_modules.dart'; import 'state/app/modules/app_modules.dart';
import 'state/app/modules/breaker/bloc/breaker_bloc.dart';
import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart'; import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart';
import 'state/app/modules/chat/bloc/chat_bloc.dart';
import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import 'state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart'; import 'state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
import 'state/app/modules/parent_letters/bloc/parent_letters_bloc.dart';
import 'state/app/modules/settings/bloc/settings_cubit.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart';
import 'state/app/modules/timetable/bloc/timetable_bloc.dart'; import 'storage/account_storage.dart';
import 'storage/hydrated_storage_bootstrap.dart';
import 'storage/settings.dart'; import 'storage/settings.dart';
import 'theming/dark_app_theme.dart'; import 'theming/dark_app_theme.dart';
import 'theming/light_app_theme.dart'; import 'theming/light_app_theme.dart';
import 'utils/app_paths.dart'; import 'utils/app_paths.dart';
import 'utils/debouncer.dart';
import 'utils/downloads/download_manager.dart'; import 'utils/downloads/download_manager.dart';
import 'view/login/account_loading_screen.dart'; import 'view/login/account_loading_screen.dart';
import 'view/login/login.dart'; import 'view/login/login.dart';
@@ -59,6 +63,7 @@ import 'widget/breaker/breaker.dart';
import 'widget/debug/debug_stats_overlay.dart'; import 'widget/debug/debug_stats_overlay.dart';
import 'widget/downloads/download_tray.dart'; import 'widget/downloads/download_tray.dart';
import 'widget/emergency/emergency_notice_gate.dart'; import 'widget/emergency/emergency_notice_gate.dart';
import 'widget/info_dialog.dart';
import 'widget_data/widget_sync.dart'; import 'widget_data/widget_sync.dart';
/// Runs one startup step with a time limit. Anything that throws or hangs /// Runs one startup step with a time limit. Anything that throws or hangs
@@ -119,7 +124,7 @@ void _installErrorHandlers() {
Future<void> main() async { Future<void> main() async {
log('MarianumMobile started'); log('MarianumMobile started');
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
AccountData().markUiEngine(); SessionManager().markUiEngine();
// Before any initialisation so startup failures reach the backend too. // Before any initialisation so startup failures reach the backend too.
_installErrorHandlers(); _installErrorHandlers();
@@ -146,27 +151,37 @@ Future<void> main() async {
]), ]),
), ),
_startupStep('hydrated storage', () async { _startupStep('hydrated storage', () async {
final path = (await getTemporaryDirectory()).path; await AccountStorage.init((await getTemporaryDirectory()).path);
HydratedBloc.storage = await buildHydratedStorageWithFallback(path);
}, timeout: null), }, timeout: null),
_startupStep('documents dir', () async { _startupStep('documents dir', () async {
AppPaths.documentsDir = (await getApplicationDocumentsDirectory()).path; AppPaths.documentsDir = (await getApplicationDocumentsDirectory()).path;
}), }),
// The keychain may still be locked right after device unlock; AccountData // The keychain may still be locked right after device unlock; the session
// keeps retrying, so on timeout the app starts on the loading screen and // keeps retrying, so on timeout the app starts on the loading screen and
// flips to the real state once the session is readable (see _MainState). // flips to the real state once the session is readable (see _MainState).
_startupStep( _startupStep(
'account data', 'account data',
AccountData().waitForPopulation, SessionManager().waitForLoad,
timeout: const Duration(seconds: 5), timeout: const Duration(seconds: 5),
// Expected on every background wake of a locked device; not an error. // Expected on every background wake of a locked device; not an error.
report: false, report: false,
), ),
_startupStep('share intent', ShareIntentListener.instance.initialize), _startupStep('share intent', ShareIntentListener.instance.initialize),
_startupStep('guardian link', GuardianLinkListener.instance.initialize),
]; ];
log('starting app initialisation...'); log('starting app initialisation...');
await Future.wait(initialisationTasks); await Future.wait(initialisationTasks);
// Snapshot: the session may still finish loading later (see _MainState),
// the bloc and the storage must agree on the account they start with.
final initialAccount = SessionManager().isLoaded
? SessionManager().activeAccount
: null;
await _startupStep(
'account storage',
() => AccountStorage.activate(initialAccount),
timeout: null,
);
log('app initialisation done!'); log('app initialisation done!');
// Independent of the notification setup below, so it runs alongside it. // Independent of the notification setup below, so it runs alongside it.
@@ -224,7 +239,7 @@ Future<void> main() async {
// has data ready by the time the user navigates to it. No-op when a // has data ready by the time the user navigates to it. No-op when a
// cached payload is already present, so this does not undo the day-long // cached payload is already present, so this does not undo the day-long
// root cache TTL. // root cache TTL.
if (AccountData().isPopulated()) { if (SessionManager().hasNextcloud) {
unawaited( unawaited(
ListFilesCache.prefetchRootListing().onError( ListFilesCache.prefetchRootListing().onError(
(e, _) => log('Files root prefetch failed: $e'), (e, _) => log('Files root prefetch failed: $e'),
@@ -237,34 +252,48 @@ Future<void> main() async {
// placeholder flash. // placeholder flash.
AvatarDiskCache.instance.warmUp(); AvatarDiskCache.instance.warmUp();
// Created eagerly so the endpoint is configured before anything below can
// issue a request (the primary timetable bloc loads on creation).
final settingsCubit = SettingsCubit(storage: AccountStorage.global);
_syncMarianumConnectEndpoint(settingsCubit.state);
settingsCubit.stream.listen(_syncMarianumConnectEndpoint);
log('running app...'); log('running app...');
runApp( runApp(
MultiBlocProvider( MultiBlocProvider(
providers: [ providers: [
BlocProvider<SettingsCubit>(create: (_) => SettingsCubit()), BlocProvider<SettingsCubit>.value(value: settingsCubit),
BlocProvider<AccountBloc>( BlocProvider<AccountBloc>(
create: (_) => AccountBloc(initialStatus: _initialAccountStatus()), create: (_) => AccountBloc(
initialStatus: _initialAccountStatus(initialAccount),
accountId: initialAccount?.id,
), ),
BlocProvider<BreakerBloc>(create: (_) => BreakerBloc()),
BlocProvider<CapabilitiesCubit>(create: (_) => CapabilitiesCubit()),
BlocProvider<NextcloudCapabilitiesCubit>(
create: (_) => NextcloudCapabilitiesCubit(),
), ),
BlocProvider<ChatListBloc>(create: (_) => ChatListBloc()),
BlocProvider<ChatBloc>(
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
),
BlocProvider<TimetableBloc>(create: (_) => TimetableBloc()),
], ],
child: const Main(), child: const Main(),
), ),
); );
} }
AccountStatus _initialAccountStatus() { String? _syncedMcBaseUrl;
final account = AccountData();
if (account.isPopulated()) return AccountStatus.loggedIn; /// Keeps the MC dio singleton aligned with the selected endpoint (live /
return account.isLoaded ? AccountStatus.loggedOut : AccountStatus.undefined; /// beta / custom), mirrored into WidgetSync so the background isolate
/// refreshes against the same endpoint. Settings emit on every toggle; only
/// an actual URL change is applied.
void _syncMarianumConnectEndpoint(Settings settings) {
final url = settings.devToolsSettings.resolveMarianumConnectBaseUrl();
if (url == _syncedMcBaseUrl) return;
_syncedMcBaseUrl = url;
MarianumConnectEndpoint.update(url);
unawaited(WidgetSync.setMarianumConnectBaseUrl(url));
}
AccountStatus _initialAccountStatus(AccountEntry? account) {
if (account != null) return AccountStatus.loggedIn;
return SessionManager().isLoaded
? AccountStatus.loggedOut
: AccountStatus.undefined;
} }
class Main extends StatefulWidget { class Main extends StatefulWidget {
@@ -293,38 +322,75 @@ class _MainState extends State<Main> {
Jiffy.setLocale('de'); Jiffy.setLocale('de');
_lastStatus = context.read<AccountBloc>().state.status; _lastStatus = context.read<AccountBloc>().state.status;
AccountData().waitForPopulation().then((value) { SessionManager().unauthorizedSignal.addListener(_onUnauthorized);
GuardianLinkListener.pending.addListener(_onGuardianLink);
SessionManager().waitForLoad().then((session) async {
if (!mounted) return; if (!mounted) return;
final accountBloc = context.read<AccountBloc>(); final accountBloc = context.read<AccountBloc>();
accountBloc.setStatus( if (session == null) {
value ? AccountStatus.loggedIn : AccountStatus.loggedOut, accountBloc.setStatus(AccountStatus.loggedOut);
); return;
if (value) {
_scheduleSessionValidation(accountBloc);
// Cold start while already logged in: the account status doesn't
// change, so the loggedIn listener below never fires — refresh
// capabilities here, then self-heal the push registration.
final settingsCubit = context.read<SettingsCubit>();
unawaited(
context.read<CapabilitiesCubit>().load().then((_) {
if (!mounted) return;
final capabilities = context.read<CapabilitiesCubit>();
_syncPush(settingsCubit, capabilities);
_applyRoleDefaults(settingsCubit, capabilities);
}),
);
unawaited(context.read<NextcloudCapabilitiesCubit>().load());
} }
// Covers a session that finished loading after the startup snapshot;
// otherwise the id is unchanged and nothing remounts.
final account = SessionManager().activeAccount;
await AccountStorage.activate(account);
if (!mounted) return;
accountBloc.activated(account?.id);
_scheduleSessionValidation(accountBloc);
unawaited(_onGuardianLink());
}); });
} }
/// Warms the core caches (timetable, chat list, files root) in the /// Runs whenever the account-scoped tree mounts for a signed-in account:
/// background so the first screen render hits populated data. /// pulls the capability flags, then registers push for this account.
void _onSessionActive(BuildContext scopeContext) {
final settingsCubit = scopeContext.read<SettingsCubit>();
final capabilitiesCubit = scopeContext.read<CapabilitiesCubit>();
unawaited(
capabilitiesCubit.load().then((_) {
// Closed: the account was switched away from while loading.
if (!mounted || capabilitiesCubit.isClosed) return;
_syncPush(settingsCubit, capabilitiesCubit);
_applyRoleDefaults(settingsCubit, capabilitiesCubit);
_promptGuardianNotifications();
}),
);
unawaited(scopeContext.read<NextcloudCapabilitiesCubit>().load());
unawaited(SessionLifecycle.refreshDisplayName());
_prefetchBaseData(scopeContext);
WidgetsBinding.instance.addPostFrameCallback((_) => _showTakeoverNotice());
}
/// A forced sign-out that handed over to another account never reaches the
/// login screen, which normally explains it.
void _showTakeoverNotice() {
final notice = SessionLifecycle.signOutNotice.value;
final overlayContext = AppRoutes.overlayContext;
if (notice == null || overlayContext == null) return;
SessionLifecycle.signOutNotice.value = null;
InfoDialog.show(overlayContext, notice, title: 'Abgemeldet');
}
/// Waits for the post-login splash so the dialog never covers it; the
/// splash's completion calls this again.
void _promptGuardianNotifications() {
if (_showPostLoginSplash) return;
final overlayContext = AppRoutes.overlayContext;
if (overlayContext == null) return;
unawaited(maybePromptGuardianLoginNotifications(overlayContext));
}
/// Warms the chat list and files root in the background so the first screen
/// render hits populated data. The timetable needs no warm-up:
/// PrimaryTimetableScope creates a freshly loading bloc per account.
void _prefetchBaseData(BuildContext context) { void _prefetchBaseData(BuildContext context) {
context.read<TimetableBloc>().refresh();
unawaited(context.read<ChatListBloc>().refresh(silent: true)); unawaited(context.read<ChatListBloc>().refresh(silent: true));
unawaited(context.read<ParentLettersBloc>().refresh(silent: true));
if (SessionManager().hasNextcloud) {
unawaited(ListFilesCache.prefetchRootListing()); unawaited(ListFilesCache.prefetchRootListing());
} }
}
/// Registers/self-heals the push subscription whenever the backend advertises /// Registers/self-heals the push subscription whenever the backend advertises
/// the capability — independent of the notification toggle, so a user with /// the capability — independent of the notification toggle, so a user with
@@ -353,26 +419,83 @@ class _MainState extends State<Main> {
) { ) {
if (AppModule.applyTeacherDefaults( if (AppModule.applyTeacherDefaults(
settings.val().modulesSettings, settings.val().modulesSettings,
isTeacher: capabilities.isTeacher, isTeacher: capabilities.state.role == UserRole.teacher,
)) { )) {
settings.val(write: true); settings.val(write: true);
} }
} }
@override
void dispose() {
SessionManager().unauthorizedSignal.removeListener(_onUnauthorized);
GuardianLinkListener.pending.removeListener(_onGuardianLink);
super.dispose();
}
/// A guardian login link tapped while signed in belongs to an "add account"
/// flow (the app may have been killed meanwhile): reopen the login for it.
Future<void> _onGuardianLink() async {
if (GuardianLinkListener.pending.value == null || !mounted) return;
final accountBloc = context.read<AccountBloc>();
if (accountBloc.state.status != AccountStatus.loggedIn) return;
await SessionLifecycle.beginAddAccount();
accountBloc.setStatus(AccountStatus.addingAccount);
}
void _onUnauthorized() {
if (!mounted) return;
final accountBloc = context.read<AccountBloc>();
if (accountBloc.state.status != AccountStatus.loggedIn) return;
Debouncer.throttle(
'sessionUnauthorized',
const Duration(seconds: 30),
() => _scheduleSessionValidation(accountBloc),
);
}
/// Background credential check: a 401 means the password was rotated /// Background credential check: a 401 means the password was rotated
/// server-side, so the validator wipes the local session and flips the /// server-side, so the validator signs the account out and the app moves
/// account bloc to `loggedOut` (sending the user to the login screen). /// on to another stored account or the login screen.
void _scheduleSessionValidation(AccountBloc accountBloc) { void _scheduleSessionValidation(AccountBloc accountBloc) {
unawaited( unawaited(
SessionValidator.probeStored( SessionValidator.probeStored(
onInvalidated: () async { onInvalidated: (nextAccountId) async {
if (!mounted) return; if (!mounted) return;
accountBloc.setStatus(AccountStatus.loggedOut); accountBloc.activated(nextAccountId);
}, },
), ),
); );
} }
void _onAccountChanged(BuildContext context, AccountState accountState) {
if (accountState.status == AccountStatus.loggedIn &&
accountState.freshLogin) {
_showPostLoginSplash = true;
_appMounted = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _appMounted = true);
});
}
final wasLoggedIn = _lastStatus == AccountStatus.loggedIn;
_lastStatus = accountState.status;
// A cold start that merely resolves as signed out has nothing to wipe,
// and a share waiting for the login must survive it.
if (accountState.status != AccountStatus.loggedOut || !wasLoggedIn) return;
// Deferred until the account-scoped tree is torn down.
WidgetsBinding.instance.addPostFrameCallback(
(_) => unawaited(SessionWipe.deviceLeft()),
);
}
/// The account-scoped tree only lives while its account is the one in use:
/// adding another account parks it, so its blocs go as well.
static Object _scopeOf(AccountState state) => (
state.accountId,
state.status == AccountStatus.addingAccount,
);
Object? _scope;
@override @override
Widget build(BuildContext context) => Directionality( Widget build(BuildContext context) => Directionality(
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
@@ -382,7 +505,6 @@ class _MainState extends State<Main> {
child: BlocSelector<SettingsCubit, Settings, _RootSettings>( child: BlocSelector<SettingsCubit, Settings, _RootSettings>(
selector: (settings) => ( selector: (settings) => (
appTheme: settings.appTheme, appTheme: settings.appTheme,
mcBaseUrl: settings.devToolsSettings.resolveMarianumConnectBaseUrl(),
notificationsEnabled: settings.notificationSettings.enabled, notificationsEnabled: settings.notificationSettings.enabled,
showPerformanceOverlay: showPerformanceOverlay:
settings.devToolsSettings.showPerformanceOverlay, settings.devToolsSettings.showPerformanceOverlay,
@@ -393,12 +515,6 @@ class _MainState extends State<Main> {
textScaleOverride: settings.devToolsSettings.textScaleOverride, textScaleOverride: settings.devToolsSettings.textScaleOverride,
), ),
builder: (context, root) { builder: (context, root) {
// Keep the MC dio singleton aligned with the currently selected
// endpoint (live / beta / custom). Idempotent when the URL is
// unchanged. Mirrored into WidgetSync so the background isolate
// refreshes against the same endpoint.
MarianumConnectEndpoint.update(root.mcBaseUrl);
unawaited(WidgetSync.setMarianumConnectBaseUrl(root.mcBaseUrl));
// Mirror the notification toggle into group-scoped storage so the FCM // Mirror the notification toggle into group-scoped storage so the FCM
// background isolate and the iOS NSE can suppress rendering when off. // background isolate and the iOS NSE can suppress rendering when off.
unawaited( unawaited(
@@ -407,7 +523,33 @@ class _MainState extends State<Main> {
), ),
); );
timeDilation = root.slowAnimations ? 5.0 : 1.0; timeDilation = root.slowAnimations ? 5.0 : 1.0;
return MaterialApp( return BlocConsumer<AccountBloc, AccountState>(
listenWhen: (previous, current) =>
previous.status != current.status ||
previous.accountId != current.accountId,
listener: _onAccountChanged,
buildWhen: (previous, current) =>
_scopeOf(previous) != _scopeOf(current),
builder: (context, account) {
final scope = _scopeOf(account);
if (scope != _scope) {
_scope = scope;
// The old navigator (and every route on it) goes with the old
// account; a shared GlobalKey would carry it over instead.
AppRoutes.rootNavigatorKey = GlobalKey<NavigatorState>();
}
return AccountScope(
key: ValueKey(scope),
onActive: _onSessionActive,
child: _buildApp(root),
);
},
);
},
),
);
Widget _buildApp(_RootSettings root) => MaterialApp(
showPerformanceOverlay: root.showPerformanceOverlay, showPerformanceOverlay: root.showPerformanceOverlay,
showSemanticsDebugger: root.showSemanticsDebugger, showSemanticsDebugger: root.showSemanticsDebugger,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
@@ -457,97 +599,29 @@ class _MainState extends State<Main> {
child: LoaderOverlay( child: LoaderOverlay(
child: Breaker( child: Breaker(
breaker: BreakerArea.global, breaker: BreakerArea.global,
child: BlocConsumer<AccountBloc, AccountState>( child: BlocBuilder<AccountBloc, AccountState>(
listenWhen: (previous, current) => buildWhen: (previous, current) => previous.status != current.status,
previous.status != current.status,
listener: (context, accountState) {
final wasLoggedIn = _lastStatus == AccountStatus.loggedIn;
_lastStatus = accountState.status;
// Fresh login (loggedOut -> loggedIn): pull capability flags
// for the newly authenticated user, then register push right
// away instead of deferring it to the next app start.
if (accountState.status == AccountStatus.loggedIn) {
final settingsCubit = context.read<SettingsCubit>();
final capabilitiesCubit = context
.read<CapabilitiesCubit>();
unawaited(
capabilitiesCubit.load().then((_) {
if (!mounted) return;
_syncPush(settingsCubit, capabilitiesCubit);
_applyRoleDefaults(settingsCubit, capabilitiesCubit);
}),
);
unawaited(
context.read<NextcloudCapabilitiesCubit>().load(),
);
_showPostLoginSplash = true;
_appMounted = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _appMounted = true);
});
_prefetchBaseData(context);
}
if (accountState.status != AccountStatus.loggedOut) return;
// A pending share would otherwise survive logout and be
// re-applied to the next account. A cold-start share that
// is merely waiting for the stored session to resolve as
// signed out stays, to be sent after login.
if (wasLoggedIn) SessionWipe.clearImmediate();
// Routes pushed via AppRoutes (e.g. Settings) live on the
// root navigator and survive the home swap below, so they
// would still cover the Login screen after logout. Pop
// them here so the user immediately sees Login.
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst);
}
// Capture bloc references before the post-frame callback
// — by the time it runs the dialog/Settings context is
// gone but this listener context is still valid.
final timetableBloc = context.read<TimetableBloc>();
final chatListBloc = context.read<ChatListBloc>();
final chatBloc = context.read<ChatBloc>();
final breakerBloc = context.read<BreakerBloc>();
final capabilitiesCubit = context.read<CapabilitiesCubit>();
final nextcloudCapabilitiesCubit = context
.read<NextcloudCapabilitiesCubit>();
// Defer the actual wipe until after this frame so the
// App tree (TimetableBloc/ChatListBloc watchers etc.)
// is already torn down. Resetting blocs while App is
// still in front caused a black-frame race.
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(
SessionWipe.run(
timetableBloc: timetableBloc,
chatListBloc: chatListBloc,
chatBloc: chatBloc,
breakerBloc: breakerBloc,
capabilitiesCubit: capabilitiesCubit,
nextcloudCapabilitiesCubit:
nextcloudCapabilitiesCubit,
),
);
});
},
builder: (context, accountState) { builder: (context, accountState) {
switch (accountState.status) { switch (accountState.status) {
case AccountStatus.loggedIn: case AccountStatus.loggedIn:
return Stack( return Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
if (_appMounted) if (_appMounted) const App(key: ValueKey('app-shell')),
const App(key: ValueKey('app-shell')),
if (_showPostLoginSplash) if (_showPostLoginSplash)
PostLoginSplash( PostLoginSplash(
key: const ValueKey('post-login-splash'), key: const ValueKey('post-login-splash'),
onComplete: () => setState( onComplete: () {
() => _showPostLoginSplash = false, setState(() => _showPostLoginSplash = false);
), _promptGuardianNotifications();
},
), ),
], ],
); );
case AccountStatus.loggedOut: case AccountStatus.loggedOut:
return const Login(); return const Login();
case AccountStatus.addingAccount:
return const Login(addingAccount: true);
case AccountStatus.undefined: case AccountStatus.undefined:
return const AccountLoadingScreen(); return const AccountLoadingScreen();
} }
@@ -557,14 +631,10 @@ class _MainState extends State<Main> {
), ),
), ),
); );
},
),
);
} }
typedef _RootSettings = ({ typedef _RootSettings = ({
ThemeMode appTheme, ThemeMode appTheme,
String mcBaseUrl,
bool notificationsEnabled, bool notificationsEnabled,
bool showPerformanceOverlay, bool showPerformanceOverlay,
bool showSemanticsDebugger, bool showSemanticsDebugger,
-398
View File
@@ -1,398 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../push/push_secure_storage.dart';
import '../utils/exponential_backoff.dart';
class AccountData {
static const _usernameField = 'username';
static const _passwordField = 'password';
// App passwords live in the push-shared (group-scoped) keystore so the iOS
// Notification Service Extension can authenticate Nextcloud calls too.
// The talk password authenticates the second (apptype=talk) push
// registration — Nextcloud binds each push subscription to its session
// token, so two registrations need two app passwords.
static const _appPasswordField = 'nextcloud_app_password';
static const _appPasswordTalkField = 'nextcloud_app_password_talk';
// Marks accounts whose Nextcloud credentials came from Login Flow v2 (2FA):
// the real password is not valid against Nextcloud, only the flow-issued
// app password is — and no further app passwords can be minted silently.
static const _loginFlowField = 'nextcloud_login_flow';
// Persists the demo session across cold starts (see DemoMode).
static const _demoField = 'is_demo';
// Keeps isPopulated()/getPassword() valid; demo mode never uses a real one.
static const _demoPasswordPlaceholder = 'demo';
// `first_unlock` so a background launch on a locked device (silent push,
// BGAppRefresh) can still read the session. Items written by older versions
// carry the plugin default `unlocked` and are invisible to this instance
// until _migrateKeychainAccessibility moved them over.
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
static const FlutterSecureStorage _legacySecureStorage = FlutterSecureStorage(
iOptions: IOSOptions(accessibility: KeychainAccessibility.unlocked),
);
static const List<String> _sessionFields = [
_usernameField,
_passwordField,
_demoField,
_loginFlowField,
];
static final AccountData _instance = AccountData._construct();
Completer<void> _populated = Completer();
factory AccountData() => _instance;
AccountData._construct() {
unawaited(_loadWithRetry());
}
int _sessionEpoch = 0;
/// Bumped on every sign-out. Async work captures it when it starts and
/// drops its result when it changed meanwhile, so a request of the previous
/// account cannot land in the next account's state or cache.
int get sessionEpoch => _sessionEpoch;
bool isCurrentSession(int epoch) => epoch == _sessionEpoch;
String? _username;
String? _password;
String? _appPassword;
String? _appPasswordTalk;
bool _isDemo = false;
bool _usesLoginFlow = false;
/// True while the active session is a local demo session (see DemoMode).
bool get isDemo => _isDemo;
/// True when the Nextcloud credentials were obtained via Login Flow v2
/// (browser login, e.g. because the account has two-factor authentication).
/// In that mode the stored real password only authenticates MarianumConnect;
/// every Nextcloud call must use the flow-issued app password.
bool get usesLoginFlow => _usesLoginFlow;
String getUsername() {
if (_username == null) throw Exception('Username not initialized');
return _username!;
}
String getPassword() {
if (_password == null) throw Exception('Password not initialized');
return _password!;
}
String getUserSecret() => sha512
.convert(utf8.encode('${getUsername()}:${getPassword()}'))
.toString();
Future<void> setData(String username, String password) async {
await _secureStorage.write(key: _usernameField, value: username);
await _secureStorage.write(key: _passwordField, value: password);
_username = username;
_password = password;
if (!_populated.isCompleted) _populated.complete();
}
/// Enters a local demo session for [username] — no real credentials or token;
/// every backend is served from fixtures while [isDemo] is true (see DemoMode).
Future<void> setDemo(String username) async {
await _secureStorage.write(key: _usernameField, value: username);
await _secureStorage.write(
key: _passwordField,
value: _demoPasswordPlaceholder,
);
await _secureStorage.write(key: _demoField, value: 'true');
_username = username;
_password = _demoPasswordPlaceholder;
_isDemo = true;
if (!_populated.isCompleted) _populated.complete();
}
Future<void> removeData() async {
_sessionEpoch++;
_populated = Completer();
_username = null;
_password = null;
_appPassword = null;
_appPasswordTalk = null;
_isDemo = false;
_usesLoginFlow = false;
await _secureStorage.delete(key: _usernameField);
await _secureStorage.delete(key: _passwordField);
await _secureStorage.delete(key: _demoField);
await _secureStorage.delete(key: _loginFlowField);
await _clearAppPasswordStorage();
await _clearAppPasswordTalkStorage();
}
/// Persists a freshly minted Nextcloud app password. After this every
/// [getBasicAuthHeader] call authenticates with the app password instead of
/// the real password.
Future<void> setAppPassword(String appPassword) async {
_appPassword = appPassword;
try {
await pushSecureStorage.write(key: _appPasswordField, value: appPassword);
} on Object {
// Group-scoped keystore may be unavailable (e.g. iOS entitlement not yet
// provisioned). Keeping it in memory still lets this session use it.
}
}
Future<void> clearAppPassword() async {
_appPassword = null;
await _clearAppPasswordStorage();
}
/// Adopts an app password obtained via Login Flow v2 and switches the
/// account into flow mode (see [usesLoginFlow]). Any previously stored Talk
/// app password belonged to the old session era and is dropped — the second
/// (optional) flow pass stores a fresh one via [setAppPasswordTalk].
Future<void> setLoginFlow(String appPassword) async {
await setAppPassword(appPassword);
await clearAppPasswordTalk();
_usesLoginFlow = true;
await _secureStorage.write(key: _loginFlowField, value: 'true');
}
bool hasAppPassword() => _appPassword != null && _appPassword!.isNotEmpty;
/// Persists the app password backing the Talk push registration.
Future<void> setAppPasswordTalk(String appPassword) async {
_appPasswordTalk = appPassword;
try {
await pushSecureStorage.write(
key: _appPasswordTalkField,
value: appPassword,
);
} on Object {
// Group-scoped keystore may be unavailable — in-memory still works for
// this session, matching setAppPassword.
}
}
Future<void> clearAppPasswordTalk() async {
_appPasswordTalk = null;
await _clearAppPasswordTalkStorage();
}
bool hasAppPasswordTalk() =>
_appPasswordTalk != null && _appPasswordTalk!.isNotEmpty;
Future<void> _clearAppPasswordStorage() async {
try {
await pushSecureStorage.delete(key: _appPasswordField);
} on Object {
// ignore — nothing stored or keystore unavailable
}
}
Future<void> _clearAppPasswordTalkStorage() async {
try {
await pushSecureStorage.delete(key: _appPasswordTalkField);
} on Object {
// ignore — nothing stored or keystore unavailable
}
}
/// iOS keychain reads fail while protected data is unavailable (app launch
/// racing the unlock, background wake on a locked device). Without a retry
/// the completer never resolved and the app stayed on the launch screen.
Future<void> _loadWithRetry() async {
for (var attempt = 1; !_populated.isCompleted; attempt++) {
try {
await _migrateAndLoad();
return;
} catch (e, s) {
log('AccountData load failed (attempt $attempt): $e', stackTrace: s);
await Future<void>.delayed(exponentialBackoff(attempt));
}
}
}
/// Stops waiting for the stored session; the app then behaves as logged
/// out. The keychain entries stay untouched so a later start can still
/// restore the session.
void abandonLoad() {
if (!_populated.isCompleted) _populated.complete();
}
Future<void> _migrateAndLoad() async {
await _migrateFromLegacyStorage();
await _migrateKeychainAccessibility();
// Independent keystore reads, each a platform-channel round trip with
// decryption: issued together since this gates the first frame.
final (username, password, demo, loginFlow) = await (
_secureStorage.read(key: _usernameField),
_secureStorage.read(key: _passwordField),
_secureStorage.read(key: _demoField),
_secureStorage.read(key: _loginFlowField),
).wait;
_username = username;
_password = password;
_isDemo = demo == 'true';
_usesLoginFlow = loginFlow == 'true';
try {
final (appPassword, appPasswordTalk) = await (
pushSecureStorage.read(key: _appPasswordField),
pushSecureStorage.read(key: _appPasswordTalkField),
).wait;
_appPassword = appPassword;
_appPasswordTalk = appPasswordTalk;
} on Object {
_appPassword = null;
_appPasswordTalk = null;
}
if (!_populated.isCompleted) _populated.complete();
}
// Move credentials from the old SharedPreferences plain-text storage into the
// platform's secure keystore. Run once per install and clear the legacy keys.
Future<void> _migrateFromLegacyStorage() async {
final prefs = await SharedPreferences.getInstance();
final legacyUsername = prefs.getString(_usernameField);
final legacyPassword = prefs.getString(_passwordField);
if (legacyUsername == null || legacyPassword == null) return;
final hasSecure = (await _secureStorage.read(key: _usernameField)) != null;
if (!hasSecure) {
await _secureStorage.write(key: _usernameField, value: legacyUsername);
await _secureStorage.write(key: _passwordField, value: legacyPassword);
}
await prefs.remove(_usernameField);
await prefs.remove(_passwordField);
}
Future<void> _migrateKeychainAccessibility() async {
if (!Platform.isIOS) return;
for (final field in _sessionFields) {
final value = await _legacySecureStorage.read(key: field);
if (value == null) continue;
// Same account+service: the legacy item has to go before the re-add.
await _legacySecureStorage.delete(key: field);
await _secureStorage.write(key: field, value: value);
}
}
bool _isUiEngine = false;
/// Called from `main()`; background entry points never run it.
void markUiEngine() => _isUiEngine = true;
/// Username currently in the keystore. Other engines (widget task, push
/// isolates) sign out or in without this instance noticing.
Future<String?> readStoredUsername() =>
_secureStorage.read(key: _usernameField);
/// Re-reads the session for long-lived background engines: they load once
/// and would otherwise keep acting with an account that signed out in the
/// app meanwhile. Keeps the known state when the keystore is unreadable.
Future<void> reloadFromStorage() async {
// The UI engine performs sign-in and sign-out itself, so its state is
// current; re-reading mid sign-out could resurrect the removed account.
if (_isUiEngine) return;
try {
final username = await _secureStorage.read(key: _usernameField);
final password = await _secureStorage.read(key: _passwordField);
final isDemo = (await _secureStorage.read(key: _demoField)) == 'true';
final usesLoginFlow =
(await _secureStorage.read(key: _loginFlowField)) == 'true';
String? appPassword;
String? appPasswordTalk;
try {
appPassword = await pushSecureStorage.read(key: _appPasswordField);
appPasswordTalk = await pushSecureStorage.read(
key: _appPasswordTalkField,
);
} on Object {
// Group keystore unavailable: fall back to the real password.
}
if (username != _username) _sessionEpoch++;
_username = username;
_password = password;
_isDemo = isDemo;
_usesLoginFlow = usesLoginFlow;
_appPassword = appPassword;
_appPasswordTalk = appPasswordTalk;
if (!_populated.isCompleted) _populated.complete();
} on Object catch (e) {
log('AccountData reload failed, keeping loaded state: $e');
}
}
Future<bool> waitForPopulation() async {
await _populated.future;
return isPopulated();
}
/// True once the stored session has been read (or given up on).
bool get isLoaded => _populated.isCompleted;
bool isPopulated() => _username != null && _password != null;
/// Returns the value for an HTTP `Authorization` header using HTTP Basic.
/// Prefer this over embedding credentials in URLs — error logs and crash
/// reports often capture the URL but not headers.
String getBasicAuthHeader() {
_requirePopulated();
// Prefer the scoped app password once available; it survives real-password
// rotation and is what the push-v2 registration is bound to.
return _basicAuth(_appPassword ?? _password!);
}
/// Basic-auth header using the Talk app password — authenticates the
/// apptype=talk push registration (and its unregister). Throws when the
/// talk password has not been minted yet; callers treat that as a failed
/// talk registration and retry on the next start.
String getTalkBasicAuthHeader() {
_requirePopulated();
if (!hasAppPasswordTalk()) {
// Login-flow account whose second (talk) flow pass was skipped: no
// silent minting possible, the talk registration shares the single
// flow-issued credential.
if (_usesLoginFlow && hasAppPassword()) return _basicAuth(_appPassword!);
throw StateError('Talk app password not available yet');
}
return _basicAuth(_appPasswordTalk!);
}
/// Basic-auth header that always uses the real password. Needed exactly once,
/// to mint the app password via `core/getapppassword` (an app password cannot
/// mint another).
String getRealPasswordBasicAuthHeader() {
_requirePopulated();
return _basicAuth(_password!);
}
/// Secret authenticating against Nextcloud: the app password once available
/// (minted or flow-issued), otherwise the real password. Mirrors the
/// preference of [getBasicAuthHeader] for clients that need the raw secret
/// (WebDAV client construction).
String getNextcloudSecret() {
_requirePopulated();
return _appPassword ?? _password!;
}
void _requirePopulated() {
if (!isPopulated()) {
throw Exception(
'AccountData (e.g. username or password) is not initialized!',
);
}
}
String _basicAuth(String secret) =>
'Basic ${base64Encode(utf8.encode('$_username:$secret'))}';
/// Convenience wrapper around [getBasicAuthHeader] returning a single-entry
/// header map ready to merge into HTTP request headers.
Map<String, String> authHeaders() => {'Authorization': getBasicAuthHeader()};
}
+42 -72
View File
@@ -4,11 +4,9 @@ import 'dart:io';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_app_badge/flutter_app_badge.dart'; import 'package:flutter_app_badge/flutter_app_badge.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../api/cache_store.dart';
import '../api/marianumcloud/talk/share_files_to_chat.dart'; import '../api/marianumcloud/talk/share_files_to_chat.dart';
import '../background/widget_background_task.dart'; import '../background/widget_background_task.dart';
import '../notification/notification_service.dart'; import '../notification/notification_service.dart';
@@ -18,20 +16,14 @@ import '../push/push_keypair.dart';
import '../push/push_tap_router.dart'; import '../push/push_tap_router.dart';
import '../routing/app_routes.dart'; import '../routing/app_routes.dart';
import '../share_intent/share_intent_listener.dart'; import '../share_intent/share_intent_listener.dart';
import '../state/app/modules/breaker/bloc/breaker_bloc.dart';
import '../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
import '../state/app/modules/chat/bloc/chat_bloc.dart';
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
import '../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../utils/app_paths.dart'; import '../utils/app_paths.dart';
import '../utils/downloads/download_manager.dart'; import '../utils/downloads/download_manager.dart';
import '../utils/file_clipboard.dart'; import '../utils/file_clipboard.dart';
import '../widget_data/widget_sync.dart'; import '../widget_data/widget_sync.dart';
/// Removes everything the signed-out account left on the device. Every step /// Removes what an account leaves on the device outside its own storage (see
/// is isolated: one failing step used to skip all later ones and leave the /// `AccountStorage` for that). Every step is isolated: one failing step used
/// previous account's data behind. /// to skip all later ones and leave the previous account's data behind.
abstract final class SessionWipe { abstract final class SessionWipe {
static Future<void>? _running; static Future<void>? _running;
@@ -39,80 +31,31 @@ abstract final class SessionWipe {
/// wipe could clear what the next account just stored (widget job, caches). /// wipe could clear what the next account just stored (widget job, caches).
static Future<void> get done => _running ?? Future.value(); static Future<void> get done => _running ?? Future.value();
/// State that must be gone before the next frame (a login screen can be /// State that must be gone before the next frame (a login screen or another
/// reached right away): pending navigation and in-memory singletons. /// account can be reached right away): pending navigation and in-memory
/// singletons.
static void clearImmediate() { static void clearImmediate() {
_step('share intents', ShareIntentListener.instance.clearAll); _step('share intents', ShareIntentListener.instance.clearAll);
_step('share folder cache', resetTalkShareFolderCache); _step('share folder cache', resetTalkShareFolderCache);
_step('pending navigation', () { _step('pending navigation', () {
AppRoutes.pendingChatToken.value = null; AppRoutes.pendingChatToken.value = null;
PushTapRouter.pendingChatToken.value = null; PushTapRouter.pendingTarget.value = null;
PushTapRouter.pendingNewsletterId.value = null;
}); });
_step('file clipboard', FileClipboard.instance.clear); _step('file clipboard', FileClipboard.instance.clear);
_step('downloads', DownloadManager.instance.clearAll); _step('downloads', DownloadManager.instance.clearAll);
} }
static Future<void> run({ /// Runs whenever the active account stops being active (sign-out or
required TimetableBloc timetableBloc, /// switch). The tray and its bookkeeping belong to that account — a tap or
required ChatListBloc chatListBloc, /// inline reply there would otherwise act as the next one. A new keypair
required ChatBloc chatBloc, /// and FCM token make pushes still addressed to the previous registration
required BreakerBloc breakerBloc, /// undecryptable and undeliverable (an offline sign-out or switch could not
required CapabilitiesCubit capabilitiesCubit, /// unregister it).
required NextcloudCapabilitiesCubit nextcloudCapabilitiesCubit, static Future<void> accountLeft() async {
}) {
final wipe = _run(
timetableBloc: timetableBloc,
chatListBloc: chatListBloc,
chatBloc: chatBloc,
breakerBloc: breakerBloc,
capabilitiesCubit: capabilitiesCubit,
nextcloudCapabilitiesCubit: nextcloudCapabilitiesCubit,
);
_running = wipe;
return wipe.whenComplete(() {
if (identical(_running, wipe)) _running = null;
});
}
static Future<void> _run({
required TimetableBloc timetableBloc,
required ChatListBloc chatListBloc,
required ChatBloc chatBloc,
required BreakerBloc breakerBloc,
required CapabilitiesCubit capabilitiesCubit,
required NextcloudCapabilitiesCubit nextcloudCapabilitiesCubit,
}) async {
// SettingsCubit is deliberately left alone: its BlocBuilder wraps
// MaterialApp, and emitting a fresh state here tore down the freshly
// mounted Login tree (blank screen until the next interaction).
await Future.wait([
_stepAsync('timetable', timetableBloc.reset),
_stepAsync('chat list', chatListBloc.reset),
_stepAsync('chat', chatBloc.reset),
_stepAsync('breakers', breakerBloc.reset),
_stepAsync('capabilities', capabilitiesCubit.reset),
_stepAsync('nc capabilities', nextcloudCapabilitiesCubit.reset),
]);
await _stepAsync('shared preferences', () async {
await (await SharedPreferences.getInstance()).clear();
});
await _stepAsync('hydrated storage', HydratedBloc.storage.clear);
await _stepAsync('request cache', CacheStore.instance.clear);
await _stepAsync('chat background', () async {
final image = File(AppPaths.chatBackgroundImage);
if (image.existsSync()) await image.delete();
});
await _stepAsync('download files', () async { await _stepAsync('download files', () async {
final dir = Directory('${(await getTemporaryDirectory()).path}/downloads'); final dir = Directory('${(await getTemporaryDirectory()).path}/downloads');
if (dir.existsSync()) await dir.delete(recursive: true); if (dir.existsSync()) await dir.delete(recursive: true);
}); });
// Push: the tray and its bookkeeping belong to the previous account — a
// tap or inline reply there would otherwise act as the next one. A new
// keypair and FCM token make pushes still addressed to the previous
// registration undecryptable and undeliverable (an offline sign-out
// could not unregister it).
await _stepAsync( await _stepAsync(
'notification tray', 'notification tray',
NotificationService().flutterLocalNotificationsPlugin.cancelAll, NotificationService().flutterLocalNotificationsPlugin.cancelAll,
@@ -121,8 +64,35 @@ abstract final class SessionWipe {
await _stepAsync('push nid store', NidStore().clear); await _stepAsync('push nid store', NidStore().clear);
await _stepAsync('push thread store', ChatThreadStore().clearAll); await _stepAsync('push thread store', ChatThreadStore().clearAll);
await _stepAsync('push keypair', const PushKeypair().clear); await _stepAsync('push keypair', const PushKeypair().clear);
await _stepAsync('fcm token', FirebaseMessaging.instance.deleteToken); // Bounded: the next account waits for this, also without a network.
await _stepAsync(
'fcm token',
() => FirebaseMessaging.instance.deleteToken().timeout(
const Duration(seconds: 5),
),
);
}
/// Wipes what outlives accounts, once the last one signed out.
static Future<void> deviceLeft() {
final wipe = _deviceLeft();
_running = wipe;
return wipe.whenComplete(() {
if (identical(_running, wipe)) _running = null;
});
}
static Future<void> _deviceLeft() async {
// SettingsCubit is deliberately left alone: its selector wraps
// MaterialApp, and emitting a fresh state here tore down the freshly
// mounted Login tree (blank screen until the next interaction).
await _stepAsync('shared preferences', () async {
await (await SharedPreferences.getInstance()).clear();
});
await _stepAsync('chat background', () async {
final image = File(AppPaths.chatBackgroundImage);
if (image.existsSync()) await image.delete();
});
// Stop the periodic widget refresh job so the background isolate doesn't // Stop the periodic widget refresh job so the background isolate doesn't
// wake up every 30 minutes only to write `loggedIn=false`. Re-registers // wake up every 30 minutes only to write `loggedIn=false`. Re-registers
// on the next successful login. // on the next successful login.
+6 -22
View File
@@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../push/push_message_handler.dart'; import '../push/push_message_handler.dart';
import '../routing/app_routes.dart'; import '../push/push_target.dart';
import '../state/app/modules/chat/bloc/chat_bloc.dart'; import '../state/app/modules/chat/bloc/chat_bloc.dart';
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../utils/debouncer.dart'; import '../utils/debouncer.dart';
@@ -51,18 +51,15 @@ class NotificationController {
RemoteMessage message, RemoteMessage message,
BuildContext context, BuildContext context,
) async { ) async {
final newsletterId = _extractNewsletterId(message); final target = resolvePushTarget(message.data);
if (newsletterId != null) { if (target != null) {
AppRoutes.openNewsletterById( NotificationTasks.openPushTarget(
context, context,
id: newsletterId, target,
title: message.notification?.title, title: message.notification?.title,
); );
} else { } else {
NotificationTasks.navigateToTalk( NotificationTasks.navigateToTalk(context);
context,
chatToken: _extractChatToken(message),
);
} }
NotificationTasks.updateProviders(context); NotificationTasks.updateProviders(context);
unawaited(NotificationTasks.refreshBadge()); unawaited(NotificationTasks.refreshBadge());
@@ -77,17 +74,4 @@ class NotificationController {
); );
}); });
} }
static String? _extractChatToken(RemoteMessage message) {
for (final key in const ['chatToken', 'token', 'roomToken']) {
final value = message.data[key];
if (value is String && value.isNotEmpty) return value;
}
return null;
}
static String? _extractNewsletterId(RemoteMessage message) {
final value = message.data['newsletterId'];
return value is String && value.isNotEmpty ? value : null;
}
} }
+43
View File
@@ -7,8 +7,13 @@ import 'package:flutter_app_badge/flutter_app_badge.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../push/chat_thread_store.dart'; import '../push/chat_thread_store.dart';
import '../push/push_renderer.dart';
import '../push/push_target.dart';
import '../routing/app_routes.dart'; import '../routing/app_routes.dart';
import '../session/session_manager.dart';
import '../state/app/modules/app_modules.dart';
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../state/app/modules/parent_letters/bloc/parent_letters_bloc.dart';
import 'notification_service.dart'; import 'notification_service.dart';
class NotificationTasks { class NotificationTasks {
@@ -70,12 +75,50 @@ class NotificationTasks {
/// even if the user has already left. /// even if the user has already left.
static void updateProviders(BuildContext context) { static void updateProviders(BuildContext context) {
context.read<ChatListBloc>().refresh(); context.read<ChatListBloc>().refresh();
context.read<ParentLettersBloc>().refresh(silent: true);
}
/// [title] is the notification title, when the tap came with one.
static void openPushTarget(
BuildContext context,
PushTarget target, {
String? title,
}) {
switch (target) {
case ParentLetterTarget(:final letterId):
if (AppModule.isAvailableFor(
Modules.parentLetters,
SessionManager().current,
)) {
AppRoutes.openParentLetter(context, id: letterId);
}
case NewsletterTarget(:final newsletterId):
AppRoutes.openNewsletterById(context, id: newsletterId, title: title);
case ChatTarget(:final chatToken):
navigateToTalk(context, chatToken: chatToken);
}
}
/// Removes the letter's tray notification (Android; iOS alerts are posted by
/// the system and cannot be addressed by id).
static Future<void> clearParentLetterNotification(String letterId) async {
try {
await NotificationService().flutterLocalNotificationsPlugin.cancel(
id: PushRenderer.parentLetterNotificationId(letterId),
);
await refreshBadge();
} on Object catch (e) {
log('Parent letter notification cleanup failed: $e');
}
} }
/// Switches to the Talk tab. If [chatToken] is provided, also schedules /// Switches to the Talk tab. If [chatToken] is provided, also schedules
/// the matching chat to be opened automatically once the chat list view /// the matching chat to be opened automatically once the chat list view
/// resolves the token (handled inside [ChatList]). /// resolves the token (handled inside [ChatList]).
static void navigateToTalk(BuildContext context, {String? chatToken}) { static void navigateToTalk(BuildContext context, {String? chatToken}) {
if (!AppModule.isAvailableFor(Modules.talk, SessionManager().current)) {
return;
}
if (chatToken != null && chatToken.isNotEmpty) { if (chatToken != null && chatToken.isNotEmpty) {
AppRoutes.openChatByToken(context, chatToken); AppRoutes.openChatByToken(context, chatToken);
} else { } else {
+71
View File
@@ -0,0 +1,71 @@
import 'dart:developer';
import 'package:firebase_messaging/firebase_messaging.dart';
import '../api/marianumconnect/queries/push_device_register/push_device_register.dart';
import '../api/marianumconnect/queries/push_device_unregister/push_device_unregister.dart';
import '../utils/random_id.dart';
import 'push_device_info.dart';
import 'push_secure_storage.dart';
/// Push registration for accounts without Nextcloud (guardians): the device
/// registers straight with MarianumConnect and only receives its direct
/// pushes (newsletter, widget refresh, later guardian messages). Nextcloud
/// normally supplies the device identifier; here a random one is kept per
/// install.
class DirectPushRegistration {
static const String registrationType = 'direct';
static const _deviceIdentifierKey = 'push_direct_device_identifier';
final FlutterSecureStorageLike _storage;
const DirectPushRegistration({
FlutterSecureStorageLike storage = const PushSecureStorage(),
}) : _storage = storage;
Future<bool> register() async {
try {
final (fcmToken, appVersion, identifier) = await (
FirebaseMessaging.instance.getToken(),
pushAppVersion(),
deviceIdentifier(),
).wait;
if (fcmToken == null || fcmToken.isEmpty) {
log('Push (direct): no FCM token, skipping registration');
return false;
}
await PushDeviceRegister().run(
deviceIdentifier: identifier,
pushToken: fcmToken,
platform: pushPlatform,
registrationType: registrationType,
appVersion: appVersion,
);
return true;
} on Object catch (e) {
log('Push (direct): registration failed: $e');
return false;
}
}
Future<void> unregister() async {
final identifier = await _storage.read(key: _deviceIdentifierKey);
if (identifier == null) return;
try {
await PushDeviceUnregister().run(deviceIdentifier: identifier);
} on Object catch (e) {
log('Push (direct): unregister failed: $e');
}
await _storage.delete(key: _deviceIdentifierKey);
}
/// Stable per install until [unregister], so re-registrations upsert the
/// same server row instead of piling up devices.
Future<String> deviceIdentifier() async {
final stored = await _storage.read(key: _deviceIdentifierKey);
if (stored != null && stored.isNotEmpty) return stored;
final fresh = randomHexId();
await _storage.write(key: _deviceIdentifierKey, value: fresh);
return fresh;
}
}
@@ -0,0 +1,195 @@
import 'dart:async';
import 'dart:developer';
import 'package:app_settings/app_settings.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../session/session_manager.dart';
import '../state/app/modules/app_modules.dart';
import '../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
import '../state/app/modules/settings/bloc/settings_cubit.dart';
import '../storage/notification_settings.dart';
import '../widget/confirm_dialog.dart';
import 'push_registration.dart';
const _talkExplanation =
'Damit du keine neuen Nachrichten im Talk verpasst, fragen wir dich '
'gleich nach der Erlaubnis für Benachrichtigungen. Bitte akzeptiere '
'sie, um Push-Nachrichten zu erhalten.';
const _talkDeclinedNote =
'Ohne die Berechtigung erhältst du keine Benachrichtigungen bei neuen '
'Talk-Nachrichten. Du kannst sie jederzeit in den Systemeinstellungen '
'deines Geräts nachträglich aktivieren.';
const _parentLetterExplanation =
'Damit du keine Elternbriefe der Schule verpasst, fragen wir dich gleich '
'nach der Erlaubnis für Benachrichtigungen. Bitte akzeptiere sie, um '
'Push-Nachrichten zu erhalten.';
const _parentLetterDeclinedNote =
'Ohne die Berechtigung erhältst du keine Benachrichtigungen bei neuen '
'Elternbriefen. Du kannst sie jederzeit in den Systemeinstellungen deines '
'Geräts nachträglich aktivieren.';
/// The occasions with a guided notification-permission flow. Each runs at
/// most once per install, guarded by its own flag in [NotificationSettings].
enum _PermissionPrompt {
talkVisit(_talkExplanation, _talkDeclinedNote),
guardianLogin(
_parentLetterExplanation,
_parentLetterDeclinedNote,
module: Modules.parentLetters,
spentOnDisplay: true,
),
parentLettersVisit(
_parentLetterExplanation,
_parentLetterDeclinedNote,
module: Modules.parentLetters,
);
const _PermissionPrompt(
this.explanation,
this.declinedNote, {
this.module,
this.spentOnDisplay = false,
});
final String explanation;
final String declinedNote;
/// Only sessions that have this module are asked.
final Modules? module;
/// Spends the one-shot as soon as the explanation is displayed, so a
/// dismissed dialog does not come back on every app start.
final bool spentOnDisplay;
bool wasShown(NotificationSettings settings) => switch (this) {
talkVisit => settings.talkPermissionPromptShown,
guardianLogin => settings.guardianLoginPromptShown,
parentLettersVisit => settings.parentLettersPromptShown,
};
void markShown(NotificationSettings settings) {
switch (this) {
case talkVisit:
settings.talkPermissionPromptShown = true;
case guardianLogin:
settings.guardianLoginPromptShown = true;
case parentLettersVisit:
settings.parentLettersPromptShown = true;
}
}
}
/// Shows the one-time notification-permission flow on the first Talk visit.
///
/// The OS prompt is deliberately kept out of the cold-start path (younger users
/// decline it reflexively before ever seeing why they'd want it). Instead, the
/// first time Talk is opened we explain the request, then trigger the OS prompt,
/// and — if declined — offer a shortcut to the system settings.
///
/// Runs at most once per install (guarded by `talkPermissionPromptShown`).
Future<void> maybePromptTalkNotifications(BuildContext context) =>
_maybePrompt(context, _PermissionPrompt.talkVisit);
/// Accounts that receive parent letters never reach the Talk flow, so they
/// are asked once right after signing in.
Future<void> maybePromptGuardianLoginNotifications(BuildContext context) =>
_maybePrompt(context, _PermissionPrompt.guardianLogin);
/// Second chance with context: explains the request once more on the first
/// visit of the parent letters when the permission is still missing.
Future<void> maybePromptParentLetterNotifications(BuildContext context) =>
_maybePrompt(context, _PermissionPrompt.parentLettersVisit);
bool _promptInFlight = false;
Future<void> _maybePrompt(
BuildContext context,
_PermissionPrompt prompt,
) async {
final module = prompt.module;
if (module != null &&
!AppModule.isAvailableFor(module, SessionManager().current)) {
return;
}
final settings = context.read<SettingsCubit>();
final notificationSettings = settings.val().notificationSettings;
// Already handled once, or the user opted out of push entirely.
if (prompt.wasShown(notificationSettings)) return;
if (!notificationSettings.enabled) return;
// Capabilities may still be loading on a fresh cold start; retry on the next
// occasion instead of burning the one-shot flag.
if (!context.read<CapabilitiesCubit>().canReceivePushNotifications) return;
if (_promptInFlight) return;
_promptInFlight = true;
try {
// Users who already granted the permission: register silently and mark the
// prompt as handled without showing any dialog.
if (await PushRegistration.isOsPermissionGranted()) {
prompt.markShown(settings.val(write: true).notificationSettings);
unawaited(PushRegistration().register());
return;
}
if (!context.mounted) return;
if (prompt.spentOnDisplay) {
prompt.markShown(settings.val(write: true).notificationSettings);
}
// The OS prompt (and its "declined" follow-up) outlive this dialog, so
// hold on to them: releasing the guard at the dialog's close would let a
// second occasion stack another dialog over the pending OS prompt.
Future<void>? permissionRequest;
await showDialog<void>(
context: context,
builder: ConfirmDialog(
icon: Icons.notifications_active_outlined,
title: 'Benachrichtigungen aktivieren',
content: prompt.explanation,
confirmButton: 'Weiter',
cancelButton: null,
onConfirm: () =>
permissionRequest = _requestPermission(context, settings, prompt),
).build,
);
await permissionRequest;
} finally {
_promptInFlight = false;
}
}
Future<void> _requestPermission(
BuildContext context,
SettingsCubit settings,
_PermissionPrompt prompt,
) async {
final granted = await PushRegistration.requestOsPermission();
// Mark handled regardless of the outcome — the user can re-enable later via
// the system settings; we don't want to prompt again on the next occasion.
prompt.markShown(settings.val(write: true).notificationSettings);
if (granted) {
unawaited(PushRegistration().register());
return;
}
log('Push: notification permission declined on ${prompt.name}');
if (!context.mounted) return;
ConfirmDialog(
icon: Icons.notifications_off_outlined,
title: 'Benachrichtigungen deaktiviert',
content: prompt.declinedNote,
confirmButton: 'Einstellungen öffnen',
cancelButton: 'Später',
onConfirm: () =>
AppSettings.openAppSettings(type: AppSettingsType.notification),
).asDialog(context);
}
+12 -8
View File
@@ -8,8 +8,8 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../api/marianumcloud/nextcloud_ocs.dart'; import '../api/marianumcloud/nextcloud_ocs.dart';
import '../model/account_data.dart';
import '../notification/notification_service.dart'; import '../notification/notification_service.dart';
import '../session/session_manager.dart';
import 'chat_thread_store.dart'; import 'chat_thread_store.dart';
import 'nid_store.dart'; import 'nid_store.dart';
import 'push_renderer.dart'; import 'push_renderer.dart';
@@ -38,7 +38,7 @@ void _plog(String message) {
/// Handles Talk notification actions (inline reply, mark-as-read). Runs in the /// Handles Talk notification actions (inline reply, mark-as-read). Runs in the
/// background isolate spawned by flutter_local_notifications, so it may not /// background isolate spawned by flutter_local_notifications, so it may not
/// share any app state — it reads credentials straight from secure storage via /// share any app state — it reads credentials straight from secure storage via
/// the [AccountData] singleton after awaiting population. /// the [SessionManager] singleton after awaiting the stored session.
/// ///
/// The class-level `vm:entry-point` pragma is REQUIRED in addition to the one /// The class-level `vm:entry-point` pragma is REQUIRED in addition to the one
/// on [handleBackgroundResponse]: the callback is resolved via /// on [handleBackgroundResponse]: the callback is resolved via
@@ -56,14 +56,14 @@ class PushActions {
) async { ) async {
// The FLN action isolate starts WITHOUT main(): unlike the FCM background // The FLN action isolate starts WITHOUT main(): unlike the FCM background
// isolate, plugins are not registered automatically there. Without this, // isolate, plugins are not registered automatically there. Without this,
// AccountData's secure-storage/prefs reads throw or never complete → no // The session's secure-storage/prefs reads throw or never complete → no
// auth header, the Talk POST never happens and the RemoteInput spinner // auth header, the Talk POST never happens and the RemoteInput spinner
// runs forever. // runs forever.
DartPluginRegistrant.ensureInitialized(); DartPluginRegistrant.ensureInitialized();
// The action engine lives as long as the process: without a reload a // The action engine lives as long as the process: without a reload a
// reply would be sent with the account that was signed in when the // reply would be sent with the account that was signed in when the
// engine first started. // engine first started.
await AccountData().reloadFromStorage(); await SessionManager().reloadFromStorage();
_plog( _plog(
'action=${response.actionId} payload=${response.payload} ' 'action=${response.actionId} payload=${response.payload} '
@@ -129,7 +129,8 @@ class PushActions {
/// any) followed by the technical reason. /// any) followed by the technical reason.
static String actionFailureBody({String? lostText, required String detail}) { static String actionFailureBody({String? lostText, required String detail}) {
return [ return [
if (lostText != null && lostText.isNotEmpty) 'Deine Nachricht: „$lostText“', if (lostText != null && lostText.isNotEmpty)
'Deine Nachricht: „$lostText“',
'Grund: $detail', 'Grund: $detail',
].join('\n'); ].join('\n');
} }
@@ -192,7 +193,10 @@ class PushActions {
static Future<({bool ok, String detail})> sendReply( static Future<({bool ok, String detail})> sendReply(
String chatToken, String chatToken,
String message, String message,
) => _ocsPost('apps/spreed/api/v1/chat/$chatToken', body: {'message': message}); ) => _ocsPost(
'apps/spreed/api/v1/chat/$chatToken',
body: {'message': message},
);
static Future<({bool ok, String detail})> markRead(String chatToken) => static Future<({bool ok, String detail})> markRead(String chatToken) =>
_ocsPost('apps/spreed/api/v1/chat/$chatToken/read'); _ocsPost('apps/spreed/api/v1/chat/$chatToken/read');
@@ -204,10 +208,10 @@ class PushActions {
try { try {
// Bounded: a hanging population (e.g. keystore issue) must fail the // Bounded: a hanging population (e.g. keystore issue) must fail the
// action instead of leaving the notification spinner running forever. // action instead of leaving the notification spinner running forever.
final populated = await AccountData().waitForPopulation().timeout( final session = await SessionManager().waitForLoad().timeout(
const Duration(seconds: 10), const Duration(seconds: 10),
); );
if (!populated) { if (session?.nextcloud == null) {
_plog('Push action $path aborted: credentials unreadable in isolate'); _plog('Push action $path aborted: credentials unreadable in isolate');
return ( return (
ok: false, ok: false,
+15
View File
@@ -0,0 +1,15 @@
import 'dart:io';
import 'package:package_info_plus/package_info_plus.dart';
/// Platform value MarianumConnect expects in push registrations.
String get pushPlatform => Platform.isIOS ? 'ios' : 'android';
/// App version sent along with push registrations; null when unavailable.
Future<String?> pushAppVersion() async {
try {
return (await PackageInfo.fromPlatform()).version;
} on Object {
return null;
}
}
+2 -2
View File
@@ -4,8 +4,8 @@ import 'package:crypton/crypton.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import '../background/widget_background_task.dart'; import '../background/widget_background_task.dart';
import '../model/account_data.dart';
import '../notification/notification_service.dart'; import '../notification/notification_service.dart';
import '../session/session_manager.dart';
import 'chat_thread_store.dart'; import 'chat_thread_store.dart';
import 'nid_store.dart'; import 'nid_store.dart';
import 'push_decryptor.dart'; import 'push_decryptor.dart';
@@ -56,7 +56,7 @@ PushKind classifyPush(Map<String, dynamic> data) {
@pragma('vm:entry-point') @pragma('vm:entry-point')
Future<void> pushOnBackgroundMessage(RemoteMessage message) async { Future<void> pushOnBackgroundMessage(RemoteMessage message) async {
// This engine outlives sign-outs and logins in the app. // This engine outlives sign-outs and logins in the app.
await AccountData().reloadFromStorage(); await SessionManager().reloadFromStorage();
await NotificationService().initializeNotifications(); await NotificationService().initializeNotifications();
await PushRenderer.ensureChannels(); await PushRenderer.ensureChannels();
await PushMessageHandler().handle(message); await PushMessageHandler().handle(message);
+48 -30
View File
@@ -3,7 +3,6 @@ import 'dart:io';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:nextcloud/notifications.dart' show generatePushTokenHash; import 'package:nextcloud/notifications.dart' show generatePushTokenHash;
import 'package:package_info_plus/package_info_plus.dart';
import '../api/demo/demo_mode.dart'; import '../api/demo/demo_mode.dart';
import '../api/marianumcloud/app_password/delete_app_password.dart'; import '../api/marianumcloud/app_password/delete_app_password.dart';
@@ -11,9 +10,12 @@ import '../api/marianumcloud/app_password/get_app_password.dart';
import '../api/marianumconnect/marianumconnect_endpoint.dart'; import '../api/marianumconnect/marianumconnect_endpoint.dart';
import '../api/marianumconnect/queries/push_device_register/push_device_register.dart'; import '../api/marianumconnect/queries/push_device_register/push_device_register.dart';
import '../api/marianumconnect/queries/push_device_unregister/push_device_unregister.dart'; import '../api/marianumconnect/queries/push_device_unregister/push_device_unregister.dart';
import '../model/account_data.dart';
import '../model/endpoint_data.dart'; import '../model/endpoint_data.dart';
import '../session/nextcloud_credentials.dart';
import '../session/session_manager.dart';
import 'direct_push_registration.dart';
import 'nextcloud_push_api.dart'; import 'nextcloud_push_api.dart';
import 'push_device_info.dart';
import 'push_keypair.dart'; import 'push_keypair.dart';
import 'push_registration_store.dart'; import 'push_registration_store.dart';
import 'push_registration_type.dart'; import 'push_registration_type.dart';
@@ -48,8 +50,6 @@ class PushRegistration {
_store = store ?? const PushRegistrationStore(), _store = store ?? const PushRegistrationStore(),
_nextcloud = nextcloud ?? NextcloudPushApi(); _nextcloud = nextcloud ?? NextcloudPushApi();
String get _platform => Platform.isIOS ? 'ios' : 'android';
String get _talkUserAgent => String get _talkUserAgent =>
Platform.isIOS ? talkUserAgentIos : talkUserAgentAndroid; Platform.isIOS ? talkUserAgentIos : talkUserAgentAndroid;
@@ -63,20 +63,29 @@ class PushRegistration {
/// slash) — persisted alongside the registration to detect endpoint changes. /// slash) — persisted alongside the registration to detect endpoint changes.
String get currentNcBaseUrl => 'https://${EndpointData().nextcloud().full()}'; String get currentNcBaseUrl => 'https://${EndpointData().nextcloud().full()}';
NextcloudCredentials? get _nextcloudOrNull =>
SessionManager().current?.nextcloud;
/// Channel for sessions without Nextcloud; see [DirectPushRegistration].
static const _direct = DirectPushRegistration();
/// Ensures the Nextcloud app password exists (idempotent, best-effort). Push /// Ensures the Nextcloud app password exists (idempotent, best-effort). Push
/// registration binds to it, so it must be obtained before registering. /// registration binds to it, so it must be obtained before registering.
Future<void> ensureAppPassword() async { Future<void> ensureAppPassword() async {
if (AccountData().hasAppPassword()) return; final nextcloud = _nextcloudOrNull;
if (AccountData().usesLoginFlow) { if (nextcloud == null || nextcloud.hasAppPassword) return;
if (nextcloud.usesLoginFlow) {
// Flow-Konten (2FA): Basic Auth mit dem echten Passwort wird abgelehnt, // Flow-Konten (2FA): Basic Auth mit dem echten Passwort wird abgelehnt,
// stilles Minting ist unmöglich. Reparatur nur interaktiv über // stilles Minting ist unmöglich. Reparatur nur interaktiv über
// Einstellungen → „Nextcloud neu verbinden". // Einstellungen → „Nextcloud neu verbinden".
log('Push: login-flow account without app password, cannot mint silently'); log(
'Push: login-flow account without app password, cannot mint silently',
);
return; return;
} }
try { try {
final appPassword = await GetAppPassword().run(); final appPassword = await GetAppPassword().run();
await AccountData().setAppPassword(appPassword); await SessionManager().setAppPassword(appPassword);
} on Object catch (e) { } on Object catch (e) {
log('Push: could not obtain app password (non-blocking): $e'); log('Push: could not obtain app password (non-blocking): $e');
} }
@@ -85,15 +94,16 @@ class PushRegistration {
/// Ensures the second app password backing the Talk registration exists /// Ensures the second app password backing the Talk registration exists
/// (each `getapppassword` call with the real password mints a fresh one). /// (each `getapppassword` call with the real password mints a fresh one).
Future<void> ensureTalkAppPassword() async { Future<void> ensureTalkAppPassword() async {
if (AccountData().hasAppPasswordTalk()) return; final nextcloud = _nextcloudOrNull;
if (nextcloud == null || nextcloud.hasAppPasswordTalk) return;
// Flow-Konten können still kein zweites App-Passwort münzen — das // Flow-Konten können still kein zweites App-Passwort münzen — das
// Talk-Passwort kommt nur aus dem zweiten Login-Flow-Durchlauf; bis dahin // Talk-Passwort kommt nur aus dem zweiten Login-Flow-Durchlauf; bis dahin
// teilt sich die Talk-Registrierung das eine App-Passwort (siehe // teilt sich die Talk-Registrierung das eine App-Passwort (siehe
// AccountData.getTalkBasicAuthHeader). // NextcloudCredentials.talkBasicAuthHeader).
if (AccountData().usesLoginFlow) return; if (nextcloud.usesLoginFlow) return;
try { try {
final appPassword = await GetAppPassword().run(); final appPassword = await GetAppPassword().run();
await AccountData().setAppPasswordTalk(appPassword); await SessionManager().setAppPasswordTalk(appPassword);
} on Object catch (e) { } on Object catch (e) {
log('Push: could not obtain talk app password (non-blocking): $e'); log('Push: could not obtain talk app password (non-blocking): $e');
} }
@@ -107,7 +117,8 @@ class PushRegistration {
/// fire-and-forget (and simply ignore the result). /// fire-and-forget (and simply ignore the result).
Future<bool> register() async { Future<bool> register() async {
if (DemoMode.active) return false; if (DemoMode.active) return false;
final epoch = AccountData().sessionEpoch; if (_nextcloudOrNull == null) return _direct.register();
final epoch = SessionManager().sessionEpoch;
final String? fcmToken; final String? fcmToken;
try { try {
fcmToken = await FirebaseMessaging.instance.getToken(); fcmToken = await FirebaseMessaging.instance.getToken();
@@ -135,16 +146,13 @@ class PushRegistration {
return false; return false;
} }
String? appVersion; final appVersion = await pushAppVersion();
try {
appVersion = (await PackageInfo.fromPlatform()).version;
} on Object {
appVersion = null;
}
// Re-read: the ensure* calls above may have swapped the credentials.
final nextcloud = SessionManager().requireNextcloud();
final types = registrationTypesFor( final types = registrationTypesFor(
usesLoginFlow: AccountData().usesLoginFlow, usesLoginFlow: nextcloud.usesLoginFlow,
hasTalkAppPassword: AccountData().hasAppPasswordTalk(), hasTalkAppPassword: nextcloud.hasAppPasswordTalk,
); );
if (!types.contains(PushRegistrationType.general)) { if (!types.contains(PushRegistrationType.general)) {
await _recordAttempt( await _recordAttempt(
@@ -183,7 +191,7 @@ class PushRegistration {
devicePublicKeyPem: pems.publicKeyPem, devicePublicKeyPem: pems.publicKeyPem,
proxyServer: proxyServer, proxyServer: proxyServer,
authorizationHeader: isTalk authorizationHeader: isTalk
? AccountData().getTalkBasicAuthHeader() ? SessionManager().requireNextcloud().talkBasicAuthHeader
: null, : null,
userAgent: isTalk ? _talkUserAgent : null, userAgent: isTalk ? _talkUserAgent : null,
); );
@@ -191,7 +199,7 @@ class PushRegistration {
// Signed out while registering: persisting or announcing this // Signed out while registering: persisting or announcing this
// registration would keep delivering the previous account's pushes, // registration would keep delivering the previous account's pushes,
// and logoutCleanup already ran so nothing would unregister it. // and logoutCleanup already ran so nothing would unregister it.
if (!AccountData().isCurrentSession(epoch)) return false; if (!SessionManager().isCurrentSession(epoch)) return false;
await _store.save( await _store.save(
type: type, type: type,
@@ -207,7 +215,7 @@ class PushRegistration {
deviceIdentifierSignature: registration.signature, deviceIdentifierSignature: registration.signature,
userPublicKey: registration.publicKey, userPublicKey: registration.publicKey,
pushToken: fcmToken, pushToken: fcmToken,
platform: _platform, platform: pushPlatform,
registrationType: type.wireName, registrationType: type.wireName,
appVersion: appVersion, appVersion: appVersion,
); );
@@ -256,7 +264,7 @@ class PushRegistration {
try { try {
final endpoint = EndpointData().nextcloud(); final endpoint = EndpointData().nextcloud();
await _store.saveNativeAuthContext( await _store.saveNativeAuthContext(
username: AccountData().getUsername(), username: SessionManager().requireNextcloud().username,
baseUrl: 'https://${endpoint.full()}', baseUrl: 'https://${endpoint.full()}',
); );
} on Object catch (e) { } on Object catch (e) {
@@ -274,7 +282,7 @@ class PushRegistration {
// session token — each registration with its own app password. // session token — each registration with its own app password.
await _nextcloud.unregister( await _nextcloud.unregister(
authorizationHeader: type == PushRegistrationType.talk authorizationHeader: type == PushRegistrationType.talk
? AccountData().getTalkBasicAuthHeader() ? SessionManager().requireNextcloud().talkBasicAuthHeader
: null, : null,
); );
} on Object catch (e) { } on Object catch (e) {
@@ -423,12 +431,21 @@ class PushRegistration {
/// first, then the proxy) with the new token. /// first, then the proxy) with the new token.
Future<void> onTokenRefresh() => register(); Future<void> onTokenRefresh() => register();
/// Stops pushes for the active account before another one takes over. The
/// app passwords stay valid so switching back can re-register silently.
Future<void> deactivate() async {
if (DemoMode.active) return;
if (_nextcloudOrNull == null) return _direct.unregister();
await unregister();
}
/// Full teardown for logout: unregister push, revoke BOTH app passwords /// Full teardown for logout: unregister push, revoke BOTH app passwords
/// (each authenticated with itself — the endpoint revokes the credential it /// (each authenticated with itself — the endpoint revokes the credential it
/// is called with), then clear them locally. Ordered so the proxy stops /// is called with), then clear them locally. Ordered so the proxy stops
/// pushing before credentials are gone. /// pushing before credentials are gone.
Future<void> logoutCleanup() async { Future<void> logoutCleanup() async {
if (DemoMode.active) return; if (DemoMode.active) return;
if (_nextcloudOrNull == null) return _direct.unregister();
await unregister(); await unregister();
try { try {
await DeleteAppPassword().run(); await DeleteAppPassword().run();
@@ -436,15 +453,16 @@ class PushRegistration {
log('Push: delete app password failed: $e'); log('Push: delete app password failed: $e');
} }
try { try {
if (AccountData().hasAppPasswordTalk()) { final nextcloud = SessionManager().requireNextcloud();
if (nextcloud.hasAppPasswordTalk) {
await DeleteAppPassword().run( await DeleteAppPassword().run(
authorizationHeader: AccountData().getTalkBasicAuthHeader(), authorizationHeader: nextcloud.talkBasicAuthHeader,
); );
} }
} on Object catch (e) { } on Object catch (e) {
log('Push: delete talk app password failed: $e'); log('Push: delete talk app password failed: $e');
} }
await AccountData().clearAppPassword(); await SessionManager().clearAppPassword();
await AccountData().clearAppPasswordTalk(); await SessionManager().clearAppPasswordTalk();
} }
} }
+1 -1
View File
@@ -24,7 +24,7 @@ class PushRegistrationStore {
// (reply / mark-as-read) directly via URLSession while the Flutter engine is // (reply / mark-as-read) directly via URLSession while the Flutter engine is
// not guaranteed to run. It needs the Nextcloud username and base URL from the // not guaranteed to run. It needs the Nextcloud username and base URL from the
// shared (group-scoped) keychain; the app password already lives there // shared (group-scoped) keychain; the app password already lives there
// (AccountData writes `nextcloud_app_password` group-scoped). // (SessionManager writes `nextcloud_app_password` group-scoped).
static const _usernameKey = 'nextcloud_username'; static const _usernameKey = 'nextcloud_username';
static const _baseUrlKey = 'nextcloud_base_url'; static const _baseUrlKey = 'nextcloud_base_url';
// Mirror of the in-app notification toggle (`notificationSettings.enabled`), // Mirror of the in-app notification toggle (`notificationSettings.enabled`),
+28 -5
View File
@@ -12,6 +12,7 @@ import 'nid_store.dart';
import 'push_actions.dart'; import 'push_actions.dart';
import 'push_avatar.dart'; import 'push_avatar.dart';
import 'push_subject.dart'; import 'push_subject.dart';
import 'push_target.dart';
/// Renders decrypted push subjects (and plaintext Connect pushes) as local /// Renders decrypted push subjects (and plaintext Connect pushes) as local
/// notifications. Talk messages of one chat stack into a SINGLE /// notifications. Talk messages of one chat stack into a SINGLE
@@ -23,6 +24,8 @@ class PushRenderer {
static const talkChannelName = 'Talk-Nachrichten'; static const talkChannelName = 'Talk-Nachrichten';
static const generalChannelId = 'nextcloud_general'; static const generalChannelId = 'nextcloud_general';
static const generalChannelName = 'Benachrichtigungen'; static const generalChannelName = 'Benachrichtigungen';
static const parentLettersChannelId = 'parent_letters';
static const parentLettersChannelName = 'Elternbriefe';
static const String iosTalkCategory = 'TALK_MESSAGE'; static const String iosTalkCategory = 'TALK_MESSAGE';
@@ -67,6 +70,14 @@ class PushRenderer {
description: 'Allgemeine Benachrichtigungen', description: 'Allgemeine Benachrichtigungen',
), ),
); );
await android.createNotificationChannel(
const AndroidNotificationChannel(
parentLettersChannelId,
parentLettersChannelName,
description: 'Neue Elternbriefe und Antworten der Schule',
importance: Importance.high,
),
);
} }
/// Renders a decrypted Nextcloud push subject. /// Renders a decrypted Nextcloud push subject.
@@ -350,23 +361,35 @@ class PushRenderer {
required String body, required String body,
Map<String, String>? data, Map<String, String>? data,
}) async { }) async {
final id = _fallbackId('$title$body'); final parentLetterId = data?[parentLetterIdKey];
const androidDetails = AndroidNotificationDetails( final isParentLetter =
generalChannelId, data?['type'] == parentLetterPushType &&
generalChannelName, parentLetterId != null &&
parentLetterId.isNotEmpty;
// One notification per letter: a reply replaces the letter's earlier one.
final id = isParentLetter
? parentLetterNotificationId(parentLetterId)
: _fallbackId('$title$body');
final androidDetails = AndroidNotificationDetails(
isParentLetter ? parentLettersChannelId : generalChannelId,
isParentLetter ? parentLettersChannelName : generalChannelName,
importance: Importance.high, importance: Importance.high,
priority: Priority.high, priority: Priority.high,
color: _accentColor, color: _accentColor,
styleInformation: isParentLetter ? BigTextStyleInformation(body) : null,
); );
await _plugin.show( await _plugin.show(
id: id, id: id,
title: title, title: title,
body: body, body: body,
notificationDetails: const NotificationDetails(android: androidDetails), notificationDetails: NotificationDetails(android: androidDetails),
payload: data == null ? null : jsonEncode(data), payload: data == null ? null : jsonEncode(data),
); );
} }
static int parentLetterNotificationId(String letterId) =>
stableChatNotificationId('parent-letter:$letterId');
String _payload({required String? chatToken, required int nid}) => String _payload({required String? chatToken, required int nid}) =>
jsonEncode({'chatToken': ?chatToken, 'nid': nid}); jsonEncode({'chatToken': ?chatToken, 'nid': nid});
+1 -1
View File
@@ -24,7 +24,7 @@ const IOSOptions kPushIosOptions = IOSOptions(
); );
/// Shared secure storage instance for all push key material and registration /// Shared secure storage instance for all push key material and registration
/// bookkeeping. Kept separate from [AccountData]'s default storage because the /// bookkeeping. Kept separate from the session's default storage because the
/// entries here are group-scoped for NSE access. /// entries here are group-scoped for NSE access.
const FlutterSecureStorage pushSecureStorage = FlutterSecureStorage( const FlutterSecureStorage pushSecureStorage = FlutterSecureStorage(
iOptions: kPushIosOptions, iOptions: kPushIosOptions,
+4 -3
View File
@@ -1,6 +1,6 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../model/account_data.dart'; import '../session/session_manager.dart';
import 'push_keypair.dart'; import 'push_keypair.dart';
import 'push_registration.dart'; import 'push_registration.dart';
import 'push_registration_store.dart'; import 'push_registration_store.dart';
@@ -124,14 +124,15 @@ Future<PushStatusReport> collectPushStatus({
lastRegistrationError: await store.lastRegistrationError(type), lastRegistrationError: await store.lastRegistrationError(type),
); );
final nextcloud = SessionManager().current?.nextcloud;
return PushStatusReport( return PushStatusReport(
settingEnabled: settingEnabled, settingEnabled: settingEnabled,
osPermission: await _osPermission(), osPermission: await _osPermission(),
serverCapability: !capabilitiesLoaded serverCapability: !capabilitiesLoaded
? PushCheck.unknown ? PushCheck.unknown
: (capabilityPush ? PushCheck.ok : PushCheck.fail), : (capabilityPush ? PushCheck.ok : PushCheck.fail),
appPasswordPresent: AccountData().hasAppPassword(), appPasswordPresent: nextcloud?.hasAppPassword ?? false,
talkAppPasswordPresent: AccountData().hasAppPasswordTalk(), talkAppPasswordPresent: nextcloud?.hasAppPasswordTalk ?? false,
keypairPresent: (await keypair.loadPublicKeyPem())?.isNotEmpty ?? false, keypairPresent: (await keypair.loadPublicKeyPem())?.isNotEmpty ?? false,
general: await typeStatus(PushRegistrationType.general), general: await typeStatus(PushRegistrationType.general),
talk: await typeStatus(PushRegistrationType.talk), talk: await typeStatus(PushRegistrationType.talk),
+27 -19
View File
@@ -1,24 +1,22 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:developer';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'push_actions.dart'; import 'push_actions.dart';
import 'push_target.dart';
/// Routes foreground notification interactions from the single /// Routes foreground notification interactions from the single
/// flutter_local_notifications response callback. Action responses (reply / /// flutter_local_notifications response callback. Action responses (reply /
/// mark-read) are dispatched straight to [PushActions]; a plain tap publishes /// mark-read) are dispatched straight to [PushActions]; a plain tap publishes
/// the target chat token via [pendingChatToken] for [App] to navigate to. /// its target via [pendingTarget] for [App] to navigate to.
class PushTapRouter { class PushTapRouter {
PushTapRouter._(); PushTapRouter._();
/// Chat token of the most recently tapped Talk notification, or null. [App] /// Target of the most recently tapped notification, or null. [App] listens
/// listens to this and opens the chat, then resets it to null. /// to this and navigates, then resets it to null.
static final ValueNotifier<String?> pendingChatToken = ValueNotifier(null); static final ValueNotifier<PushTarget?> pendingTarget = ValueNotifier(null);
/// Newsletter id of the most recently tapped Marianum-Message notification,
/// or null. [App] listens to this and opens the message, then resets it.
static final ValueNotifier<String?> pendingNewsletterId = ValueNotifier(null);
static void handleResponse(NotificationResponse response) { static void handleResponse(NotificationResponse response) {
final actionId = response.actionId; final actionId = response.actionId;
@@ -29,13 +27,28 @@ class PushTapRouter {
} }
final map = _payloadMap(response.payload); final map = _payloadMap(response.payload);
if (map == null) return; if (map == null) return;
final newsletterId = _stringValue(map, 'newsletterId'); final target = resolvePushTarget(map);
if (newsletterId != null) { if (target != null) pendingTarget.value = target;
pendingNewsletterId.value = newsletterId; }
return;
static bool _launchHandled = false;
/// Routes the tap that cold-started the app. The plugin reports such a tap
/// only through its launch details, never through the response callback;
/// the details stay set for the whole process, hence the one-shot guard.
static Future<void> handleAppLaunch(
FlutterLocalNotificationsPlugin plugin,
) async {
if (_launchHandled) return;
_launchHandled = true;
try {
final details = await plugin.getNotificationAppLaunchDetails();
final response = details?.notificationResponse;
if (details?.didNotificationLaunchApp != true || response == null) return;
handleResponse(response);
} on Object catch (e) {
log('Reading the notification launch details failed: $e');
} }
final token = _stringValue(map, 'chatToken');
if (token != null) pendingChatToken.value = token;
} }
static Map<String, dynamic>? _payloadMap(String? payload) { static Map<String, dynamic>? _payloadMap(String? payload) {
@@ -46,9 +59,4 @@ class PushTapRouter {
return null; return null;
} }
} }
static String? _stringValue(Map<String, dynamic> map, String key) {
final value = map[key];
return value is String && value.isNotEmpty ? value : null;
}
} }
+45
View File
@@ -0,0 +1,45 @@
/// `type` of the visible Connect push for a parent letter. Its data carries
/// the letter under [parentLetterIdKey].
const String parentLetterPushType = 'parent-letter';
const String parentLetterIdKey = 'parentLetterId';
/// Where a tapped notification leads.
sealed class PushTarget {
const PushTarget();
}
class ParentLetterTarget extends PushTarget {
final String letterId;
const ParentLetterTarget(this.letterId);
}
class NewsletterTarget extends PushTarget {
final String newsletterId;
const NewsletterTarget(this.newsletterId);
}
class ChatTarget extends PushTarget {
final String chatToken;
const ChatTarget(this.chatToken);
}
/// Resolves the data of a tapped notification — the payload of a locally
/// rendered one as well as the data of an FCM message. Null when it names no
/// known target.
PushTarget? resolvePushTarget(Map<String, dynamic> data) {
String? value(String key) {
final value = data[key];
return value is String && value.isNotEmpty ? value : null;
}
if (value(parentLetterIdKey) case final letterId?) {
return ParentLetterTarget(letterId);
}
if (value('newsletterId') case final newsletterId?) {
return NewsletterTarget(newsletterId);
}
for (final key in const ['chatToken', 'token', 'roomToken']) {
if (value(key) case final chatToken?) return ChatTarget(chatToken);
}
return null;
}
+18 -5
View File
@@ -9,8 +9,8 @@ import '../api/marianumcloud/talk/room/get_room_response.dart';
import '../api/marianumconnect/marianumconnect_endpoint.dart'; import '../api/marianumconnect/marianumconnect_endpoint.dart';
import '../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart'; import '../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
import '../main.dart'; import '../main.dart';
import '../model/account_data.dart';
import '../notification/notification_tasks.dart'; import '../notification/notification_tasks.dart';
import '../session/session_manager.dart';
import '../share_intent/pending_share.dart'; import '../share_intent/pending_share.dart';
import '../share_intent/remote_file_ref.dart'; import '../share_intent/remote_file_ref.dart';
import '../state/app/modules/app_modules.dart'; import '../state/app/modules/app_modules.dart';
@@ -26,6 +26,7 @@ import '../view/pages/marianum_message/marianum_message_view.dart';
import '../view/pages/more/feedback/feedback_dialog.dart'; import '../view/pages/more/feedback/feedback_dialog.dart';
import '../view/pages/more/roomplan/roomplan.dart'; import '../view/pages/more/roomplan/roomplan.dart';
import '../view/pages/more/share/qr_share_view.dart'; import '../view/pages/more/share/qr_share_view.dart';
import '../view/pages/parent_letters/parent_letter_view.dart';
import '../view/pages/settings/chat_background_settings_page.dart'; import '../view/pages/settings/chat_background_settings_page.dart';
import '../view/pages/settings/diagnostics_page.dart'; import '../view/pages/settings/diagnostics_page.dart';
import '../view/pages/settings/modules_settings_page.dart'; import '../view/pages/settings/modules_settings_page.dart';
@@ -64,7 +65,9 @@ class AppRoutes {
/// Root navigator key, set on [MaterialApp]. Lets globally-mounted UI (e.g. /// Root navigator key, set on [MaterialApp]. Lets globally-mounted UI (e.g.
/// the downloads tray, which lives above the navigator in `MaterialApp.builder` /// the downloads tray, which lives above the navigator in `MaterialApp.builder`
/// and is therefore never covered by a pushed route) open full-page routes. /// and is therefore never covered by a pushed route) open full-page routes.
static final GlobalKey<NavigatorState> rootNavigatorKey = /// Replaced per account (see `Main`), so a switch starts on a fresh
/// navigator.
static GlobalKey<NavigatorState> rootNavigatorKey =
GlobalKey<NavigatorState>(); GlobalKey<NavigatorState>();
/// A context that is a descendant of the root navigator (its overlay), safe to /// A context that is a descendant of the root navigator (its overlay), safe to
@@ -206,6 +209,12 @@ class AppRoutes {
); );
} }
/// Opens a parent letter by id — from the inbox as well as from push deep
/// links, where only the id is known.
static void openParentLetter(BuildContext context, {required String id}) {
pushScreen(context, withNavBar: false, screen: ParentLetterView(id: id));
}
/// Opens a ticker page (CONTENT or PROXIED_FILE) as a standalone detail /// Opens a ticker page (CONTENT or PROXIED_FILE) as a standalone detail
/// screen. Used for deep links from outside the ticker module — inside the /// screen. Used for deep links from outside the ticker module — inside the
/// module pages open in-place instead. /// module pages open in-place instead.
@@ -395,7 +404,10 @@ class AppRoutes {
// ChatBloc._loadChat with the freshly-fetched maxId — sending one // ChatBloc._loadChat with the freshly-fetched maxId — sending one
// here too with the chat list's possibly-stale room.lastMessage.id // here too with the chat list's possibly-stale room.lastMessage.id
// would race the fresh one and could regress the server cursor. // would race the fresh one and could regress the server cursor.
context.read<ChatListBloc>().markRoomAsRead(room.token, room.lastMessage.id); context.read<ChatListBloc>().markRoomAsRead(
room.token,
room.lastMessage.id,
);
NotificationTasks.clearNotificationsForChat(room.token); NotificationTasks.clearNotificationsForChat(room.token);
TalkNavigator.pushSplitView( TalkNavigator.pushSplitView(
context, context,
@@ -425,7 +437,8 @@ class AppRoutes {
static ResolvedPendingChat? resolvePendingChat(BuildContext context) { static ResolvedPendingChat? resolvePendingChat(BuildContext context) {
final token = pendingChatToken.value; final token = pendingChatToken.value;
if (token == null) return null; if (token == null) return null;
if (!AccountData().isPopulated()) return null; final nextcloud = SessionManager().current?.nextcloud;
if (nextcloud == null) return null;
final rooms = context.read<ChatListBloc>().state.data?.rooms; final rooms = context.read<ChatListBloc>().state.data?.rooms;
final room = _findRoomByToken(rooms, token); final room = _findRoomByToken(rooms, token);
@@ -438,7 +451,7 @@ class AppRoutes {
); );
return ResolvedPendingChat( return ResolvedPendingChat(
room: room, room: room,
selfId: AccountData().getUsername(), selfId: nextcloud.username,
avatar: avatar, avatar: avatar,
); );
} }
+221
View File
@@ -0,0 +1,221 @@
import 'dart:convert';
import 'session.dart';
import 'session_codec.dart';
/// One signed-in account on this device. The session itself lives in the
/// keychain (active slots or the account's vault); this is the index entry.
class AccountEntry {
final String id;
final String kind;
/// Username or e-mail — the identity of the account.
final String label;
/// Real name as known to the server; null until it was loaded once.
final String? displayName;
final bool isDemo;
/// Storage namespace of the account's local data. Empty for the account that
/// existed before multi-account support, so its data stays where it was.
final String namespace;
final int lastUsed;
const AccountEntry({
required this.id,
required this.kind,
required this.label,
required this.namespace,
this.displayName,
this.isDemo = false,
this.lastUsed = 0,
});
bool get isGuardian => kind == SessionKeys.kindGuardian;
AccountEntry copyWith({int? lastUsed, String? displayName}) => AccountEntry(
id: id,
kind: kind,
label: label,
namespace: namespace,
displayName: displayName ?? this.displayName,
isDemo: isDemo,
lastUsed: lastUsed ?? this.lastUsed,
);
Map<String, dynamic> toJson() => {
'id': id,
'kind': kind,
'label': label,
'namespace': namespace,
'displayName': displayName,
'isDemo': isDemo,
'lastUsed': lastUsed,
};
static AccountEntry? fromJson(Object? json) {
if (json is! Map) return null;
final id = json['id'];
final kind = json['kind'];
final label = json['label'];
if (id is! String || kind is! String || label is! String) return null;
return AccountEntry(
id: id,
kind: kind,
label: label,
namespace: json['namespace'] is String ? json['namespace'] as String : id,
displayName: json['displayName'] is String
? json['displayName'] as String
: null,
isDemo: json['isDemo'] == true,
lastUsed: json['lastUsed'] is int ? json['lastUsed'] as int : 0,
);
}
}
class AccountIndex {
final List<AccountEntry> accounts;
final String? activeId;
const AccountIndex({this.accounts = const [], this.activeId});
static const empty = AccountIndex();
AccountEntry? get active => byId(activeId);
AccountEntry? byId(String? id) {
if (id == null) return null;
for (final entry in accounts) {
if (entry.id == id) return entry;
}
return null;
}
/// The entry [session] belongs to — same kind, identity and demo flag.
AccountEntry? matching(Session session) {
final (kind, label) = identityOf(session);
for (final entry in accounts) {
if (entry.kind == kind &&
entry.label == label &&
entry.isDemo == session.isDemo) {
return entry;
}
}
return null;
}
/// Makes [session] the active account, reusing its entry when it is already
/// known and otherwise adding one with [newId] (also its namespace).
AccountIndex activate(
Session session, {
required String newId,
String? namespace,
int now = 0,
}) {
final existing = matching(session);
if (existing != null) return select(existing.id, now: now);
final (kind, label) = identityOf(session);
return AccountIndex(
accounts: [
...accounts,
AccountEntry(
id: newId,
kind: kind,
label: label,
namespace: namespace ?? newId,
isDemo: session.isDemo,
lastUsed: now,
),
],
activeId: newId,
);
}
AccountIndex select(String id, {int now = 0}) => AccountIndex(
accounts: accounts
.map((e) => e.id == id ? e.copyWith(lastUsed: now) : e)
.toList(),
activeId: id,
);
AccountIndex rename(String id, String displayName) => AccountIndex(
accounts: accounts
.map((e) => e.id == id ? e.copyWith(displayName: displayName) : e)
.toList(),
activeId: activeId,
);
/// Drops [id]; the active account is left unset when it was the one removed.
AccountIndex remove(String id) => AccountIndex(
accounts: [
for (final e in accounts)
if (e.id != id) e,
],
activeId: activeId == id ? null : activeId,
);
/// Most recently used account other than [excludedId], if any.
AccountEntry? mostRecentExcept(String? excludedId) {
AccountEntry? best;
for (final entry in accounts) {
if (entry.id == excludedId) continue;
if (best == null || entry.lastUsed > best.lastUsed) best = entry;
}
return best;
}
String encode() => jsonEncode({
'activeId': activeId,
'accounts': [for (final e in accounts) e.toJson()],
});
static AccountIndex decode(String? raw) {
if (raw == null || raw.isEmpty) return empty;
try {
final json = jsonDecode(raw);
if (json is! Map) return empty;
final list = json['accounts'];
return AccountIndex(
accounts: [
if (list is List)
for (final item in list) ?AccountEntry.fromJson(item),
],
activeId: json['activeId'] is String
? json['activeId'] as String
: null,
);
} on FormatException {
return empty;
}
}
}
(String kind, String label) identityOf(Session session) => switch (session) {
CredentialSession(:final username) => (SessionKeys.kindCredential, username),
GuardianSession(:final email) => (SessionKeys.kindGuardian, email),
};
/// Keychain fields of [session] including the app passwords, as stored in an
/// inactive account's vault. Unset fields are omitted.
Map<String, String> sessionVaultFields(Session session) => {
for (final MapEntry(:key, :value) in encodeSessionFields(session).entries)
key: ?value,
SessionKeys.appPassword: ?session.nextcloud?.appPassword,
SessionKeys.appPasswordTalk: ?session.nextcloud?.appPasswordTalk,
};
String encodeVault(Map<String, String> fields) => jsonEncode(fields);
Map<String, String> decodeVault(String? raw) {
if (raw == null || raw.isEmpty) return const {};
try {
final json = jsonDecode(raw);
if (json is! Map) return const {};
return {
for (final MapEntry(:key, :value) in json.entries)
if (key is String && value is String) key: value,
};
} on FormatException {
return const {};
}
}
+77
View File
@@ -0,0 +1,77 @@
import 'dart:convert';
/// Nextcloud identity of a session. Immutable; the session manager swaps in a
/// new instance whenever an app password is minted or revoked.
class NextcloudCredentials {
final String username;
/// The real account password. Invalid against Nextcloud when
/// [usesLoginFlow] is set (2FA accounts), where only [appPassword] works.
final String password;
final String? appPassword;
/// Backs the second (apptype=talk) push registration — Nextcloud binds each
/// push subscription to its session token, so two registrations need two
/// app passwords.
final String? appPasswordTalk;
/// True when the credentials came from Login Flow v2 (browser login, e.g.
/// because the account has two-factor authentication).
final bool usesLoginFlow;
const NextcloudCredentials({
required this.username,
required this.password,
this.appPassword,
this.appPasswordTalk,
this.usesLoginFlow = false,
});
bool get hasAppPassword => appPassword != null && appPassword!.isNotEmpty;
bool get hasAppPasswordTalk =>
appPasswordTalk != null && appPasswordTalk!.isNotEmpty;
/// The app password once available (minted or flow-issued), otherwise the
/// real password. It survives real-password rotation and is what the push
/// registration is bound to.
String get secret => hasAppPassword ? appPassword! : password;
/// HTTP Basic header value. Prefer headers over credentials in URLs — error
/// logs and crash reports often capture the URL but not headers.
String get basicAuthHeader => _basicAuth(secret);
Map<String, String> get authHeaders => {'Authorization': basicAuthHeader};
/// Authenticates the apptype=talk push registration (and its unregister).
/// Throws when the talk password has not been minted yet; callers treat that
/// as a failed talk registration and retry on the next start.
String get talkBasicAuthHeader {
if (hasAppPasswordTalk) return _basicAuth(appPasswordTalk!);
// Login-flow account whose second (talk) flow pass was skipped: no silent
// minting possible, the talk registration shares the flow credential.
if (usesLoginFlow && hasAppPassword) return _basicAuth(appPassword!);
throw StateError('Talk app password not available yet');
}
/// Always the real password. Needed to mint the app password via
/// `core/getapppassword` — an app password cannot mint another.
String get realPasswordBasicAuthHeader => _basicAuth(password);
NextcloudCredentials copyWith({
String? Function()? appPassword,
String? Function()? appPasswordTalk,
bool? usesLoginFlow,
}) => NextcloudCredentials(
username: username,
password: password,
appPassword: appPassword != null ? appPassword() : this.appPassword,
appPasswordTalk: appPasswordTalk != null
? appPasswordTalk()
: this.appPasswordTalk,
usesLoginFlow: usesLoginFlow ?? this.usesLoginFlow,
);
String _basicAuth(String secret) =>
'Basic ${base64Encode(utf8.encode('$username:$secret'))}';
}
+74
View File
@@ -0,0 +1,74 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'nextcloud_credentials.dart';
/// The signed-in account. Exactly one session is active at a time; features
/// check for the backend identities they need ([nextcloud]) instead of
/// assuming every account has all of them.
sealed class Session {
/// Local demo session: every backend is served from fixtures (see DemoMode).
final bool isDemo;
const Session({this.isDemo = false});
/// Nextcloud identity, or null for accounts without one (guardians).
NextcloudCredentials? get nextcloud;
}
/// Student, teacher or staff account: username + password, backed by
/// MarianumConnect and Nextcloud (with the same username and password).
final class CredentialSession extends Session {
@override
final NextcloudCredentials nextcloud;
CredentialSession({
required String username,
required String password,
String? appPassword,
String? appPasswordTalk,
bool usesLoginFlow = false,
super.isDemo,
}) : nextcloud = NextcloudCredentials(
username: username,
password: password,
appPassword: appPassword,
appPasswordTalk: appPasswordTalk,
usesLoginFlow: usesLoginFlow,
);
const CredentialSession._(this.nextcloud, {super.isDemo});
String get username => nextcloud.username;
String get password => nextcloud.password;
CredentialSession withNextcloud(NextcloudCredentials nextcloud) =>
CredentialSession._(nextcloud, isDemo: isDemo);
/// Legacy MHSL identity (`sha512(user:pass)`), only for the one-off custom
/// events migration.
String get legacyUserSecret =>
sha512.convert(utf8.encode('$username:$password')).toString();
}
/// Parent/guardian account: passwordless e-mail login, MarianumConnect only.
final class GuardianSession extends Session {
final String email;
const GuardianSession({required this.email, super.isDemo});
@override
NextcloudCredentials? get nextcloud => null;
}
/// Thrown when a Nextcloud-only feature is reached with a session that has no
/// Nextcloud identity. Indicates a missing gate, not a user error.
class NextcloudUnavailableException implements Exception {
const NextcloudUnavailableException();
@override
String toString() =>
'NextcloudUnavailableException: session has no Nextcloud account';
}
+70
View File
@@ -0,0 +1,70 @@
import 'session.dart';
/// Keychain keys of the session. Names are frozen: installed versions and the
/// iOS AppDelegate/NSE read them, so renaming would log every user out.
abstract final class SessionKeys {
static const username = 'username';
static const password = 'password';
static const appPassword = 'nextcloud_app_password';
static const appPasswordTalk = 'nextcloud_app_password_talk';
static const loginFlow = 'nextcloud_login_flow';
static const demo = 'is_demo';
// Added with guardian accounts. Absent on installs from before — see
// [decodeSession].
static const kind = 'session_kind';
static const guardianEmail = 'guardian_email';
static const kindCredential = 'credential';
static const kindGuardian = 'guardian';
}
/// Rebuilds the session from raw keychain values. Installs from before
/// guardian accounts carry no [SessionKeys.kind]; a stored username and
/// password then mean a credential session, so existing users stay signed in.
Session? decodeSession(Map<String, String?> raw) {
final isDemo = raw[SessionKeys.demo] == 'true';
switch (raw[SessionKeys.kind]) {
case SessionKeys.kindGuardian:
final email = raw[SessionKeys.guardianEmail];
if (email == null || email.isEmpty) return null;
return GuardianSession(email: email, isDemo: isDemo);
case null:
case SessionKeys.kindCredential:
final username = raw[SessionKeys.username];
final password = raw[SessionKeys.password];
if (username == null || password == null) return null;
return CredentialSession(
username: username,
password: password,
appPassword: raw[SessionKeys.appPassword],
appPasswordTalk: raw[SessionKeys.appPasswordTalk],
usesLoginFlow: raw[SessionKeys.loginFlow] == 'true',
isDemo: isDemo,
);
default:
// Written by a newer app version; unknown here, treat as signed out.
return null;
}
}
/// Keychain values for [session], excluding the group-scoped app passwords
/// (written separately so the iOS NSE can read them). `null` = delete.
Map<String, String?> encodeSessionFields(Session session) => switch (session) {
CredentialSession() => {
SessionKeys.kind: SessionKeys.kindCredential,
SessionKeys.username: session.username,
SessionKeys.password: session.password,
SessionKeys.demo: session.isDemo ? 'true' : null,
SessionKeys.loginFlow: session.nextcloud.usesLoginFlow ? 'true' : null,
SessionKeys.guardianEmail: null,
},
GuardianSession() => {
SessionKeys.kind: SessionKeys.kindGuardian,
SessionKeys.guardianEmail: session.email,
SessionKeys.demo: session.isDemo ? 'true' : null,
SessionKeys.username: null,
SessionKeys.password: null,
SessionKeys.loginFlow: null,
},
};
+207
View File
@@ -0,0 +1,207 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import '../api/marianumcloud/app_password/delete_app_password.dart';
import '../api/marianumconnect/auth/auth_interceptor.dart';
import '../api/marianumconnect/auth/token_storage.dart';
import '../api/marianumconnect/queries/auth_logout/auth_logout.dart';
import '../api/marianumconnect/queries/auth_me/auth_me.dart';
import '../auth_link/guardian_link_listener.dart';
import '../background/widget_background_task.dart';
import '../model/session_wipe.dart';
import '../push/push_registration.dart';
import '../storage/account_storage.dart';
import '../widget_data/widget_sync.dart';
import 'session.dart';
import 'session_manager.dart';
abstract final class SessionLifecycle {
/// Why the last sign-out happened, when the user did not ask for it. The
/// login screen (or, when another account takes over, the app) shows it
/// once and clears it.
static final ValueNotifier<String?> signOutNotice = ValueNotifier(null);
/// Account to return to while "add account" runs; null otherwise.
static String? _addReturnId;
/// Ordered teardown: unregister push and revoke the Nextcloud app passwords
/// (while those credentials still exist), then revoke the MC bearer token,
/// finally wipe the local session and its data. Each step is best-effort so
/// an offline sign-out still reaches a clean local state. Another signed-in
/// account takes over when there is one; returns its id.
static Future<String?> signOut({String? notice}) async {
signOutNotice.value = notice;
try {
await PushRegistration().logoutCleanup();
} on Object catch (e) {
log('Sign-out: push cleanup failed: $e');
}
await AuthLogout().run();
final removed = await SessionManager().signOut();
SessionWipe.clearImmediate();
// A login link that arrived while signed in must not be replayed on the
// login screen that follows.
GuardianLinkListener.clear();
await SessionWipe.accountLeft();
if (removed != null) await AccountStorage.delete(removed.namespace);
for (
var next = SessionManager().accounts.value.mostRecentExcept(null);
next != null;
next = SessionManager().accounts.value.mostRecentExcept(null)
) {
try {
await SessionManager().activate(next.id);
break;
} on Object catch (e) {
log('Sign-out: taking over ${next.id} failed: $e');
await SessionManager().forget(next.id);
}
}
await AccountStorage.activate(SessionManager().activeAccount);
if (SessionManager().isSignedIn) await _resetWidget();
return SessionManager().activeAccount?.id;
}
/// Makes the stored account [id] the active one. Push moves along: the
/// previous account is unregistered here, the new one registers once the
/// app has remounted for it.
static Future<void> switchTo(String id) async {
final previous = SessionManager().activeAccount;
if (previous?.id == id) return;
await MarianumConnectAuthInterceptor.idle();
try {
await PushRegistration().deactivate();
} on Object catch (e) {
log('Switch: push deactivation failed: $e');
}
await SessionManager().stashActive();
SessionWipe.clearImmediate();
var restored = false;
try {
await SessionManager().activate(id);
} on Object {
if (previous != null) {
await SessionManager().activate(previous.id);
restored = true;
}
rethrow;
} finally {
await SessionWipe.accountLeft();
await AccountStorage.activate(SessionManager().activeAccount);
// Nothing remounts for the account that stays, so its push would
// remain unregistered until the next start.
if (restored) unawaited(PushRegistration().register());
}
await _resetWidget();
}
/// Parks the active account before the login screen signs in another one.
static Future<void> beginAddAccount() async {
await SessionManager().stashActive();
_addReturnId = SessionManager().activeAccount?.id;
SessionWipe.clearImmediate();
// Whatever runs behind the login screen must not write into the parked
// account's data.
await AccountStorage.activate(null);
}
static bool get isAddingAccount => _addReturnId != null;
/// Leaves "add account" without a new account: a half-finished sign-in is
/// dropped and the previous account restored.
static Future<void> cancelAddAccount() async {
final returnId = _addReturnId;
_addReturnId = null;
if (returnId == null) return;
if (SessionManager().activeAccount?.id != returnId &&
SessionManager().isSignedIn) {
await SessionManager().signOut();
}
await SessionManager().activate(returnId);
await AccountStorage.activate(SessionManager().activeAccount);
}
/// Called once a login finished. After "add account" the previous account's
/// push is unregistered with its own credentials before the new one takes
/// over. Returns the id of the now active account.
static Future<String> finishLogin() async {
final returnId = _addReturnId;
_addReturnId = null;
final added = SessionManager().activeAccount!;
if (returnId != null && returnId != added.id) {
await SessionManager().stashActive();
await SessionManager().activate(returnId);
try {
await PushRegistration().deactivate();
} on Object catch (e) {
log('Add account: push deactivation failed: $e');
}
await SessionManager().activate(added.id);
await SessionWipe.accountLeft();
}
await AccountStorage.activate(SessionManager().activeAccount);
return added.id;
}
/// Refreshes the stored real name of the active account (best-effort).
static Future<void> refreshDisplayName() async {
if (SessionManager().current case final session? when !session.isDemo) {
try {
final user = await AuthMe().user();
final name = '${user.firstName} ${user.lastName}'.trim();
if (name.isNotEmpty) await SessionManager().setDisplayName(name);
} on Object catch (e) {
log('Display name refresh failed: $e');
}
}
}
/// Signs out an account that is not active: revokes its tokens with the
/// stored credentials (best-effort) and deletes its local data. Its push
/// was already unregistered when it stopped being active.
static Future<void> removeInactive(String id) async {
final entry = SessionManager().accounts.value.byId(id);
if (entry == null || entry.id == SessionManager().activeAccount?.id) return;
final (session, fields) = await SessionManager().readVault(id);
if (session != null && !session.isDemo) {
await _revoke(session, fields[MarianumConnectTokenStorage.bearerKey]);
}
await SessionManager().forget(id);
await AccountStorage.delete(entry.namespace);
}
static Future<void> _revoke(Session session, String? token) async {
if (token != null && token.isNotEmpty) await AuthLogout.revoke(token);
final nextcloud = session.nextcloud;
if (nextcloud == null) return;
try {
if (nextcloud.hasAppPassword) {
await DeleteAppPassword().run(
authorizationHeader: nextcloud.basicAuthHeader,
);
}
if (nextcloud.hasAppPasswordTalk) {
await DeleteAppPassword().run(
authorizationHeader: nextcloud.talkBasicAuthHeader,
);
}
} on Object catch (e) {
log('Remove account: app password revoke failed: $e');
}
}
/// The widget still shows the previous account's plan; the app republishes
/// once it mounted, the refresh covers a backgrounded app.
static Future<void> _resetWidget() async {
try {
await WidgetSync.clear();
await WidgetSync.triggerUpdate();
unawaited(WidgetBackgroundTask.requestImmediateRefresh());
} on Object catch (e) {
log('Widget reset failed: $e');
}
}
}
+422
View File
@@ -0,0 +1,422 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../api/marianumconnect/auth/token_storage.dart';
import '../push/push_secure_storage.dart';
import '../utils/exponential_backoff.dart';
import '../utils/random_id.dart';
import 'account_codec.dart';
import 'nextcloud_credentials.dart';
import 'session.dart';
import 'session_codec.dart';
/// Owns the active [Session] and its persistence. One instance per isolate;
/// the widget background isolate reads the same keychain.
///
/// Several accounts can be signed in; the frozen [SessionKeys] slots, the MC
/// token and the group-keychain app passwords always hold the *active* one
/// (native code and the background isolate read only those). Inactive
/// accounts are parked in a per-account vault entry.
class SessionManager {
// `first_unlock` so a background launch on a locked device (silent push,
// BGAppRefresh) can still read the session. Items written by older versions
// carry the plugin default `unlocked` and are invisible to this instance
// until _migrateKeychainAccessibility moved them over.
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
static const FlutterSecureStorage _legacySecureStorage = FlutterSecureStorage(
iOptions: IOSOptions(accessibility: KeychainAccessibility.unlocked),
);
static const List<String> _sessionFields = [
SessionKeys.kind,
SessionKeys.username,
SessionKeys.password,
SessionKeys.guardianEmail,
SessionKeys.demo,
SessionKeys.loginFlow,
];
static const _indexKey = 'accounts_index';
static String _vaultKey(String id) => 'account_vault_$id';
static const _tokenStorage = MarianumConnectTokenStorage();
static final SessionManager _instance = SessionManager._();
factory SessionManager() => _instance;
SessionManager._() {
unawaited(_loadWithRetry());
}
Completer<void> _loaded = Completer();
Session? _current;
Session? get current => _current;
int _sessionEpoch = 0;
/// Bumped whenever the active account changes (sign-out, switch, another
/// account signing in). Async work captures it when it starts and drops its
/// result when it changed meanwhile, so a request of the previous account
/// cannot land in the next account's state or cache.
int get sessionEpoch => _sessionEpoch;
bool isCurrentSession(int epoch) => epoch == _sessionEpoch;
bool _isUiEngine = false;
/// Called from `main()`; background entry points never run it.
void markUiEngine() => _isUiEngine = true;
/// Every signed-in account and which one is active.
final ValueNotifier<AccountIndex> accounts = ValueNotifier(
AccountIndex.empty,
);
AccountEntry? get activeAccount => accounts.value.active;
bool get isSignedIn => _current != null;
bool get isDemo => _current?.isDemo ?? false;
/// Whether the session has a Nextcloud identity (Talk, Files, NC push).
bool get hasNextcloud => _current?.nextcloud != null;
/// True once the stored session has been read (or given up on).
bool get isLoaded => _loaded.isCompleted;
/// Resolves once the stored session is known. After [signOut] it stays
/// pending until the next sign-in.
Future<Session?> waitForLoad() async {
await _loaded.future;
return _current;
}
/// Stops waiting for the stored session; the app then behaves as signed
/// out. The keychain entries stay untouched so a later start can still
/// restore the session.
void abandonLoad() {
if (!_loaded.isCompleted) _loaded.complete();
}
/// Bumped when the server rejects a token that cannot be renewed silently
/// (passwordless accounts). The app confirms via [SessionValidator] before
/// signing out, so a transient 401 does not cost the session.
final ValueNotifier<int> unauthorizedSignal = ValueNotifier(0);
void reportUnauthorized() => unauthorizedSignal.value++;
NextcloudCredentials requireNextcloud() =>
_current?.nextcloud ?? (throw const NextcloudUnavailableException());
/// Makes [session] the active account, replacing the active slots
/// completely. An account signed in before keeps its entry; call
/// [stashActive] first so the previously active one stays switchable.
Future<void> signIn(Session session) async {
await _writeActive(session);
await _saveIndex(
accounts.value.activate(
session,
newId: randomHexId(bytes: 8),
now: _now(),
),
);
if (!_loaded.isCompleted) _loaded.complete();
}
Future<void> _writeActive(Session session) async {
if (!_isSameAccount(_current, session)) _sessionEpoch++;
await Future.wait([
for (final MapEntry(:key, :value) in encodeSessionFields(session).entries)
_writeSecret(key, value),
_writeGroupSecret(
SessionKeys.appPassword,
session.nextcloud?.appPassword,
),
_writeGroupSecret(
SessionKeys.appPasswordTalk,
session.nextcloud?.appPasswordTalk,
),
]);
_current = session;
}
/// Wipes the active slots and forgets the active account. Other accounts
/// stay in their vaults; see [activate].
Future<AccountEntry?> signOut() async {
final removed = activeAccount;
_sessionEpoch++;
_loaded = Completer();
_current = null;
await Future.wait([
for (final field in _sessionFields) _secureStorage.delete(key: field),
_writeGroupSecret(SessionKeys.appPassword, null),
_writeGroupSecret(SessionKeys.appPasswordTalk, null),
if (removed != null) _secureStorage.delete(key: _vaultKey(removed.id)),
]);
if (removed != null) await _saveIndex(accounts.value.remove(removed.id));
return removed;
}
/// Parks the active account (session, app passwords, MC token) in its vault
/// so the slots can be taken over by another account.
Future<void> stashActive() async {
final session = _current;
final entry = activeAccount;
if (session == null || entry == null) return;
final fields = {
...sessionVaultFields(session),
...await _tokenStorage.readAll(),
};
await _secureStorage.write(
key: _vaultKey(entry.id),
value: encodeVault(fields),
);
}
/// Loads the vault of [id] into the active slots. The active account must
/// have been stashed before, or its session is lost.
Future<void> activate(String id) async {
final fields = decodeVault(await _secureStorage.read(key: _vaultKey(id)));
final session = decodeSession(fields);
if (session == null) throw StateError('No stored session for account $id');
await _writeActive(session);
await _tokenStorage.writeAll(fields);
await _saveIndex(accounts.value.select(id, now: _now()));
if (!_loaded.isCompleted) _loaded.complete();
}
/// Remembers the real name of the active account for the account list.
Future<void> setDisplayName(String displayName) async {
final entry = activeAccount;
if (entry == null || entry.displayName == displayName) return;
await _saveIndex(accounts.value.rename(entry.id, displayName));
}
/// Session and MC token of an inactive account, e.g. to revoke them.
Future<(Session?, Map<String, String>)> readVault(String id) async {
final fields = decodeVault(await _secureStorage.read(key: _vaultKey(id)));
return (decodeSession(fields), fields);
}
/// Drops an inactive account without touching the active slots.
Future<void> forget(String id) async {
await _secureStorage.delete(key: _vaultKey(id));
await _saveIndex(accounts.value.remove(id));
}
static bool _isSameAccount(Session? a, Session? b) {
if (a == null || b == null) return a == b;
return identityOf(a) == identityOf(b) && a.isDemo == b.isDemo;
}
Future<void> _saveIndex(AccountIndex index) async {
accounts.value = index;
await _secureStorage.write(key: _indexKey, value: index.encode());
}
static int _now() => DateTime.now().millisecondsSinceEpoch;
/// Persists a freshly minted Nextcloud app password; from then on every
/// Nextcloud call authenticates with it instead of the real password.
Future<void> setAppPassword(String appPassword) async {
_updateNextcloud((nc) => nc.copyWith(appPassword: () => appPassword));
await _writeGroupSecret(SessionKeys.appPassword, appPassword);
}
Future<void> clearAppPassword() async {
_updateNextcloud((nc) => nc.copyWith(appPassword: () => null));
await _writeGroupSecret(SessionKeys.appPassword, null);
}
/// Adopts an app password obtained via Login Flow v2 and switches the
/// account into flow mode. A previously stored Talk app password belonged
/// to the old session era and is dropped — the second (optional) flow pass
/// stores a fresh one via [setAppPasswordTalk].
Future<void> setLoginFlow(String appPassword) async {
await setAppPassword(appPassword);
await clearAppPasswordTalk();
_updateNextcloud((nc) => nc.copyWith(usesLoginFlow: true));
await _secureStorage.write(key: SessionKeys.loginFlow, value: 'true');
}
Future<void> setAppPasswordTalk(String appPassword) async {
_updateNextcloud((nc) => nc.copyWith(appPasswordTalk: () => appPassword));
await _writeGroupSecret(SessionKeys.appPasswordTalk, appPassword);
}
Future<void> clearAppPasswordTalk() async {
_updateNextcloud((nc) => nc.copyWith(appPasswordTalk: () => null));
await _writeGroupSecret(SessionKeys.appPasswordTalk, null);
}
void _updateNextcloud(
NextcloudCredentials Function(NextcloudCredentials) update,
) {
final session = _current;
if (session is CredentialSession) {
_current = session.withNextcloud(update(session.nextcloud));
}
}
Future<void> _writeSecret(String key, String? value) => value == null
? _secureStorage.delete(key: key)
: _secureStorage.write(key: key, value: value);
// App passwords live in the push-shared (group-scoped) keystore so the iOS
// Notification Service Extension can authenticate Nextcloud calls too. That
// keystore may be unavailable (entitlement not provisioned); the in-memory
// copy still serves this session.
Future<void> _writeGroupSecret(String key, String? value) async {
try {
if (value == null) {
await pushSecureStorage.delete(key: key);
} else {
await pushSecureStorage.write(key: key, value: value);
}
} on Object {
// ignore — see above
}
}
/// iOS keychain reads fail while protected data is unavailable (app launch
/// racing the unlock, background wake on a locked device). Without a retry
/// the completer never resolved and the app stayed on the launch screen.
Future<void> _loadWithRetry() async {
for (var attempt = 1; !_loaded.isCompleted; attempt++) {
try {
await _migrateAndLoad();
return;
} catch (e, s) {
log('Session load failed (attempt $attempt): $e', stackTrace: s);
await Future<void>.delayed(exponentialBackoff(attempt));
}
}
}
Future<void> _migrateAndLoad() async {
await _migrateFromLegacyStorage();
await _migrateKeychainAccessibility();
_current = await _readActive();
await _loadIndex();
if (!_loaded.isCompleted) _loaded.complete();
}
/// The session in the active slots. On the startup critical path (and every
/// background wake): read in parallel instead of one keychain round-trip
/// after the other.
Future<Session?> _readActive() async {
final values = await Future.wait(
_sessionFields.map((field) => _secureStorage.read(key: field)),
);
final raw = Map<String, String?>.fromIterables(_sessionFields, values);
try {
final (appPassword, appPasswordTalk) = await (
pushSecureStorage.read(key: SessionKeys.appPassword),
pushSecureStorage.read(key: SessionKeys.appPasswordTalk),
).wait;
raw[SessionKeys.appPassword] = appPassword;
raw[SessionKeys.appPasswordTalk] = appPasswordTalk;
} on Object {
// Group keystore unavailable: fall back to the real password.
}
return decodeSession(raw);
}
/// Username currently in the active slots. Other engines (widget task, push
/// isolates) sign out, in or switch without this instance noticing.
Future<String?> readStoredUsername() =>
_secureStorage.read(key: SessionKeys.username);
/// Re-reads the session for long-lived background engines: they load once
/// and would otherwise keep acting with an account that signed out or was
/// switched away from in the app meanwhile. Keeps the known state when the
/// keystore is unreadable.
Future<void> reloadFromStorage() async {
// The UI engine performs sign-in, sign-out and switches itself, so its
// state is current; re-reading in between could resurrect the removed
// account.
if (_isUiEngine) return;
try {
final session = await _readActive();
final index = AccountIndex.decode(
await _secureStorage.read(key: _indexKey),
);
if (!_isSameAccount(_current, session)) _sessionEpoch++;
_current = session;
accounts.value = index;
if (!_loaded.isCompleted) _loaded.complete();
} on Object catch (e) {
log('Session reload failed, keeping loaded state: $e');
}
}
Future<void> _loadIndex() async {
var index = AccountIndex.decode(await _secureStorage.read(key: _indexKey));
final session = _current;
if (session != null) {
if (index.active == null ||
index.matching(session)?.id != index.activeId) {
// First start after the update: the existing account keeps its data
// in the un-namespaced storage.
index = index.activate(
session,
newId: randomHexId(bytes: 8),
namespace: index.accounts.isEmpty ? '' : null,
now: _now(),
);
await _secureStorage.write(key: _indexKey, value: index.encode());
}
accounts.value = index;
return;
}
accounts.value = index.remove(index.activeId ?? '');
// Interrupted switch or sign-out: fall back to another stored account
// instead of showing the login screen.
final fallback = accounts.value.mostRecentExcept(null);
if (fallback != null) await activate(fallback.id);
}
// Move credentials from the old SharedPreferences plain-text storage into the
// platform's secure keystore. Run once per install and clear the legacy keys.
Future<void> _migrateFromLegacyStorage() async {
final prefs = await SharedPreferences.getInstance();
final legacyUsername = prefs.getString(SessionKeys.username);
final legacyPassword = prefs.getString(SessionKeys.password);
if (legacyUsername == null || legacyPassword == null) return;
final hasSecure =
(await _secureStorage.read(key: SessionKeys.username)) != null;
if (!hasSecure) {
await _secureStorage.write(
key: SessionKeys.username,
value: legacyUsername,
);
await _secureStorage.write(
key: SessionKeys.password,
value: legacyPassword,
);
}
await prefs.remove(SessionKeys.username);
await prefs.remove(SessionKeys.password);
}
Future<void> _migrateKeychainAccessibility() async {
if (!Platform.isIOS) return;
final legacyValues = await Future.wait(
_sessionFields.map((field) => _legacySecureStorage.read(key: field)),
);
for (final (i, field) in _sessionFields.indexed) {
final value = legacyValues[i];
if (value == null) continue;
// Same account+service: the legacy item has to go before the re-add.
await _legacySecureStorage.delete(key: field);
await _secureStorage.write(key: field, value: value);
}
}
}
+12 -1
View File
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import '../auth_link/guardian_login_link.dart';
import 'pending_share.dart'; import 'pending_share.dart';
/// Bridges native share intents (Android ACTION_SEND, iOS Share Extension) /// Bridges native share intents (Android ACTION_SEND, iOS Share Extension)
@@ -136,6 +137,16 @@ class ShareIntentListener {
if (previous != null) _deleteFiles(previous); if (previous != null) _deleteFiles(previous);
} }
/// Android hands an opened App Link (guardian login mail) to the share
/// plugin as a shared URL too; it belongs to [GuardianLinkListener] only.
@visibleForTesting
static bool isAppLink(String value) {
final uri = Uri.tryParse(value.trim());
return uri != null &&
uri.hasScheme &&
uri.path.endsWith(GuardianLoginLink.path);
}
PendingShare? _toPendingShare(List<SharedMediaFile> items) { PendingShare? _toPendingShare(List<SharedMediaFile> items) {
if (items.isEmpty) return null; if (items.isEmpty) return null;
final files = <String>[]; final files = <String>[];
@@ -150,7 +161,7 @@ class ShareIntentListener {
if (File(item.path).existsSync()) files.add(item.path); if (File(item.path).existsSync()) files.add(item.path);
case SharedMediaType.text: case SharedMediaType.text:
case SharedMediaType.url: case SharedMediaType.url:
texts.add(item.path); if (!isAppLink(item.path)) texts.add(item.path);
} }
} }
if (files.isEmpty && texts.isEmpty) return null; if (files.isEmpty && texts.isEmpty) return null;
@@ -3,9 +3,10 @@ import 'dart:developer';
import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../../access/access_requirement.dart';
import '../../../../../api/errors/error_mapper.dart'; import '../../../../../api/errors/error_mapper.dart';
import '../../../../../api/errors/stale_session_exception.dart'; import '../../../../../api/errors/stale_session_exception.dart';
import '../../../../../model/account_data.dart'; import '../../../../../session/session_manager.dart';
import '../../../../../utils/session_single_flight.dart'; import '../../../../../utils/session_single_flight.dart';
import '../../loadable_state/loadable_state.dart'; import '../../loadable_state/loadable_state.dart';
import '../../loadable_state/loading_error.dart'; import '../../loadable_state/loading_error.dart';
@@ -126,18 +127,34 @@ abstract class LoadableHydratedBloc<
add(Reset<TState>()); add(Reset<TState>());
} }
/// Backend identities the data needs. Without them loading is a no-op, so
/// reading the bloc in a session that lacks them (e.g. guardians and the
/// Nextcloud blocs) is harmless.
Set<AccessRequirement> get requirements => const {};
bool get requirementsMet => requirements.areMetBy(SessionManager().current);
// Blocs are rebuilt per account (see AccountScope). One that briefly
// outlives its account would otherwise load with the next account's
// credentials and persist the result into its own storage.
final int _ownSession = SessionManager().sessionEpoch;
/// Whether the account this bloc was created for is still the active one.
bool get ownsSession => SessionManager().isCurrentSession(_ownSession);
static const _sessionKey = #loadableSessionEpoch; static const _sessionKey = #loadableSessionEpoch;
/// Runs [body] tagged with the current session: events it adds, also from /// Runs [body] tagged with the current session: events it adds, also from
/// its async continuations, are dropped once the account signed out, so a /// its async continuations, are dropped once the account signed out, so a
/// late response of the previous account cannot refill the reset bloc. /// late response of the previous account cannot refill the reset bloc.
R runInSession<R>(R Function() body) => R runInSession<R>(R Function() body) =>
runZoned(body, zoneValues: {_sessionKey: AccountData().sessionEpoch}); runZoned(body, zoneValues: {_sessionKey: SessionManager().sessionEpoch});
@override @override
void add(LoadableHydratedBlocEvent<TState> event) { void add(LoadableHydratedBlocEvent<TState> event) {
if (!ownsSession) return;
final epoch = Zone.current[_sessionKey]; final epoch = Zone.current[_sessionKey];
if (epoch is int && !AccountData().isCurrentSession(epoch)) return; if (epoch is int && !SessionManager().isCurrentSession(epoch)) return;
super.add(event); super.add(event);
} }
@@ -173,6 +190,7 @@ abstract class LoadableHydratedBloc<
final SessionSingleFlight _fetchFlight = SessionSingleFlight(); final SessionSingleFlight _fetchFlight = SessionSingleFlight();
void fetch() { void fetch() {
if (!requirementsMet || !ownsSession) return;
unawaited( unawaited(
_fetchFlight.run(() { _fetchFlight.run(() {
log('Fetching data for ${TState.toString()}'); log('Fetching data for ${TState.toString()}');
@@ -0,0 +1,71 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../breaker/bloc/breaker_bloc.dart';
import '../capabilities/bloc/capabilities_cubit.dart';
import '../chat/bloc/chat_bloc.dart';
import '../chat_list/bloc/chat_list_bloc.dart';
import '../children/child_selection_cubit.dart';
import '../nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
import '../parent_letters/bloc/parent_letters_bloc.dart';
import '../timetable/primary/primary_timetable_scope.dart';
import 'bloc/account_bloc.dart';
import 'bloc/account_state.dart';
/// The blocs of one account. Keyed by the account id above, so a switch
/// recreates all of them from the new account's storage instead of resetting
/// them one by one.
class AccountScope extends StatelessWidget {
/// Called with a context below the account's blocs once the scope mounted
/// for a signed-in account.
final void Function(BuildContext scopeContext) onActive;
final Widget child;
const AccountScope({super.key, required this.onActive, required this.child});
@override
Widget build(BuildContext context) => MultiBlocProvider(
providers: [
BlocProvider<BreakerBloc>(create: (_) => BreakerBloc()),
BlocProvider<CapabilitiesCubit>(create: (_) => CapabilitiesCubit()),
BlocProvider<NextcloudCapabilitiesCubit>(
create: (_) => NextcloudCapabilitiesCubit(),
),
BlocProvider<ChatListBloc>(create: (_) => ChatListBloc()),
BlocProvider<ChatBloc>(
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
),
BlocProvider<ChildSelectionCubit>(create: (_) => ChildSelectionCubit()),
BlocProvider<ParentLettersBloc>(create: (_) => ParentLettersBloc()),
],
child: PrimaryTimetableScope(
child: _Activation(onActive: onActive, child: child),
),
);
}
class _Activation extends StatefulWidget {
final void Function(BuildContext scopeContext) onActive;
final Widget child;
const _Activation({required this.onActive, required this.child});
@override
State<_Activation> createState() => _ActivationState();
}
class _ActivationState extends State<_Activation> {
@override
void initState() {
super.initState();
if (context.read<AccountBloc>().state.status != AccountStatus.loggedIn) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.onActive(context);
});
}
@override
Widget build(BuildContext context) => widget.child;
}
@@ -4,12 +4,38 @@ import 'account_event.dart';
import 'account_state.dart'; import 'account_state.dart';
class AccountBloc extends Bloc<AccountEvent, AccountState> { class AccountBloc extends Bloc<AccountEvent, AccountState> {
AccountBloc({AccountStatus initialStatus = AccountStatus.undefined}) AccountBloc({
: super(AccountState(status: initialStatus)) { AccountStatus initialStatus = AccountStatus.undefined,
String? accountId,
}) : super(AccountState(status: initialStatus, accountId: accountId)) {
on<AccountStatusChanged>( on<AccountStatusChanged>(
(event, emit) => emit(state.copyWith(status: event.status)), (event, emit) => emit(
AccountState(
status: event.status,
accountId: event.status == AccountStatus.loggedOut
? null
: state.accountId,
),
),
);
on<AccountActivated>(
(event, emit) => emit(
AccountState(
status: AccountStatus.loggedIn,
accountId: event.accountId,
freshLogin: event.freshLogin,
),
),
); );
} }
void setStatus(AccountStatus status) => add(AccountStatusChanged(status)); void setStatus(AccountStatus status) => add(AccountStatusChanged(status));
/// [accountId] became the active account (login, switch or takeover after a
/// sign-out); null means no account is left.
void activated(String? accountId, {bool freshLogin = false}) => add(
accountId == null
? const AccountStatusChanged(AccountStatus.loggedOut)
: AccountActivated(accountId, freshLogin: freshLogin),
);
} }
Loaded 100 of 211 files, more files were not shown because too many files have changed in this diff. Show more