Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a8fed24bb | ||
|
|
b413fe05de | ||
|
|
38a651f916 | ||
|
|
f98a6979fb | ||
|
|
3e856d0b1c | ||
|
|
8eddc8f771 | ||
|
|
27563a6dc1 | ||
|
|
5e65c4671a | ||
|
|
79b33ab248 | ||
|
|
1fd6264d10 | ||
|
|
a71f4f379b | ||
|
|
84098af7e2 | ||
|
|
630497abdd | ||
|
|
2423c1a75e | ||
|
|
67c935c05b |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -16,7 +16,10 @@ Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Fil
|
||||
|
||||
```
|
||||
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/infrastructure LoadableState<T>, DataLoader, geteilte BLoC-Bausteine
|
||||
├── view/ Screens
|
||||
@@ -51,6 +54,16 @@ lib/
|
||||
|
||||
**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
|
||||
|
||||
```bash
|
||||
@@ -65,13 +78,12 @@ flutter test # Tests (siehe test
|
||||
|
||||
| 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 |
|
||||
| Custom MHSL-Server | `lib/api/mhsl/` | Breaker, Custom Events, Notify, Noten |
|
||||
| Holiday-Calendar | `lib/api/holidays/` | Ferien |
|
||||
| MHSL (Legacy) | `lib/api/mhsl/` | nur noch Einmal-Migration der Custom Events |
|
||||
|
||||
`nextcloud`-Paket ist auf einen Custom-Fork gepinnt (siehe `pubspec.yaml` `dependency_overrides`).
|
||||
|
||||
## 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.
|
||||
@@ -41,6 +41,23 @@
|
||||
<data android:mimeType="video/*" />
|
||||
<data android:mimeType="application/*" />
|
||||
</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>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
|
||||
@@ -106,12 +106,14 @@ void _log(String message) => debugPrint('SHOTS: $message');
|
||||
Future<void> _login(WidgetTester tester) async {
|
||||
final loginVisible = await _pumpUntil(
|
||||
tester,
|
||||
find.byKey(const Key('login-username-field')),
|
||||
find.byKey(const Key('login-audience-school')),
|
||||
);
|
||||
if (!loginVisible) {
|
||||
_log('kein Login-Screen sichtbar – bereits angemeldet, überspringe Login');
|
||||
return;
|
||||
}
|
||||
await tester.tap(find.byKey(const Key('login-audience-school')));
|
||||
await _pumpUntil(tester, find.byKey(const Key('login-username-field')));
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('login-username-field')),
|
||||
'demo@screenshots',
|
||||
|
||||
@@ -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_device_identifier` | NC Device-Identifier | `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_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
|
||||
> unsichtbar**. Deshalb werden `nextcloud_username` + `nextcloud_base_url` bei
|
||||
> jeder `register()`-Runde zusätzlich **group-scoped** geschrieben. Das
|
||||
|
||||
+2
-8
@@ -12,8 +12,6 @@ PODS:
|
||||
- PhoneNumberKit/PhoneNumberKitCore (3.7.11)
|
||||
- PhoneNumberKit/UIKit (3.7.11):
|
||||
- PhoneNumberKit/PhoneNumberKitCore
|
||||
- workmanager_apple (0.0.1):
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- eraser (from `.symlinks/plugins/eraser/ios`)
|
||||
@@ -21,7 +19,6 @@ DEPENDENCIES:
|
||||
- flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`)
|
||||
- open_filex (from `.symlinks/plugins/open_filex/ios`)
|
||||
- PhoneNumberKit (~> 3.7.6)
|
||||
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
@@ -36,17 +33,14 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/flutter_app_badge/ios"
|
||||
open_filex:
|
||||
:path: ".symlinks/plugins/open_filex/ios"
|
||||
workmanager_apple:
|
||||
:path: ".symlinks/plugins/workmanager_apple/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
eraser: 83a4b06985f3702aa3d8dec816f9693266012937
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
|
||||
flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf
|
||||
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
|
||||
PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c
|
||||
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
|
||||
|
||||
PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
COCOAPODS: 1.17.0
|
||||
@@ -3,11 +3,12 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objectVersion = 70;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
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 */; };
|
||||
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 */; };
|
||||
@@ -77,19 +78,19 @@
|
||||
/* End PBXCopyFilesBuildPhase 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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; name = NotificationServiceExtension.entitlements; path = NotificationServiceExtension.entitlements; 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; 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>"; };
|
||||
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>"; };
|
||||
@@ -107,6 +108,7 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -222,7 +224,6 @@
|
||||
12B96C78930441C73F123636 /* Info.plist */,
|
||||
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */,
|
||||
);
|
||||
name = NotificationServiceExtension;
|
||||
path = NotificationServiceExtension;
|
||||
sourceTree = SOURCE_ROOT;
|
||||
};
|
||||
@@ -304,6 +305,7 @@
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
AA0102020000000022222222 /* SceneDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
AFE007D963441C0650CD3ED9 /* PrivacyInfo.xcprivacy */,
|
||||
);
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
@@ -469,6 +471,7 @@
|
||||
33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */,
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||
0B36AA42294848D99E4E0742 /* PrivacyInfo.xcprivacy in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -50,8 +50,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
||||
"state" : {
|
||||
"revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270",
|
||||
"version" : "12.15.0"
|
||||
"revision" : "27eaab3918e0bf78711cf1abf240577176326432",
|
||||
"version" : "12.19.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -59,8 +59,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
||||
"state" : {
|
||||
"revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55",
|
||||
"version" : "3.6.1"
|
||||
"revision" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee",
|
||||
"version" : "3.7.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -68,8 +68,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
||||
"state" : {
|
||||
"revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b",
|
||||
"version" : "12.15.0"
|
||||
"revision" : "b8455dd2ff55cc269fda7a4f9eac6c1e3588c53f",
|
||||
"version" : "12.19.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -86,8 +86,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleUtilities.git",
|
||||
"state" : {
|
||||
"revision" : "9f183ae842be978784f2963a343682e0c46d8fb3",
|
||||
"version" : "8.1.2"
|
||||
"revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3",
|
||||
"version" : "8.1.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -50,8 +50,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
||||
"state" : {
|
||||
"revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270",
|
||||
"version" : "12.15.0"
|
||||
"revision" : "27eaab3918e0bf78711cf1abf240577176326432",
|
||||
"version" : "12.19.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -59,8 +59,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
||||
"state" : {
|
||||
"revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64",
|
||||
"version" : "3.6.0"
|
||||
"revision" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee",
|
||||
"version" : "3.7.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -68,8 +68,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
||||
"state" : {
|
||||
"revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b",
|
||||
"version" : "12.15.0"
|
||||
"revision" : "b8455dd2ff55cc269fda7a4f9eac6c1e3588c53f",
|
||||
"version" : "12.19.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -86,8 +86,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleUtilities.git",
|
||||
"state" : {
|
||||
"revision" : "c46e5f8b7c23265f17c24ca7f9fa1b13ded7a822",
|
||||
"version" : "8.1.1"
|
||||
"revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3",
|
||||
"version" : "8.1.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -37,12 +37,14 @@
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>FlutterDeepLinkingEnabled</key>
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<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>
|
||||
<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>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@@ -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>
|
||||
@@ -4,6 +4,11 @@
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<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>
|
||||
<array>
|
||||
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
|
||||
|
||||
@@ -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))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.padding(.vertical, 2)
|
||||
.minimumScaleFactor(0.8)
|
||||
.padding(.vertical, 1)
|
||||
}
|
||||
}
|
||||
.frame(width: RULER_WIDTH, height: height)
|
||||
@@ -304,6 +305,8 @@ private struct LessonTile: View {
|
||||
let metrics: TileMetrics
|
||||
let layout: TileTextLayout
|
||||
|
||||
@Environment(\.widgetRenderingMode) private var renderingMode
|
||||
|
||||
private static let cancelledRed =
|
||||
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)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.frame(height: height, alignment: .topLeading)
|
||||
.background(lesson.tileColor)
|
||||
.background { tileBackground(shape) }
|
||||
.clipShape(shape)
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if let count = lesson.siblingCount, count > 0 {
|
||||
@@ -357,6 +360,22 @@ private struct LessonTile: View {
|
||||
.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 {
|
||||
HStack(alignment: .center, spacing: 3) {
|
||||
Text(layout.title)
|
||||
|
||||
@@ -47,7 +47,10 @@ let TITLE_WRAP_BELOW_FONT: CGFloat = 8
|
||||
let BADGE_WIDTH: CGFloat = 16
|
||||
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
|
||||
|
||||
/// Space the holiday column keeps free above and below the rotated name for
|
||||
|
||||
@@ -4,6 +4,7 @@ import WidgetKit
|
||||
struct TimetableWeekView: View {
|
||||
let entry: TimetableEntry
|
||||
@Environment(\.widgetPalette) private var palette
|
||||
@Environment(\.widgetRenderingMode) private var renderingMode
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -115,13 +116,23 @@ struct TimetableWeekView: View {
|
||||
.lineLimit(1)
|
||||
Text("\(Calendar.current.component(.day, from: day))")
|
||||
.font(.system(size: 11, weight: isToday ? .bold : .regular))
|
||||
.foregroundStyle(isToday ? palette.onAccent : palette.textPrimary)
|
||||
.foregroundStyle(isToday ? todayNumberColor : palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.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 {
|
||||
VStack(spacing: 4) {
|
||||
Text("Marianum Stundenplan")
|
||||
|
||||
@@ -201,12 +201,15 @@ private struct WidgetSurface<Content: View>: View {
|
||||
let entry: TimetableEntry
|
||||
@ViewBuilder let content: () -> Content
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.widgetRenderingMode) private var renderingMode
|
||||
|
||||
var body: some View {
|
||||
let palette = WidgetPalette.resolve(
|
||||
themeMode: entry.themeMode,
|
||||
colorScheme: colorScheme
|
||||
)
|
||||
// Tinted / clear home screens map every colour to its luminance, so
|
||||
// the light palette's near-black text would vanish — use the dark
|
||||
// palette's light text there regardless of the theme setting.
|
||||
let palette = renderingMode == .fullColor
|
||||
? WidgetPalette.resolve(themeMode: entry.themeMode, colorScheme: colorScheme)
|
||||
: .dark
|
||||
return AnyView(
|
||||
background(content: content(), palette: palette)
|
||||
.environment(\.widgetPalette, palette)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+71
-28
@@ -5,6 +5,7 @@ import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../session/session_manager.dart';
|
||||
import '../utils/directory_size.dart';
|
||||
|
||||
/// 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.
|
||||
/// Here every access is async and touches exactly one file, and the payload is
|
||||
/// 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 {
|
||||
CacheStore._();
|
||||
|
||||
@@ -30,15 +34,30 @@ class CacheStore {
|
||||
/// Payloads above this size are decoded on a background isolate.
|
||||
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 = {};
|
||||
|
||||
Future<Directory> _directory() => _dir ??= () async {
|
||||
final base = await getApplicationCacheDirectory();
|
||||
final dir = Directory('${base.path}/request_cache');
|
||||
await dir.create(recursive: true);
|
||||
return dir;
|
||||
}();
|
||||
/// 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 dir = Directory('${base.path}/$name');
|
||||
await dir.create(recursive: true);
|
||||
return dir;
|
||||
}();
|
||||
}
|
||||
|
||||
Future<File> _file(String key) async =>
|
||||
File('${(await _directory()).path}/${_safeName(key)}');
|
||||
@@ -49,8 +68,9 @@ class CacheStore {
|
||||
|
||||
Future<CacheEntry?> read(String key) async {
|
||||
try {
|
||||
await _writes[key];
|
||||
return parse(await (await _file(key)).readAsString());
|
||||
final file = await _file(key);
|
||||
await _writes[file.path];
|
||||
return parse(await file.readAsString());
|
||||
} on Object {
|
||||
// Missing (PathNotFoundException) or unreadable: a cache miss.
|
||||
return null;
|
||||
@@ -64,18 +84,21 @@ class CacheStore {
|
||||
return {for (var i = 0; i < keys.length; i++) keys[i]: ?entries[i]};
|
||||
}
|
||||
|
||||
Future<void> write(String key, String json) {
|
||||
final previous = _writes[key] ?? Future<void>.value();
|
||||
Future<void> write(String key, String json) async {
|
||||
// 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;
|
||||
current = previous.then((_) => _write(key, json)).whenComplete(() {
|
||||
if (identical(_writes[key], current)) _writes.remove(key);
|
||||
current = previous.then((_) => _write(file, json)).whenComplete(() {
|
||||
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 {
|
||||
final file = await _file(key);
|
||||
// Write-then-rename so a reader (or the widget background isolate)
|
||||
// never sees a half-written file.
|
||||
final tmp = File('${file.path}.tmp');
|
||||
@@ -85,19 +108,21 @@ class CacheStore {
|
||||
);
|
||||
await tmp.rename(file.path);
|
||||
} on Object catch (e) {
|
||||
debugPrint('CacheStore.write($key) failed: $e');
|
||||
debugPrint('CacheStore.write(${file.path}) failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> delete(String key) async {
|
||||
try {
|
||||
await _writes[key];
|
||||
await (await _file(key)).delete();
|
||||
final file = await _file(key);
|
||||
await _writes[file.path];
|
||||
await file.delete();
|
||||
} on Object {
|
||||
// A missing or locked file is as good as deleted for a cache.
|
||||
}
|
||||
}
|
||||
|
||||
/// Empties the active account's cache.
|
||||
Future<void> clear() async {
|
||||
try {
|
||||
final dir = await _directory();
|
||||
@@ -105,7 +130,20 @@ class CacheStore {
|
||||
} on Object catch (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].
|
||||
@@ -124,17 +162,22 @@ class CacheStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes entries not written for [maxAge], judged by file mtime so nothing
|
||||
/// has to be read or decoded.
|
||||
/// Removes entries not written for [maxAge] from every account's cache,
|
||||
/// judged by file mtime so nothing has to be read or decoded.
|
||||
Future<void> deleteOlderThan(Duration maxAge) async {
|
||||
try {
|
||||
final dir = await _directory();
|
||||
final base = await getApplicationCacheDirectory();
|
||||
final cutoff = DateTime.now().subtract(maxAge);
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is! File) continue;
|
||||
// ignore: avoid_slow_async_io
|
||||
final modified = (await entity.stat()).modified;
|
||||
if (modified.isBefore(cutoff)) await entity.delete();
|
||||
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()) {
|
||||
if (entity is! File) continue;
|
||||
// ignore: avoid_slow_async_io
|
||||
final modified = (await entity.stat()).modified;
|
||||
if (modified.isBefore(cutoff)) await entity.delete();
|
||||
}
|
||||
}
|
||||
} on Object catch (e) {
|
||||
debugPrint('CacheStore.deleteOlderThan failed: $e');
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import '../../../state/app/modules/capabilities/bloc/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 persona sees every feature (incl. push) as available and the timetable
|
||||
@@ -17,6 +19,27 @@ class DemoCapabilities {
|
||||
userType: 'STUDENT',
|
||||
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
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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/room/get_room_response.dart';
|
||||
import '../demo_persona.dart';
|
||||
@@ -204,7 +204,8 @@ class DemoTalk {
|
||||
id: base + 2,
|
||||
token: token,
|
||||
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(
|
||||
id: base + 3,
|
||||
@@ -366,7 +367,7 @@ class DemoTalk {
|
||||
}) => _msg(
|
||||
id: id,
|
||||
token: token,
|
||||
actor: AccountData().getUsername(),
|
||||
actor: SessionManager().requireNextcloud().username,
|
||||
display: DemoPersona.studentName,
|
||||
ago: ago,
|
||||
message: message,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import '../../model/account_data.dart';
|
||||
import '../../session/session_manager.dart';
|
||||
|
||||
/// 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
|
||||
/// 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
|
||||
/// too — reviewers run the shipped release build.
|
||||
class DemoMode {
|
||||
@@ -22,6 +22,14 @@ class DemoMode {
|
||||
static bool matches(String username) =>
|
||||
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.
|
||||
static bool get active => AccountData().isDemo;
|
||||
static bool get active => SessionManager().isDemo;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:dio/dio.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:nextcloud/nextcloud.dart';
|
||||
|
||||
import '../../session/session.dart';
|
||||
import '../api_error.dart';
|
||||
import '../http_errors.dart';
|
||||
import '../marianumcloud/talk/talk_error.dart';
|
||||
@@ -61,7 +62,9 @@ AppException? _dioToAppException(DioException error) {
|
||||
AppException _dynamiteToAppException(DynamiteApiException error) {
|
||||
final status = error.statusCode;
|
||||
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) {
|
||||
case 401:
|
||||
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 TalkError) return TalkException(error).userMessage;
|
||||
if (error is NextcloudUnavailableException) {
|
||||
return 'Diese Funktion ist mit deinem Konto nicht verfügbar.';
|
||||
}
|
||||
|
||||
if (error is DioException) {
|
||||
final mapped = _dioToAppException(error);
|
||||
@@ -136,6 +142,7 @@ String? errorToTechnicalDetails(Object? error) {
|
||||
bool errorAllowsRetry(Object? error) {
|
||||
if (error == null) return true;
|
||||
if (error is AppException) return error.allowRetry;
|
||||
if (error is NextcloudUnavailableException) return false;
|
||||
if (error is DioException) {
|
||||
final mapped = _dioToAppException(error);
|
||||
if (mapped != null) return mapped.allowRetry;
|
||||
|
||||
@@ -16,10 +16,15 @@ class DeleteAppPassword {
|
||||
Future<void> run({String? authorizationHeader}) async {
|
||||
await _client.delete(
|
||||
NextcloudOcs.uri('core/apppassword'),
|
||||
headers: {
|
||||
...NextcloudOcs.headers(),
|
||||
'Authorization': ?authorizationHeader,
|
||||
},
|
||||
// An explicit header may belong to an inactive account, so the active
|
||||
// session's headers must not be required then.
|
||||
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 '../../../model/account_data.dart';
|
||||
import '../../../session/session_manager.dart';
|
||||
import '../../http_errors.dart';
|
||||
import '../nextcloud_ocs.dart';
|
||||
|
||||
/// Exchanges the user's real Nextcloud password for a scoped app password via
|
||||
/// `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.
|
||||
///
|
||||
/// 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
|
||||
// the app password, but an app password cannot mint another one —
|
||||
// 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 '../../../model/account_data.dart';
|
||||
import '../../../model/endpoint_data.dart';
|
||||
import '../../../session/session_manager.dart';
|
||||
import '../../errors/parse_exception.dart';
|
||||
import '../../http_errors.dart';
|
||||
import '../nextcloud_ocs.dart';
|
||||
@@ -27,12 +27,12 @@ Uri _coreAvatarUri() {
|
||||
return Uri.https(endpoint.domain, '${endpoint.path}/avatar/');
|
||||
}
|
||||
|
||||
Uri _userInfoUri() =>
|
||||
NextcloudOcs.uri('cloud/users/${AccountData().getUsername()}');
|
||||
Uri _userInfoUri() => NextcloudOcs.uri(
|
||||
'cloud/users/${SessionManager().requireNextcloud().username}',
|
||||
);
|
||||
|
||||
Future<http.Response> _send(
|
||||
Future<http.Response> Function(Uri uri, Map<String, String> headers)
|
||||
perform,
|
||||
Future<http.Response> Function(Uri uri, Map<String, String> headers) perform,
|
||||
Uri uri,
|
||||
) async {
|
||||
final headers = NextcloudOcs.headers();
|
||||
@@ -98,16 +98,13 @@ class GetUserInfo {
|
||||
try {
|
||||
final root = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final data =
|
||||
(root['ocs'] as Map<String, dynamic>)['data']
|
||||
as Map<String, dynamic>;
|
||||
(root['ocs'] as Map<String, dynamic>)['data'] as Map<String, dynamic>;
|
||||
return CloudUserInfo(
|
||||
userId: data['id'] as String,
|
||||
displayName: (data['displayname'] as String?) ?? '',
|
||||
);
|
||||
} catch (e) {
|
||||
throw ParseException(
|
||||
technicalDetails: 'Cloud $uri user info parse: $e',
|
||||
);
|
||||
throw ParseException(technicalDetails: 'Cloud $uri user info parse: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../model/account_data.dart';
|
||||
import '../../model/endpoint_data.dart';
|
||||
import '../../session/session_manager.dart';
|
||||
|
||||
/// Shared headers and URI builder for Nextcloud OCS v2 endpoints. Used by
|
||||
/// TalkApi, AutocompleteApi, FileSharingApi.
|
||||
@@ -16,7 +16,7 @@ class NextcloudOcs {
|
||||
static Map<String, String> headers() => {
|
||||
'Accept': 'application/json',
|
||||
'OCS-APIRequest': 'true',
|
||||
'Authorization': AccountData().getBasicAuthHeader(),
|
||||
'Authorization': SessionManager().requireNextcloud().basicAuthHeader,
|
||||
};
|
||||
|
||||
static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:nextcloud/nextcloud.dart';
|
||||
|
||||
import '../../../model/account_data.dart';
|
||||
import '../../../model/endpoint_data.dart';
|
||||
import '../../../session/session_manager.dart';
|
||||
import '../../api_response.dart';
|
||||
|
||||
abstract class WebdavApi<T> {
|
||||
@@ -20,8 +20,8 @@ abstract class WebdavApi<T> {
|
||||
static Future<WebDavClient> get webdav {
|
||||
// Keyed by user too: two accounts may share a password (no app password
|
||||
// minted), and the client would keep the previous login name.
|
||||
final secret =
|
||||
'${AccountData().getUsername()}:${AccountData().getNextcloudSecret()}';
|
||||
final nextcloud = SessionManager().requireNextcloud();
|
||||
final secret = '${nextcloud.username}:${nextcloud.secret}';
|
||||
if (_webdav == null || _webdavSecret != secret) {
|
||||
_webdavSecret = secret;
|
||||
_webdav = establishWebdavConnection();
|
||||
@@ -33,13 +33,13 @@ abstract class WebdavApi<T> {
|
||||
NextcloudClient(
|
||||
Uri.parse('https://${EndpointData().nextcloud().full()}'),
|
||||
// App password preferred — with 2FA the real password is not accepted
|
||||
// by Nextcloud at all (see AccountData.usesLoginFlow).
|
||||
password: AccountData().getNextcloudSecret(),
|
||||
loginName: AccountData().getUsername(),
|
||||
// by Nextcloud at all (see NextcloudCredentials.usesLoginFlow).
|
||||
password: SessionManager().requireNextcloud().secret,
|
||||
loginName: SessionManager().requireNextcloud().username,
|
||||
).webdav;
|
||||
|
||||
/// 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() =>
|
||||
'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 '../../../model/account_data.dart';
|
||||
import '../../../session/session.dart';
|
||||
import '../../../session/session_manager.dart';
|
||||
import '../queries/auth_login/auth_login.dart';
|
||||
import 'device_token_name.dart';
|
||||
import 'token_storage.dart';
|
||||
|
||||
/// 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 {
|
||||
static const _retriedKey = 'mc_auth_retried';
|
||||
|
||||
@@ -18,6 +20,15 @@ class MarianumConnectAuthInterceptor extends Interceptor {
|
||||
// each spawning a fresh row in api_tokens.
|
||||
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({
|
||||
MarianumConnectTokenStorage tokenStorage =
|
||||
const MarianumConnectTokenStorage(),
|
||||
@@ -64,6 +75,9 @@ class MarianumConnectAuthInterceptor extends Interceptor {
|
||||
}
|
||||
final refreshed = await _attemptReLogin();
|
||||
if (!refreshed) {
|
||||
if (SessionManager().current is GuardianSession) {
|
||||
SessionManager().reportUnauthorized();
|
||||
}
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
@@ -80,32 +94,35 @@ class MarianumConnectAuthInterceptor extends Interceptor {
|
||||
if (inFlight != null) return inFlight;
|
||||
final fresh = _performReLogin();
|
||||
_pendingReLogin = fresh;
|
||||
_anyPendingReLogin = fresh;
|
||||
fresh.whenComplete(() {
|
||||
if (identical(_pendingReLogin, fresh)) _pendingReLogin = null;
|
||||
if (identical(_anyPendingReLogin, fresh)) _anyPendingReLogin = null;
|
||||
});
|
||||
return fresh;
|
||||
}
|
||||
|
||||
Future<bool> _performReLogin() async {
|
||||
if (!AccountData().isPopulated()) return false;
|
||||
final username = AccountData().getUsername();
|
||||
final session = SessionManager().current;
|
||||
if (session is! CredentialSession) return false;
|
||||
final username = session.username;
|
||||
// A background engine (widget task) keeps the account it loaded. When
|
||||
// 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.
|
||||
if (await AccountData().readStoredUsername() != username) return false;
|
||||
if (await SessionManager().readStoredUsername() != username) return false;
|
||||
try {
|
||||
await _loginClient.run(
|
||||
username: username,
|
||||
password: AccountData().getPassword(),
|
||||
password: session.password,
|
||||
tokenName: await DeviceTokenName.resolve(),
|
||||
);
|
||||
} catch (_) {
|
||||
if (await AccountData().readStoredUsername() == username) {
|
||||
if (await SessionManager().readStoredUsername() == username) {
|
||||
await _tokenStorage.clear();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
final stored = await AccountData().readStoredUsername();
|
||||
final stored = await SessionManager().readStoredUsername();
|
||||
if (stored == username) return true;
|
||||
// Signed out during the login: drop the orphaned token, unless another
|
||||
// account already stored its own.
|
||||
|
||||
@@ -1,39 +1,50 @@
|
||||
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 '../queries/auth_logout/auth_logout.dart';
|
||||
import '../queries/auth_me/auth_me.dart';
|
||||
import '../queries/auth_verify/auth_verify.dart';
|
||||
import 'token_storage.dart';
|
||||
|
||||
/// Background credential probe — a server-side password rotation forces a
|
||||
/// re-login on the next cold start even when the bearer token would still
|
||||
/// be accepted.
|
||||
/// Credential probe. For password accounts a server-side password rotation
|
||||
/// forces a re-login on the next cold start even when the bearer token would
|
||||
/// still be accepted; for guardians it confirms a rejected token before the
|
||||
/// session is dropped. Another stored account then takes over.
|
||||
class SessionValidator {
|
||||
static Future<void> probeStored({
|
||||
required Future<void> Function() onInvalidated,
|
||||
required Future<void> Function(String? nextAccountId) onInvalidated,
|
||||
}) async {
|
||||
if (!AccountData().isPopulated()) return;
|
||||
// AuthVerify uses its own dio (bypassing the demo interceptor), so a demo
|
||||
// session must be skipped here or its missing token would 401 into a logout.
|
||||
if (AccountData().isDemo) return;
|
||||
final username = AccountData().getUsername();
|
||||
final password = AccountData().getPassword();
|
||||
final epoch = AccountData().sessionEpoch;
|
||||
final session = SessionManager().current;
|
||||
// The probes use their own dio (bypassing the demo interceptor), so a demo
|
||||
// session must be skipped or its missing token would 401 into a logout.
|
||||
if (session == null || session.isDemo) return;
|
||||
final epoch = SessionManager().sessionEpoch;
|
||||
try {
|
||||
await AuthVerify().run(username: username, password: password);
|
||||
switch (session) {
|
||||
case CredentialSession(:final username, :final password):
|
||||
await AuthVerify().run(username: username, password: password);
|
||||
case GuardianSession():
|
||||
await AuthMe().run();
|
||||
}
|
||||
} on AuthException catch (e) {
|
||||
if (e.statusCode != 401) return;
|
||||
// The probed account already signed out; the 401 must not sign out
|
||||
// whoever logged in meanwhile.
|
||||
if (!AccountData().isCurrentSession(epoch)) return;
|
||||
log('MC: stored credentials rejected — forcing re-login');
|
||||
await AuthLogout().run();
|
||||
await const MarianumConnectTokenStorage().clear();
|
||||
await AccountData().removeData();
|
||||
await onInvalidated();
|
||||
// The probed account is no longer the active one; the 401 must not
|
||||
// sign out whoever took over meanwhile.
|
||||
if (!SessionManager().isCurrentSession(epoch)) return;
|
||||
log('MC: stored session rejected — forcing re-login');
|
||||
final next = await SessionLifecycle.signOut(
|
||||
notice: switch (session) {
|
||||
CredentialSession() =>
|
||||
'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) {
|
||||
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 '../../errors/auth_exception.dart';
|
||||
|
||||
/// `first_unlock` accessibility so the token can be read during background
|
||||
/// requests (telemetry heartbeat, push-triggered syncs) after the first device
|
||||
/// 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
|
||||
/// 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.
|
||||
class MarianumConnectTokenStorage {
|
||||
static const _tokenKey = 'mc_bearer_token';
|
||||
@@ -24,6 +27,18 @@ class MarianumConnectTokenStorage {
|
||||
|
||||
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<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 {
|
||||
await _storage.delete(key: _tokenKey);
|
||||
await _storage.delete(key: _tokenIdKey);
|
||||
|
||||
@@ -6,6 +6,22 @@ import '../../errors/network_exception.dart';
|
||||
import '../../errors/parse_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
|
||||
/// the app's typed AppExceptions. Keeps the dio dependency out of call sites
|
||||
/// that just want to render an error message.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../errors/app_exception.dart';
|
||||
import 'errors/marianumconnect_error.dart';
|
||||
import 'marianumconnect_api.dart';
|
||||
import 'marianumconnect_endpoint.dart';
|
||||
@@ -23,10 +26,14 @@ abstract class MarianumConnectQuery {
|
||||
try {
|
||||
return await body();
|
||||
} 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].
|
||||
Future<T> getObject<T>(
|
||||
String path,
|
||||
@@ -55,6 +62,16 @@ abstract class MarianumConnectQuery {
|
||||
.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.
|
||||
String isoDate(DateTime d) =>
|
||||
'${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 'absence_prefill_response.dart';
|
||||
|
||||
/// 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 {
|
||||
AbsencePrefill({super.dio});
|
||||
|
||||
Future<AbsencePrefillResponse> run() =>
|
||||
getObject('absence/prefill', AbsencePrefillResponse.fromJson);
|
||||
Future<AbsencePrefillResponse> run({String? childId}) => getObject(
|
||||
'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`).
|
||||
/// Empty identity fields are backfilled from LDAP server-side; validation
|
||||
/// (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 {
|
||||
AbsenceSubmit({super.dio});
|
||||
|
||||
@@ -15,6 +16,7 @@ class AbsenceSubmit extends MarianumConnectQuery {
|
||||
required DateTime absentUntil,
|
||||
required String phone,
|
||||
required String note,
|
||||
String? childId,
|
||||
}) => guard(() async {
|
||||
await dio.post<void>(
|
||||
endpoint('absence'),
|
||||
@@ -26,6 +28,7 @@ class AbsenceSubmit extends MarianumConnectQuery {
|
||||
'absentUntil': isoDate(absentUntil),
|
||||
'phone': phone,
|
||||
'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 '../../auth/token_storage.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_endpoint.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
|
||||
/// Revokes the stored MC bearer token both server-side and locally. Best-effort
|
||||
@@ -24,4 +26,17 @@ class AuthLogout extends MarianumConnectQuery {
|
||||
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 password,
|
||||
}) async {
|
||||
final token = await _tokenStorage.readToken();
|
||||
if (token == null || token.isEmpty) {
|
||||
throw AuthException.unauthorized(
|
||||
technicalDetails: 'AuthVerify: no bearer token in storage',
|
||||
);
|
||||
}
|
||||
final options = await _tokenStorage.requireBearerOptions('AuthVerify');
|
||||
return guard(() async {
|
||||
await dio.post<void>(
|
||||
endpoint('auth/verify'),
|
||||
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 'guardian_child.dart';
|
||||
|
||||
part 'get_capabilities_response.g.dart';
|
||||
|
||||
/// Slimmed-down capability flags the mobile UI gates features on. The backend
|
||||
@@ -23,16 +25,21 @@ class CapabilitiesResponse {
|
||||
|
||||
final int? timetableFutureDays;
|
||||
|
||||
/// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'). Null when the backend
|
||||
/// predates the field or has no LDAP record for the user.
|
||||
/// User type ('TEACHER' | 'STUDENT' | 'STAFF' | 'PARENT'). Null when the
|
||||
/// backend predates the field or has no record for the user.
|
||||
final String? userType;
|
||||
|
||||
/// Students linked to a guardian account; empty for everyone else.
|
||||
@JsonKey(defaultValue: <GuardianChild>[])
|
||||
final List<GuardianChild> children;
|
||||
|
||||
CapabilitiesResponse({
|
||||
required this.viewForeignTimetables,
|
||||
required this.pushNotifications,
|
||||
this.timetablePastDays,
|
||||
this.timetableFutureDays,
|
||||
this.userType,
|
||||
this.children = const [],
|
||||
});
|
||||
|
||||
factory CapabilitiesResponse.fromJson(Map<String, dynamic> json) =>
|
||||
|
||||
@@ -14,6 +14,11 @@ CapabilitiesResponse _$CapabilitiesResponseFromJson(
|
||||
timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(),
|
||||
timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(),
|
||||
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(
|
||||
@@ -24,4 +29,5 @@ Map<String, dynamic> _$CapabilitiesResponseToJson(
|
||||
'timetablePastDays': instance.timetablePastDays,
|
||||
'timetableFutureDays': instance.timetableFutureDays,
|
||||
'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 'package:dio/dio.dart';
|
||||
|
||||
import '../../marianumconnect_query.dart';
|
||||
|
||||
/// Downloads the raw PDF bytes of a Marianum Message from
|
||||
@@ -15,11 +13,6 @@ class GetNewsletterFile extends MarianumConnectQuery {
|
||||
|
||||
GetNewsletterFile(this.id, {super.dio});
|
||||
|
||||
Future<Uint8List> run() => guard(() async {
|
||||
final response = await dio.get<List<int>>(
|
||||
endpoint('newsletter/${Uri.encodeComponent(id)}/file'),
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
return Uint8List.fromList(response.data!);
|
||||
});
|
||||
Future<Uint8List> run() =>
|
||||
getBytes('newsletter/${Uri.encodeComponent(id)}/file');
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../marianumconnect_query.dart';
|
||||
|
||||
/// Downloads the raw bytes of a PROXIED_FILE ticker page from
|
||||
@@ -15,11 +13,6 @@ class GetTickerPageFile extends MarianumConnectQuery {
|
||||
|
||||
GetTickerPageFile(this.slug, {super.dio});
|
||||
|
||||
Future<Uint8List> run() => guard(() async {
|
||||
final response = await dio.get<List<int>>(
|
||||
endpoint('ticker/pages/${Uri.encodeComponent(slug)}/file'),
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
return Uint8List.fromList(response.data!);
|
||||
});
|
||||
Future<Uint8List> run() =>
|
||||
getBytes('ticker/pages/${Uri.encodeComponent(slug)}/file');
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
/// Registers (upserts) this device's push subscription with MarianumConnect via
|
||||
/// `PUT /api/mobile/v1/me/push-device`. The backend verifies the Nextcloud
|
||||
/// device-identifier signature, stores the routing metadata and starts
|
||||
/// forwarding Nextcloud pushes to this device's FCM token. Responds 204.
|
||||
/// `PUT /api/mobile/v1/me/push-device`. For Nextcloud registrations the backend
|
||||
/// verifies the device-identifier signature and forwards Nextcloud pushes to
|
||||
/// this device's FCM token; `direct` registrations (accounts without
|
||||
/// Nextcloud) carry no signature and only receive MarianumConnect pushes.
|
||||
/// Responds 204.
|
||||
class PushDeviceRegister extends MarianumConnectQuery {
|
||||
PushDeviceRegister({super.dio});
|
||||
|
||||
Future<void> run({
|
||||
required String deviceIdentifier,
|
||||
required String deviceIdentifierSignature,
|
||||
required String userPublicKey,
|
||||
String? deviceIdentifierSignature,
|
||||
String? userPublicKey,
|
||||
required String pushToken,
|
||||
required String platform,
|
||||
required String registrationType,
|
||||
@@ -20,12 +22,13 @@ class PushDeviceRegister extends MarianumConnectQuery {
|
||||
endpoint('me/push-device'),
|
||||
data: {
|
||||
'deviceIdentifier': deviceIdentifier,
|
||||
'deviceIdentifierSignature': deviceIdentifierSignature,
|
||||
'userPublicKey': userPublicKey,
|
||||
'deviceIdentifierSignature': ?deviceIdentifierSignature,
|
||||
'userPublicKey': ?userPublicKey,
|
||||
'pushToken': pushToken,
|
||||
'platform': platform,
|
||||
// 'general' | 'talk' — the backend derives the NC hash comparison
|
||||
// value from it (general = sha512(token), talk = sha512(token+'#talk')).
|
||||
// 'direct' — no Nextcloud subscription behind it.
|
||||
'registrationType': registrationType,
|
||||
'appVersion': ?appVersion,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:math';
|
||||
|
||||
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
|
||||
/// 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
|
||||
@@ -21,15 +21,9 @@ class TelemetryDeviceId {
|
||||
_cached = existing;
|
||||
return existing;
|
||||
}
|
||||
final generated = _generate();
|
||||
final generated = randomHexId();
|
||||
await _storage.write(key: _key, value: generated);
|
||||
_cached = 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();
|
||||
}
|
||||
}
|
||||
+13
-8
@@ -2,8 +2,8 @@ import 'dart:developer';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../../../model/account_data.dart';
|
||||
import '../../../demo/demo_mode.dart';
|
||||
import '../../../../session/session.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_params.dart';
|
||||
import '../../../mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart';
|
||||
@@ -26,28 +26,33 @@ class CustomEventsMigration {
|
||||
const CustomEventsMigration._();
|
||||
|
||||
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;
|
||||
|
||||
final epoch = AccountData().sessionEpoch;
|
||||
final epoch = SessionManager().sessionEpoch;
|
||||
try {
|
||||
final response = await GetCustomTimetableEvent(
|
||||
GetCustomTimetableEventParams(AccountData().getUserSecret()),
|
||||
GetCustomTimetableEventParams(session.legacyUserSecret),
|
||||
).run();
|
||||
|
||||
for (final event in response.events) {
|
||||
// The POST authenticates with whoever is signed in now; after a
|
||||
// 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 RemoveCustomTimetableEvent(
|
||||
RemoveCustomTimetableEventParams(event.id),
|
||||
).run();
|
||||
}
|
||||
|
||||
if (!AccountData().isCurrentSession(epoch)) return;
|
||||
if (!SessionManager().isCurrentSession(epoch)) return;
|
||||
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) {
|
||||
// Leave the flag unset so the next launch retries; the delete-after-post
|
||||
// above keeps a partial run duplicate-free.
|
||||
|
||||
+18
@@ -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)},
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import '../model/account_data.dart';
|
||||
import '../session/session_manager.dart';
|
||||
import 'api_response.dart';
|
||||
import 'cache_store.dart';
|
||||
import 'errors/parse_exception.dart';
|
||||
@@ -47,7 +47,7 @@ abstract class RequestCache<T extends ApiResponse?> {
|
||||
static void ignore(Exception e) {}
|
||||
|
||||
Future<void> start(String document) async {
|
||||
final epoch = AccountData().sessionEpoch;
|
||||
final epoch = SessionManager().sessionEpoch;
|
||||
try {
|
||||
final entry = await CacheStore.instance.read(document);
|
||||
var lastUpdate = entry?.lastUpdate ?? 0;
|
||||
@@ -85,7 +85,7 @@ abstract class RequestCache<T extends ApiResponse?> {
|
||||
final newValue = await onLoad();
|
||||
// The cache is shared, so a late response of a signed-out
|
||||
// account would otherwise be cached for the next one.
|
||||
if (!AccountData().isCurrentSession(epoch)) {
|
||||
if (!SessionManager().isCurrentSession(epoch)) {
|
||||
onError(const StaleSessionException());
|
||||
return;
|
||||
}
|
||||
|
||||
+73
-66
@@ -10,14 +10,16 @@ import 'api/marianumconnect/marianumconnect_api.dart';
|
||||
import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
|
||||
import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart';
|
||||
import 'main.dart';
|
||||
import 'model/account_data.dart';
|
||||
import 'model/data_cleaner.dart';
|
||||
import 'notification/notification_controller.dart';
|
||||
import 'notification/notification_service.dart';
|
||||
import 'notification/notification_tasks.dart';
|
||||
import 'push/push_registration.dart';
|
||||
import 'push/push_tap_router.dart';
|
||||
import 'routing/app_routes.dart';
|
||||
import 'session/session_manager.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/breaker/bloc/breaker_bloc.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/timetable/bloc/timetable_bloc.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 'utils/debouncer.dart';
|
||||
import 'utils/haptics.dart';
|
||||
import 'view/pages/overhang.dart';
|
||||
import 'widget/breaker/breaker.dart';
|
||||
import 'widget/info_dialog.dart';
|
||||
import 'widget_data/widget_navigation.dart';
|
||||
import 'widget_data/widget_publisher.dart';
|
||||
import 'widget_data/widget_sync.dart';
|
||||
|
||||
class App extends StatefulWidget {
|
||||
const App({super.key});
|
||||
@@ -41,7 +46,6 @@ class App extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
StreamSubscription<dynamic>? _timetableWidgetSync;
|
||||
StreamSubscription<RemoteMessage>? _onMessageSub;
|
||||
StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
|
||||
StreamSubscription<String>? _fcmTokenRefreshSub;
|
||||
@@ -113,18 +117,11 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
void _onPushTapPending() {
|
||||
final token = PushTapRouter.pendingChatToken.value;
|
||||
if (token == null || !mounted) return;
|
||||
PushTapRouter.pendingChatToken.value = null;
|
||||
NotificationTasks.navigateToTalk(context, chatToken: token);
|
||||
}
|
||||
|
||||
void _onNewsletterTapPending() {
|
||||
final id = PushTapRouter.pendingNewsletterId.value;
|
||||
if (id == null || !mounted) return;
|
||||
PushTapRouter.pendingNewsletterId.value = null;
|
||||
AppRoutes.openNewsletterById(context, id: id);
|
||||
void _onPushTargetPending() {
|
||||
final target = PushTapRouter.pendingTarget.value;
|
||||
if (target == null || !mounted) return;
|
||||
PushTapRouter.pendingTarget.value = null;
|
||||
NotificationTasks.openPushTarget(context, target);
|
||||
}
|
||||
|
||||
Future<void> _handlePendingWidgetNavigation() async {
|
||||
@@ -140,12 +137,43 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
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() {
|
||||
if (!mounted) return;
|
||||
final share = ShareIntentListener.pending.value;
|
||||
if (share == null || ShareIntentListener.instance.isShown(share)) return;
|
||||
// A second share would otherwise leave the previous share-flow page
|
||||
// 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);
|
||||
if (navigator.canPop()) {
|
||||
navigator.popUntil((route) => route.isFirst || route is PopupRoute);
|
||||
@@ -167,53 +195,13 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
if (!mounted) return;
|
||||
context.read<BreakerBloc>().refresh();
|
||||
context.read<ChatListBloc>().refresh();
|
||||
// Re-mounts on every login, so this also covers post-logout state reset.
|
||||
final timetable = context.read<TimetableBloc>();
|
||||
timetable.refresh();
|
||||
// Mirror BLoC updates into the home-screen widget without waiting
|
||||
// for the periodic background refresh.
|
||||
final settingsCubit = context.read<SettingsCubit>();
|
||||
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. No refresh
|
||||
// needed: PrimaryTimetableScope hands out a freshly loading bloc. The
|
||||
// widget still shows its last snapshot, so this waits until the
|
||||
// cold-start frames are done (a bloc emit in the meantime supersedes it).
|
||||
Debouncer.debounce('widgetPublish', const Duration(seconds: 3), () {
|
||||
if (mounted) _publishWidget(context.read<TimetableBloc>());
|
||||
});
|
||||
// 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());
|
||||
ShareIntentListener.pending.addListener(_handlePendingShare);
|
||||
_handlePendingShare();
|
||||
@@ -237,8 +225,12 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
|
||||
// Android renders pushes locally, so a tap arrives via the local
|
||||
// notifications callback (PushTapRouter) rather than onMessageOpenedApp.
|
||||
PushTapRouter.pendingChatToken.addListener(_onPushTapPending);
|
||||
PushTapRouter.pendingNewsletterId.addListener(_onNewsletterTapPending);
|
||||
PushTapRouter.pendingTarget.addListener(_onPushTargetPending);
|
||||
unawaited(
|
||||
PushTapRouter.handleAppLaunch(
|
||||
NotificationService().flutterLocalNotificationsPlugin,
|
||||
),
|
||||
);
|
||||
|
||||
_onMessageSub = FirebaseMessaging.onMessage.listen((message) {
|
||||
if (!mounted) return;
|
||||
@@ -267,12 +259,10 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timetableWidgetSync?.cancel();
|
||||
_onMessageSub?.cancel();
|
||||
_onMessageOpenedAppSub?.cancel();
|
||||
_fcmTokenRefreshSub?.cancel();
|
||||
PushTapRouter.pendingChatToken.removeListener(_onPushTapPending);
|
||||
PushTapRouter.pendingNewsletterId.removeListener(_onNewsletterTapPending);
|
||||
PushTapRouter.pendingTarget.removeListener(_onPushTargetPending);
|
||||
ShareIntentListener.pending.removeListener(_handlePendingShare);
|
||||
Main.bottomNavigator.removeListener(_onTabControllerChanged);
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
@@ -280,7 +270,24 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
@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,
|
||||
) => BlocSelector<SettingsCubit, model.Settings, Object>(
|
||||
// Only the module layout shapes the shell; copied values because the
|
||||
|
||||
@@ -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('=', '');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/// A sign-in link from the guardian login mail
|
||||
/// (`https://<connect-host>/app/guardian-login?rid=…<=…`).
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter/widgets.dart';
|
||||
import 'package:workmanager/workmanager.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_response.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_timegrid/timetable_get_timegrid.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 '../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_publisher.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.
|
||||
static Future<void> runRefreshNow({bool force = false}) async {
|
||||
await WidgetSync.ensureInitialized();
|
||||
bool populated;
|
||||
Session? session;
|
||||
try {
|
||||
// Bounded: a hanging keystore read must not stall the caller's budget
|
||||
// (FCM handler ~25s on iOS) forever.
|
||||
populated = await AccountData().waitForPopulation().timeout(
|
||||
session = await SessionManager().waitForLoad().timeout(
|
||||
const Duration(seconds: 10),
|
||||
);
|
||||
} on TimeoutException {
|
||||
populated = false;
|
||||
session = null;
|
||||
}
|
||||
if (!populated) {
|
||||
if (session == null) {
|
||||
// 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
|
||||
// indistinguishable from "never logged in" here, and blanking the
|
||||
@@ -101,11 +102,23 @@ class WidgetBackgroundTask {
|
||||
return;
|
||||
}
|
||||
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');
|
||||
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 {
|
||||
@@ -126,6 +139,16 @@ bool shouldSkipRefresh({
|
||||
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')
|
||||
void _callbackDispatcher() {
|
||||
Workmanager().executeTask((task, inputData) async {
|
||||
@@ -142,7 +165,7 @@ void _callbackDispatcher() {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
Future<void> _refresh(TimetableSubject subject) async {
|
||||
await WidgetSync.ensureInitialized();
|
||||
// The background isolate doesn't go through main.dart's BlocBuilder, so we
|
||||
// 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
|
||||
// hard time budget). Reference-data failures fall through to null in the
|
||||
// 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,
|
||||
until: weekEndExclusive.subtract(const Duration(days: 1)),
|
||||
until: until,
|
||||
);
|
||||
final subjectsFuture = _runOrNull<TimetableGetSubjectsResponse>(
|
||||
() => TimetableGetSubjects().run(),
|
||||
@@ -181,11 +206,11 @@ Future<void> _refresh() async {
|
||||
final timegridFuture = _runOrNull<TimetableGetTimegridResponse>(
|
||||
() => TimetableGetTimegrid().run(),
|
||||
);
|
||||
final customEventsFuture = _runOrNull<GetCustomTimetableEventResponse>(
|
||||
() => GetCustomTimetableEvent(
|
||||
GetCustomTimetableEventParams(AccountData().getUserSecret()),
|
||||
).run(),
|
||||
);
|
||||
final customEventsFuture = subject.supportsCustomEvents
|
||||
? _runOrNull<GetCustomTimetableEventResponse>(
|
||||
() => TimetableCustomEventsGet().run(),
|
||||
)
|
||||
: Future<GetCustomTimetableEventResponse?>.value();
|
||||
final timetable = await timetableFuture;
|
||||
final subjects = await subjectsFuture;
|
||||
final rooms = await roomsFuture;
|
||||
@@ -195,9 +220,9 @@ Future<void> _refresh() async {
|
||||
|
||||
final lessons = timetable.entries;
|
||||
|
||||
final [connectDouble, isTeacher] = await Future.wait([
|
||||
final [connectDouble, showClassInsteadOfTeacher] = await Future.wait([
|
||||
WidgetSync.getConnectDoubleLessons(),
|
||||
WidgetSync.getIsTeacher(),
|
||||
WidgetSync.getShowClassInsteadOfTeacher(),
|
||||
]);
|
||||
final dayData = WidgetDataMapper.buildDayData(
|
||||
now: now,
|
||||
@@ -208,7 +233,7 @@ Future<void> _refresh() async {
|
||||
timegrid: timegrid,
|
||||
customEvents: customEvents,
|
||||
connectDoubleLessons: connectDouble,
|
||||
showClassInsteadOfTeacher: isTeacher,
|
||||
showClassInsteadOfTeacher: showClassInsteadOfTeacher,
|
||||
);
|
||||
final weekData = WidgetDataMapper.buildWeekData(
|
||||
now: now,
|
||||
@@ -219,9 +244,15 @@ Future<void> _refresh() async {
|
||||
timegrid: timegrid,
|
||||
customEvents: customEvents,
|
||||
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.writeWeekData(weekData);
|
||||
await WidgetSync.setLoggedIn(true);
|
||||
|
||||
+280
-210
@@ -11,12 +11,12 @@ import 'package:flutter/scheduler.dart' show timeDilation;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
||||
import 'package:jiffy/jiffy.dart';
|
||||
import 'package:loader_overlay/loader_overlay.dart';
|
||||
import 'package:path_provider/path_provider.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/marianumconnect/auth/session_validator.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/telemetry_heartbeat/telemetry_heartbeat.dart';
|
||||
import 'app.dart';
|
||||
import 'auth_link/guardian_link_listener.dart';
|
||||
import 'background/widget_background_task.dart';
|
||||
import 'firebase_options.dart';
|
||||
import 'model/account_data.dart';
|
||||
import 'model/session_wipe.dart';
|
||||
import 'notification/notification_service.dart';
|
||||
import 'push/notification_permission_prompt.dart';
|
||||
import 'push/push_message_handler.dart';
|
||||
import 'push/push_registration.dart';
|
||||
import 'push/push_registration_store.dart';
|
||||
import 'push/push_renderer.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 'state/app/modules/account/account_scope.dart';
|
||||
import 'state/app/modules/account/bloc/account_bloc.dart';
|
||||
import 'state/app/modules/account/bloc/account_state.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/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/parent_letters/bloc/parent_letters_bloc.dart';
|
||||
import 'state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import 'state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||
import 'storage/hydrated_storage_bootstrap.dart';
|
||||
import 'storage/account_storage.dart';
|
||||
import 'storage/settings.dart';
|
||||
import 'theming/dark_app_theme.dart';
|
||||
import 'theming/light_app_theme.dart';
|
||||
import 'utils/app_paths.dart';
|
||||
import 'utils/debouncer.dart';
|
||||
import 'utils/downloads/download_manager.dart';
|
||||
import 'view/login/account_loading_screen.dart';
|
||||
import 'view/login/login.dart';
|
||||
@@ -59,6 +63,7 @@ import 'widget/breaker/breaker.dart';
|
||||
import 'widget/debug/debug_stats_overlay.dart';
|
||||
import 'widget/downloads/download_tray.dart';
|
||||
import 'widget/emergency/emergency_notice_gate.dart';
|
||||
import 'widget/info_dialog.dart';
|
||||
import 'widget_data/widget_sync.dart';
|
||||
|
||||
/// Runs one startup step with a time limit. Anything that throws or hangs
|
||||
@@ -119,7 +124,7 @@ void _installErrorHandlers() {
|
||||
Future<void> main() async {
|
||||
log('MarianumMobile started');
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
AccountData().markUiEngine();
|
||||
SessionManager().markUiEngine();
|
||||
// Before any initialisation so startup failures reach the backend too.
|
||||
_installErrorHandlers();
|
||||
|
||||
@@ -146,27 +151,37 @@ Future<void> main() async {
|
||||
]),
|
||||
),
|
||||
_startupStep('hydrated storage', () async {
|
||||
final path = (await getTemporaryDirectory()).path;
|
||||
HydratedBloc.storage = await buildHydratedStorageWithFallback(path);
|
||||
await AccountStorage.init((await getTemporaryDirectory()).path);
|
||||
}, timeout: null),
|
||||
_startupStep('documents dir', () async {
|
||||
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
|
||||
// flips to the real state once the session is readable (see _MainState).
|
||||
_startupStep(
|
||||
'account data',
|
||||
AccountData().waitForPopulation,
|
||||
SessionManager().waitForLoad,
|
||||
timeout: const Duration(seconds: 5),
|
||||
// Expected on every background wake of a locked device; not an error.
|
||||
report: false,
|
||||
),
|
||||
_startupStep('share intent', ShareIntentListener.instance.initialize),
|
||||
_startupStep('guardian link', GuardianLinkListener.instance.initialize),
|
||||
];
|
||||
|
||||
log('starting app initialisation...');
|
||||
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!');
|
||||
|
||||
// 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
|
||||
// cached payload is already present, so this does not undo the day-long
|
||||
// root cache TTL.
|
||||
if (AccountData().isPopulated()) {
|
||||
if (SessionManager().hasNextcloud) {
|
||||
unawaited(
|
||||
ListFilesCache.prefetchRootListing().onError(
|
||||
(e, _) => log('Files root prefetch failed: $e'),
|
||||
@@ -237,34 +252,48 @@ Future<void> main() async {
|
||||
// placeholder flash.
|
||||
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...');
|
||||
runApp(
|
||||
MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<SettingsCubit>(create: (_) => SettingsCubit()),
|
||||
BlocProvider<SettingsCubit>.value(value: settingsCubit),
|
||||
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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
AccountStatus _initialAccountStatus() {
|
||||
final account = AccountData();
|
||||
if (account.isPopulated()) return AccountStatus.loggedIn;
|
||||
return account.isLoaded ? AccountStatus.loggedOut : AccountStatus.undefined;
|
||||
String? _syncedMcBaseUrl;
|
||||
|
||||
/// Keeps the MC dio singleton aligned with the selected endpoint (live /
|
||||
/// 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 {
|
||||
@@ -293,37 +322,74 @@ class _MainState extends State<Main> {
|
||||
Jiffy.setLocale('de');
|
||||
_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;
|
||||
final accountBloc = context.read<AccountBloc>();
|
||||
accountBloc.setStatus(
|
||||
value ? AccountStatus.loggedIn : AccountStatus.loggedOut,
|
||||
);
|
||||
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());
|
||||
if (session == null) {
|
||||
accountBloc.setStatus(AccountStatus.loggedOut);
|
||||
return;
|
||||
}
|
||||
// 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
|
||||
/// background so the first screen render hits populated data.
|
||||
/// Runs whenever the account-scoped tree mounts for a signed-in account:
|
||||
/// 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) {
|
||||
context.read<TimetableBloc>().refresh();
|
||||
unawaited(context.read<ChatListBloc>().refresh(silent: true));
|
||||
unawaited(ListFilesCache.prefetchRootListing());
|
||||
unawaited(context.read<ParentLettersBloc>().refresh(silent: true));
|
||||
if (SessionManager().hasNextcloud) {
|
||||
unawaited(ListFilesCache.prefetchRootListing());
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers/self-heals the push subscription whenever the backend advertises
|
||||
@@ -353,26 +419,83 @@ class _MainState extends State<Main> {
|
||||
) {
|
||||
if (AppModule.applyTeacherDefaults(
|
||||
settings.val().modulesSettings,
|
||||
isTeacher: capabilities.isTeacher,
|
||||
isTeacher: capabilities.state.role == UserRole.teacher,
|
||||
)) {
|
||||
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
|
||||
/// server-side, so the validator wipes the local session and flips the
|
||||
/// account bloc to `loggedOut` (sending the user to the login screen).
|
||||
/// server-side, so the validator signs the account out and the app moves
|
||||
/// on to another stored account or the login screen.
|
||||
void _scheduleSessionValidation(AccountBloc accountBloc) {
|
||||
unawaited(
|
||||
SessionValidator.probeStored(
|
||||
onInvalidated: () async {
|
||||
onInvalidated: (nextAccountId) async {
|
||||
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
|
||||
Widget build(BuildContext context) => Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
@@ -382,7 +505,6 @@ class _MainState extends State<Main> {
|
||||
child: BlocSelector<SettingsCubit, Settings, _RootSettings>(
|
||||
selector: (settings) => (
|
||||
appTheme: settings.appTheme,
|
||||
mcBaseUrl: settings.devToolsSettings.resolveMarianumConnectBaseUrl(),
|
||||
notificationsEnabled: settings.notificationSettings.enabled,
|
||||
showPerformanceOverlay:
|
||||
settings.devToolsSettings.showPerformanceOverlay,
|
||||
@@ -393,12 +515,6 @@ class _MainState extends State<Main> {
|
||||
textScaleOverride: settings.devToolsSettings.textScaleOverride,
|
||||
),
|
||||
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
|
||||
// background isolate and the iOS NSE can suppress rendering when off.
|
||||
unawaited(
|
||||
@@ -407,164 +523,118 @@ class _MainState extends State<Main> {
|
||||
),
|
||||
);
|
||||
timeDilation = root.slowAnimations ? 5.0 : 1.0;
|
||||
return MaterialApp(
|
||||
showPerformanceOverlay: root.showPerformanceOverlay,
|
||||
showSemanticsDebugger: root.showSemanticsDebugger,
|
||||
debugShowCheckedModeBanner: false,
|
||||
navigatorKey: AppRoutes.rootNavigatorKey,
|
||||
// Used by ChatView.didPopNext to reclaim the global ChatBloc.
|
||||
// DownloadRouteObserver tracks full-page navigations so the downloads
|
||||
// chip only surfaces once the user leaves the screen they started on.
|
||||
navigatorObservers: _navigatorObservers,
|
||||
localizationsDelegates: const [
|
||||
...GlobalMaterialLocalizations.delegates,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: const [Locale('de'), Locale('en')],
|
||||
locale: const Locale('de'),
|
||||
title: 'Marianum Fulda',
|
||||
themeMode: root.appTheme,
|
||||
theme: LightAppTheme.theme,
|
||||
darkTheme: DarkAppTheme.theme,
|
||||
// Brand-colored backdrop behind every route. During the logout
|
||||
// home-swap and route pop animations the framework can briefly
|
||||
// expose the layer below the topmost Scaffold; without this
|
||||
// the dark Material default shows through and the user sees a
|
||||
// black flash.
|
||||
builder: (context, child) {
|
||||
Widget app = ColoredBox(
|
||||
color: LightAppTheme.marianumRed,
|
||||
// Downloads tray mounted ABOVE the navigator so its chip floats
|
||||
// over every route (folder views, chat, viewer are full-page
|
||||
// pushes that would otherwise cover it).
|
||||
child: DownloadTrayHost(child: child ?? const SizedBox.shrink()),
|
||||
);
|
||||
final scale = root.textScaleOverride;
|
||||
if (scale > 0) {
|
||||
app = MediaQuery.withClampedTextScaling(
|
||||
minScaleFactor: scale,
|
||||
maxScaleFactor: scale,
|
||||
child: app,
|
||||
);
|
||||
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 DebugStatsOverlay(
|
||||
showFrameStats: root.showFrameStats,
|
||||
showMemoryStats: root.showMemoryStats,
|
||||
child: app,
|
||||
return AccountScope(
|
||||
key: ValueKey(scope),
|
||||
onActive: _onSessionActive,
|
||||
child: _buildApp(root),
|
||||
);
|
||||
},
|
||||
home: EmergencyNoticeGate(
|
||||
child: LoaderOverlay(
|
||||
child: Breaker(
|
||||
breaker: BreakerArea.global,
|
||||
child: BlocConsumer<AccountBloc, AccountState>(
|
||||
listenWhen: (previous, current) =>
|
||||
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) {
|
||||
switch (accountState.status) {
|
||||
case AccountStatus.loggedIn:
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (_appMounted)
|
||||
const App(key: ValueKey('app-shell')),
|
||||
if (_showPostLoginSplash)
|
||||
PostLoginSplash(
|
||||
key: const ValueKey('post-login-splash'),
|
||||
onComplete: () => setState(
|
||||
() => _showPostLoginSplash = false,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
case AccountStatus.loggedOut:
|
||||
return const Login();
|
||||
case AccountStatus.undefined:
|
||||
return const AccountLoadingScreen();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Widget _buildApp(_RootSettings root) => MaterialApp(
|
||||
showPerformanceOverlay: root.showPerformanceOverlay,
|
||||
showSemanticsDebugger: root.showSemanticsDebugger,
|
||||
debugShowCheckedModeBanner: false,
|
||||
navigatorKey: AppRoutes.rootNavigatorKey,
|
||||
// Used by ChatView.didPopNext to reclaim the global ChatBloc.
|
||||
// DownloadRouteObserver tracks full-page navigations so the downloads
|
||||
// chip only surfaces once the user leaves the screen they started on.
|
||||
navigatorObservers: _navigatorObservers,
|
||||
localizationsDelegates: const [
|
||||
...GlobalMaterialLocalizations.delegates,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: const [Locale('de'), Locale('en')],
|
||||
locale: const Locale('de'),
|
||||
title: 'Marianum Fulda',
|
||||
themeMode: root.appTheme,
|
||||
theme: LightAppTheme.theme,
|
||||
darkTheme: DarkAppTheme.theme,
|
||||
// Brand-colored backdrop behind every route. During the logout
|
||||
// home-swap and route pop animations the framework can briefly
|
||||
// expose the layer below the topmost Scaffold; without this
|
||||
// the dark Material default shows through and the user sees a
|
||||
// black flash.
|
||||
builder: (context, child) {
|
||||
Widget app = ColoredBox(
|
||||
color: LightAppTheme.marianumRed,
|
||||
// Downloads tray mounted ABOVE the navigator so its chip floats
|
||||
// over every route (folder views, chat, viewer are full-page
|
||||
// pushes that would otherwise cover it).
|
||||
child: DownloadTrayHost(child: child ?? const SizedBox.shrink()),
|
||||
);
|
||||
final scale = root.textScaleOverride;
|
||||
if (scale > 0) {
|
||||
app = MediaQuery.withClampedTextScaling(
|
||||
minScaleFactor: scale,
|
||||
maxScaleFactor: scale,
|
||||
child: app,
|
||||
);
|
||||
}
|
||||
return DebugStatsOverlay(
|
||||
showFrameStats: root.showFrameStats,
|
||||
showMemoryStats: root.showMemoryStats,
|
||||
child: app,
|
||||
);
|
||||
},
|
||||
home: EmergencyNoticeGate(
|
||||
child: LoaderOverlay(
|
||||
child: Breaker(
|
||||
breaker: BreakerArea.global,
|
||||
child: BlocBuilder<AccountBloc, AccountState>(
|
||||
buildWhen: (previous, current) => previous.status != current.status,
|
||||
builder: (context, accountState) {
|
||||
switch (accountState.status) {
|
||||
case AccountStatus.loggedIn:
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (_appMounted) const App(key: ValueKey('app-shell')),
|
||||
if (_showPostLoginSplash)
|
||||
PostLoginSplash(
|
||||
key: const ValueKey('post-login-splash'),
|
||||
onComplete: () {
|
||||
setState(() => _showPostLoginSplash = false);
|
||||
_promptGuardianNotifications();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
case AccountStatus.loggedOut:
|
||||
return const Login();
|
||||
case AccountStatus.addingAccount:
|
||||
return const Login(addingAccount: true);
|
||||
case AccountStatus.undefined:
|
||||
return const AccountLoadingScreen();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
typedef _RootSettings = ({
|
||||
ThemeMode appTheme,
|
||||
String mcBaseUrl,
|
||||
bool notificationsEnabled,
|
||||
bool showPerformanceOverlay,
|
||||
bool showSemanticsDebugger,
|
||||
|
||||
@@ -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
@@ -4,11 +4,9 @@ import 'dart:io';
|
||||
|
||||
import 'package:firebase_messaging/firebase_messaging.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:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../api/cache_store.dart';
|
||||
import '../api/marianumcloud/talk/share_files_to_chat.dart';
|
||||
import '../background/widget_background_task.dart';
|
||||
import '../notification/notification_service.dart';
|
||||
@@ -18,20 +16,14 @@ import '../push/push_keypair.dart';
|
||||
import '../push/push_tap_router.dart';
|
||||
import '../routing/app_routes.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/downloads/download_manager.dart';
|
||||
import '../utils/file_clipboard.dart';
|
||||
import '../widget_data/widget_sync.dart';
|
||||
|
||||
/// Removes everything the signed-out account left on the device. Every step
|
||||
/// is isolated: one failing step used to skip all later ones and leave the
|
||||
/// previous account's data behind.
|
||||
/// Removes what an account leaves on the device outside its own storage (see
|
||||
/// `AccountStorage` for that). Every step is isolated: one failing step used
|
||||
/// to skip all later ones and leave the previous account's data behind.
|
||||
abstract final class SessionWipe {
|
||||
static Future<void>? _running;
|
||||
|
||||
@@ -39,80 +31,31 @@ abstract final class SessionWipe {
|
||||
/// wipe could clear what the next account just stored (widget job, caches).
|
||||
static Future<void> get done => _running ?? Future.value();
|
||||
|
||||
/// State that must be gone before the next frame (a login screen can be
|
||||
/// reached right away): pending navigation and in-memory singletons.
|
||||
/// State that must be gone before the next frame (a login screen or another
|
||||
/// account can be reached right away): pending navigation and in-memory
|
||||
/// singletons.
|
||||
static void clearImmediate() {
|
||||
_step('share intents', ShareIntentListener.instance.clearAll);
|
||||
_step('share folder cache', resetTalkShareFolderCache);
|
||||
_step('pending navigation', () {
|
||||
AppRoutes.pendingChatToken.value = null;
|
||||
PushTapRouter.pendingChatToken.value = null;
|
||||
PushTapRouter.pendingNewsletterId.value = null;
|
||||
PushTapRouter.pendingTarget.value = null;
|
||||
});
|
||||
_step('file clipboard', FileClipboard.instance.clear);
|
||||
_step('downloads', DownloadManager.instance.clearAll);
|
||||
}
|
||||
|
||||
static Future<void> run({
|
||||
required TimetableBloc timetableBloc,
|
||||
required ChatListBloc chatListBloc,
|
||||
required ChatBloc chatBloc,
|
||||
required BreakerBloc breakerBloc,
|
||||
required CapabilitiesCubit capabilitiesCubit,
|
||||
required NextcloudCapabilitiesCubit nextcloudCapabilitiesCubit,
|
||||
}) {
|
||||
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();
|
||||
});
|
||||
/// Runs whenever the active account stops being active (sign-out or
|
||||
/// switch). The tray and its bookkeeping belong to that 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 or switch could not
|
||||
/// unregister it).
|
||||
static Future<void> accountLeft() async {
|
||||
await _stepAsync('download files', () async {
|
||||
final dir = Directory('${(await getTemporaryDirectory()).path}/downloads');
|
||||
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(
|
||||
'notification tray',
|
||||
NotificationService().flutterLocalNotificationsPlugin.cancelAll,
|
||||
@@ -121,8 +64,35 @@ abstract final class SessionWipe {
|
||||
await _stepAsync('push nid store', NidStore().clear);
|
||||
await _stepAsync('push thread store', ChatThreadStore().clearAll);
|
||||
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
|
||||
// wake up every 30 minutes only to write `loggedIn=false`. Re-registers
|
||||
// on the next successful login.
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.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_list/bloc/chat_list_bloc.dart';
|
||||
import '../utils/debouncer.dart';
|
||||
@@ -51,18 +51,15 @@ class NotificationController {
|
||||
RemoteMessage message,
|
||||
BuildContext context,
|
||||
) async {
|
||||
final newsletterId = _extractNewsletterId(message);
|
||||
if (newsletterId != null) {
|
||||
AppRoutes.openNewsletterById(
|
||||
final target = resolvePushTarget(message.data);
|
||||
if (target != null) {
|
||||
NotificationTasks.openPushTarget(
|
||||
context,
|
||||
id: newsletterId,
|
||||
target,
|
||||
title: message.notification?.title,
|
||||
);
|
||||
} else {
|
||||
NotificationTasks.navigateToTalk(
|
||||
context,
|
||||
chatToken: _extractChatToken(message),
|
||||
);
|
||||
NotificationTasks.navigateToTalk(context);
|
||||
}
|
||||
NotificationTasks.updateProviders(context);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,13 @@ import 'package:flutter_app_badge/flutter_app_badge.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../push/chat_thread_store.dart';
|
||||
import '../push/push_renderer.dart';
|
||||
import '../push/push_target.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/parent_letters/bloc/parent_letters_bloc.dart';
|
||||
import 'notification_service.dart';
|
||||
|
||||
class NotificationTasks {
|
||||
@@ -70,12 +75,50 @@ class NotificationTasks {
|
||||
/// even if the user has already left.
|
||||
static void updateProviders(BuildContext context) {
|
||||
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
|
||||
/// the matching chat to be opened automatically once the chat list view
|
||||
/// resolves the token (handled inside [ChatList]).
|
||||
static void navigateToTalk(BuildContext context, {String? chatToken}) {
|
||||
if (!AppModule.isAvailableFor(Modules.talk, SessionManager().current)) {
|
||||
return;
|
||||
}
|
||||
if (chatToken != null && chatToken.isNotEmpty) {
|
||||
AppRoutes.openChatByToken(context, chatToken);
|
||||
} else {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../api/marianumcloud/nextcloud_ocs.dart';
|
||||
import '../model/account_data.dart';
|
||||
import '../notification/notification_service.dart';
|
||||
import '../session/session_manager.dart';
|
||||
import 'chat_thread_store.dart';
|
||||
import 'nid_store.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
|
||||
/// background isolate spawned by flutter_local_notifications, so it may not
|
||||
/// 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
|
||||
/// on [handleBackgroundResponse]: the callback is resolved via
|
||||
@@ -56,14 +56,14 @@ class PushActions {
|
||||
) async {
|
||||
// The FLN action isolate starts WITHOUT main(): unlike the FCM background
|
||||
// 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
|
||||
// runs forever.
|
||||
DartPluginRegistrant.ensureInitialized();
|
||||
// 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
|
||||
// engine first started.
|
||||
await AccountData().reloadFromStorage();
|
||||
await SessionManager().reloadFromStorage();
|
||||
|
||||
_plog(
|
||||
'action=${response.actionId} payload=${response.payload} '
|
||||
@@ -129,7 +129,8 @@ class PushActions {
|
||||
/// any) followed by the technical reason.
|
||||
static String actionFailureBody({String? lostText, required String detail}) {
|
||||
return [
|
||||
if (lostText != null && lostText.isNotEmpty) 'Deine Nachricht: „$lostText“',
|
||||
if (lostText != null && lostText.isNotEmpty)
|
||||
'Deine Nachricht: „$lostText“',
|
||||
'Grund: $detail',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -192,7 +193,10 @@ class PushActions {
|
||||
static Future<({bool ok, String detail})> sendReply(
|
||||
String chatToken,
|
||||
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) =>
|
||||
_ocsPost('apps/spreed/api/v1/chat/$chatToken/read');
|
||||
@@ -204,10 +208,10 @@ class PushActions {
|
||||
try {
|
||||
// Bounded: a hanging population (e.g. keystore issue) must fail the
|
||||
// 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),
|
||||
);
|
||||
if (!populated) {
|
||||
if (session?.nextcloud == null) {
|
||||
_plog('Push action $path aborted: credentials unreadable in isolate');
|
||||
return (
|
||||
ok: false,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import 'package:crypton/crypton.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
|
||||
import '../background/widget_background_task.dart';
|
||||
import '../model/account_data.dart';
|
||||
import '../notification/notification_service.dart';
|
||||
import '../session/session_manager.dart';
|
||||
import 'chat_thread_store.dart';
|
||||
import 'nid_store.dart';
|
||||
import 'push_decryptor.dart';
|
||||
@@ -56,7 +56,7 @@ PushKind classifyPush(Map<String, dynamic> data) {
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> pushOnBackgroundMessage(RemoteMessage message) async {
|
||||
// This engine outlives sign-outs and logins in the app.
|
||||
await AccountData().reloadFromStorage();
|
||||
await SessionManager().reloadFromStorage();
|
||||
await NotificationService().initializeNotifications();
|
||||
await PushRenderer.ensureChannels();
|
||||
await PushMessageHandler().handle(message);
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'dart:io';
|
||||
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:nextcloud/notifications.dart' show generatePushTokenHash;
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import '../api/demo/demo_mode.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/queries/push_device_register/push_device_register.dart';
|
||||
import '../api/marianumconnect/queries/push_device_unregister/push_device_unregister.dart';
|
||||
import '../model/account_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 'push_device_info.dart';
|
||||
import 'push_keypair.dart';
|
||||
import 'push_registration_store.dart';
|
||||
import 'push_registration_type.dart';
|
||||
@@ -48,8 +50,6 @@ class PushRegistration {
|
||||
_store = store ?? const PushRegistrationStore(),
|
||||
_nextcloud = nextcloud ?? NextcloudPushApi();
|
||||
|
||||
String get _platform => Platform.isIOS ? 'ios' : 'android';
|
||||
|
||||
String get _talkUserAgent =>
|
||||
Platform.isIOS ? talkUserAgentIos : talkUserAgentAndroid;
|
||||
|
||||
@@ -63,20 +63,29 @@ class PushRegistration {
|
||||
/// slash) — persisted alongside the registration to detect endpoint changes.
|
||||
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
|
||||
/// registration binds to it, so it must be obtained before registering.
|
||||
Future<void> ensureAppPassword() async {
|
||||
if (AccountData().hasAppPassword()) return;
|
||||
if (AccountData().usesLoginFlow) {
|
||||
final nextcloud = _nextcloudOrNull;
|
||||
if (nextcloud == null || nextcloud.hasAppPassword) return;
|
||||
if (nextcloud.usesLoginFlow) {
|
||||
// Flow-Konten (2FA): Basic Auth mit dem echten Passwort wird abgelehnt,
|
||||
// stilles Minting ist unmöglich. Reparatur nur interaktiv über
|
||||
// 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;
|
||||
}
|
||||
try {
|
||||
final appPassword = await GetAppPassword().run();
|
||||
await AccountData().setAppPassword(appPassword);
|
||||
await SessionManager().setAppPassword(appPassword);
|
||||
} on Object catch (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
|
||||
/// (each `getapppassword` call with the real password mints a fresh one).
|
||||
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
|
||||
// Talk-Passwort kommt nur aus dem zweiten Login-Flow-Durchlauf; bis dahin
|
||||
// teilt sich die Talk-Registrierung das eine App-Passwort (siehe
|
||||
// AccountData.getTalkBasicAuthHeader).
|
||||
if (AccountData().usesLoginFlow) return;
|
||||
// NextcloudCredentials.talkBasicAuthHeader).
|
||||
if (nextcloud.usesLoginFlow) return;
|
||||
try {
|
||||
final appPassword = await GetAppPassword().run();
|
||||
await AccountData().setAppPasswordTalk(appPassword);
|
||||
await SessionManager().setAppPasswordTalk(appPassword);
|
||||
} on Object catch (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).
|
||||
Future<bool> register() async {
|
||||
if (DemoMode.active) return false;
|
||||
final epoch = AccountData().sessionEpoch;
|
||||
if (_nextcloudOrNull == null) return _direct.register();
|
||||
final epoch = SessionManager().sessionEpoch;
|
||||
final String? fcmToken;
|
||||
try {
|
||||
fcmToken = await FirebaseMessaging.instance.getToken();
|
||||
@@ -135,16 +146,13 @@ class PushRegistration {
|
||||
return false;
|
||||
}
|
||||
|
||||
String? appVersion;
|
||||
try {
|
||||
appVersion = (await PackageInfo.fromPlatform()).version;
|
||||
} on Object {
|
||||
appVersion = null;
|
||||
}
|
||||
final appVersion = await pushAppVersion();
|
||||
|
||||
// Re-read: the ensure* calls above may have swapped the credentials.
|
||||
final nextcloud = SessionManager().requireNextcloud();
|
||||
final types = registrationTypesFor(
|
||||
usesLoginFlow: AccountData().usesLoginFlow,
|
||||
hasTalkAppPassword: AccountData().hasAppPasswordTalk(),
|
||||
usesLoginFlow: nextcloud.usesLoginFlow,
|
||||
hasTalkAppPassword: nextcloud.hasAppPasswordTalk,
|
||||
);
|
||||
if (!types.contains(PushRegistrationType.general)) {
|
||||
await _recordAttempt(
|
||||
@@ -183,7 +191,7 @@ class PushRegistration {
|
||||
devicePublicKeyPem: pems.publicKeyPem,
|
||||
proxyServer: proxyServer,
|
||||
authorizationHeader: isTalk
|
||||
? AccountData().getTalkBasicAuthHeader()
|
||||
? SessionManager().requireNextcloud().talkBasicAuthHeader
|
||||
: null,
|
||||
userAgent: isTalk ? _talkUserAgent : null,
|
||||
);
|
||||
@@ -191,7 +199,7 @@ class PushRegistration {
|
||||
// Signed out while registering: persisting or announcing this
|
||||
// registration would keep delivering the previous account's pushes,
|
||||
// and logoutCleanup already ran so nothing would unregister it.
|
||||
if (!AccountData().isCurrentSession(epoch)) return false;
|
||||
if (!SessionManager().isCurrentSession(epoch)) return false;
|
||||
|
||||
await _store.save(
|
||||
type: type,
|
||||
@@ -207,7 +215,7 @@ class PushRegistration {
|
||||
deviceIdentifierSignature: registration.signature,
|
||||
userPublicKey: registration.publicKey,
|
||||
pushToken: fcmToken,
|
||||
platform: _platform,
|
||||
platform: pushPlatform,
|
||||
registrationType: type.wireName,
|
||||
appVersion: appVersion,
|
||||
);
|
||||
@@ -256,7 +264,7 @@ class PushRegistration {
|
||||
try {
|
||||
final endpoint = EndpointData().nextcloud();
|
||||
await _store.saveNativeAuthContext(
|
||||
username: AccountData().getUsername(),
|
||||
username: SessionManager().requireNextcloud().username,
|
||||
baseUrl: 'https://${endpoint.full()}',
|
||||
);
|
||||
} on Object catch (e) {
|
||||
@@ -274,7 +282,7 @@ class PushRegistration {
|
||||
// session token — each registration with its own app password.
|
||||
await _nextcloud.unregister(
|
||||
authorizationHeader: type == PushRegistrationType.talk
|
||||
? AccountData().getTalkBasicAuthHeader()
|
||||
? SessionManager().requireNextcloud().talkBasicAuthHeader
|
||||
: null,
|
||||
);
|
||||
} on Object catch (e) {
|
||||
@@ -423,12 +431,21 @@ class PushRegistration {
|
||||
/// first, then the proxy) with the new token.
|
||||
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
|
||||
/// (each authenticated with itself — the endpoint revokes the credential it
|
||||
/// is called with), then clear them locally. Ordered so the proxy stops
|
||||
/// pushing before credentials are gone.
|
||||
Future<void> logoutCleanup() async {
|
||||
if (DemoMode.active) return;
|
||||
if (_nextcloudOrNull == null) return _direct.unregister();
|
||||
await unregister();
|
||||
try {
|
||||
await DeleteAppPassword().run();
|
||||
@@ -436,15 +453,16 @@ class PushRegistration {
|
||||
log('Push: delete app password failed: $e');
|
||||
}
|
||||
try {
|
||||
if (AccountData().hasAppPasswordTalk()) {
|
||||
final nextcloud = SessionManager().requireNextcloud();
|
||||
if (nextcloud.hasAppPasswordTalk) {
|
||||
await DeleteAppPassword().run(
|
||||
authorizationHeader: AccountData().getTalkBasicAuthHeader(),
|
||||
authorizationHeader: nextcloud.talkBasicAuthHeader,
|
||||
);
|
||||
}
|
||||
} on Object catch (e) {
|
||||
log('Push: delete talk app password failed: $e');
|
||||
}
|
||||
await AccountData().clearAppPassword();
|
||||
await AccountData().clearAppPasswordTalk();
|
||||
await SessionManager().clearAppPassword();
|
||||
await SessionManager().clearAppPasswordTalk();
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ class PushRegistrationStore {
|
||||
// (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
|
||||
// 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 _baseUrlKey = 'nextcloud_base_url';
|
||||
// Mirror of the in-app notification toggle (`notificationSettings.enabled`),
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'nid_store.dart';
|
||||
import 'push_actions.dart';
|
||||
import 'push_avatar.dart';
|
||||
import 'push_subject.dart';
|
||||
import 'push_target.dart';
|
||||
|
||||
/// Renders decrypted push subjects (and plaintext Connect pushes) as local
|
||||
/// notifications. Talk messages of one chat stack into a SINGLE
|
||||
@@ -23,6 +24,8 @@ class PushRenderer {
|
||||
static const talkChannelName = 'Talk-Nachrichten';
|
||||
static const generalChannelId = 'nextcloud_general';
|
||||
static const generalChannelName = 'Benachrichtigungen';
|
||||
static const parentLettersChannelId = 'parent_letters';
|
||||
static const parentLettersChannelName = 'Elternbriefe';
|
||||
|
||||
static const String iosTalkCategory = 'TALK_MESSAGE';
|
||||
|
||||
@@ -67,6 +70,14 @@ class PushRenderer {
|
||||
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.
|
||||
@@ -350,23 +361,35 @@ class PushRenderer {
|
||||
required String body,
|
||||
Map<String, String>? data,
|
||||
}) async {
|
||||
final id = _fallbackId('$title$body');
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
generalChannelId,
|
||||
generalChannelName,
|
||||
final parentLetterId = data?[parentLetterIdKey];
|
||||
final isParentLetter =
|
||||
data?['type'] == parentLetterPushType &&
|
||||
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,
|
||||
priority: Priority.high,
|
||||
color: _accentColor,
|
||||
styleInformation: isParentLetter ? BigTextStyleInformation(body) : null,
|
||||
);
|
||||
await _plugin.show(
|
||||
id: id,
|
||||
title: title,
|
||||
body: body,
|
||||
notificationDetails: const NotificationDetails(android: androidDetails),
|
||||
notificationDetails: NotificationDetails(android: androidDetails),
|
||||
payload: data == null ? null : jsonEncode(data),
|
||||
);
|
||||
}
|
||||
|
||||
static int parentLetterNotificationId(String letterId) =>
|
||||
stableChatNotificationId('parent-letter:$letterId');
|
||||
|
||||
String _payload({required String? chatToken, required int nid}) =>
|
||||
jsonEncode({'chatToken': ?chatToken, 'nid': nid});
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ const IOSOptions kPushIosOptions = IOSOptions(
|
||||
);
|
||||
|
||||
/// 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.
|
||||
const FlutterSecureStorage pushSecureStorage = FlutterSecureStorage(
|
||||
iOptions: kPushIosOptions,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../model/account_data.dart';
|
||||
import '../session/session_manager.dart';
|
||||
import 'push_keypair.dart';
|
||||
import 'push_registration.dart';
|
||||
import 'push_registration_store.dart';
|
||||
@@ -124,14 +124,15 @@ Future<PushStatusReport> collectPushStatus({
|
||||
lastRegistrationError: await store.lastRegistrationError(type),
|
||||
);
|
||||
|
||||
final nextcloud = SessionManager().current?.nextcloud;
|
||||
return PushStatusReport(
|
||||
settingEnabled: settingEnabled,
|
||||
osPermission: await _osPermission(),
|
||||
serverCapability: !capabilitiesLoaded
|
||||
? PushCheck.unknown
|
||||
: (capabilityPush ? PushCheck.ok : PushCheck.fail),
|
||||
appPasswordPresent: AccountData().hasAppPassword(),
|
||||
talkAppPasswordPresent: AccountData().hasAppPasswordTalk(),
|
||||
appPasswordPresent: nextcloud?.hasAppPassword ?? false,
|
||||
talkAppPasswordPresent: nextcloud?.hasAppPasswordTalk ?? false,
|
||||
keypairPresent: (await keypair.loadPublicKeyPem())?.isNotEmpty ?? false,
|
||||
general: await typeStatus(PushRegistrationType.general),
|
||||
talk: await typeStatus(PushRegistrationType.talk),
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
import 'push_actions.dart';
|
||||
import 'push_target.dart';
|
||||
|
||||
/// Routes foreground notification interactions from the single
|
||||
/// flutter_local_notifications response callback. Action responses (reply /
|
||||
/// 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 {
|
||||
PushTapRouter._();
|
||||
|
||||
/// Chat token of the most recently tapped Talk notification, or null. [App]
|
||||
/// listens to this and opens the chat, then resets it to null.
|
||||
static final ValueNotifier<String?> pendingChatToken = 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);
|
||||
/// Target of the most recently tapped notification, or null. [App] listens
|
||||
/// to this and navigates, then resets it to null.
|
||||
static final ValueNotifier<PushTarget?> pendingTarget = ValueNotifier(null);
|
||||
|
||||
static void handleResponse(NotificationResponse response) {
|
||||
final actionId = response.actionId;
|
||||
@@ -29,13 +27,28 @@ class PushTapRouter {
|
||||
}
|
||||
final map = _payloadMap(response.payload);
|
||||
if (map == null) return;
|
||||
final newsletterId = _stringValue(map, 'newsletterId');
|
||||
if (newsletterId != null) {
|
||||
pendingNewsletterId.value = newsletterId;
|
||||
return;
|
||||
final target = resolvePushTarget(map);
|
||||
if (target != null) pendingTarget.value = target;
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -46,9 +59,4 @@ class PushTapRouter {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static String? _stringValue(Map<String, dynamic> map, String key) {
|
||||
final value = map[key];
|
||||
return value is String && value.isNotEmpty ? value : null;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import '../api/marianumcloud/talk/room/get_room_response.dart';
|
||||
import '../api/marianumconnect/marianumconnect_endpoint.dart';
|
||||
import '../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
|
||||
import '../main.dart';
|
||||
import '../model/account_data.dart';
|
||||
import '../notification/notification_tasks.dart';
|
||||
import '../session/session_manager.dart';
|
||||
import '../share_intent/pending_share.dart';
|
||||
import '../share_intent/remote_file_ref.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/roomplan/roomplan.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/diagnostics_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.
|
||||
/// the downloads tray, which lives above the navigator in `MaterialApp.builder`
|
||||
/// 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>();
|
||||
|
||||
/// 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
|
||||
/// screen. Used for deep links from outside the ticker module — inside the
|
||||
/// module pages open in-place instead.
|
||||
@@ -395,7 +404,10 @@ class AppRoutes {
|
||||
// ChatBloc._loadChat with the freshly-fetched maxId — sending one
|
||||
// here too with the chat list's possibly-stale room.lastMessage.id
|
||||
// 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);
|
||||
TalkNavigator.pushSplitView(
|
||||
context,
|
||||
@@ -425,7 +437,8 @@ class AppRoutes {
|
||||
static ResolvedPendingChat? resolvePendingChat(BuildContext context) {
|
||||
final token = pendingChatToken.value;
|
||||
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 room = _findRoomByToken(rooms, token);
|
||||
@@ -438,7 +451,7 @@ class AppRoutes {
|
||||
);
|
||||
return ResolvedPendingChat(
|
||||
room: room,
|
||||
selfId: AccountData().getUsername(),
|
||||
selfId: nextcloud.username,
|
||||
avatar: avatar,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -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'))}';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
|
||||
|
||||
import '../auth_link/guardian_login_link.dart';
|
||||
import 'pending_share.dart';
|
||||
|
||||
/// Bridges native share intents (Android ACTION_SEND, iOS Share Extension)
|
||||
@@ -136,6 +137,16 @@ class ShareIntentListener {
|
||||
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) {
|
||||
if (items.isEmpty) return null;
|
||||
final files = <String>[];
|
||||
@@ -150,7 +161,7 @@ class ShareIntentListener {
|
||||
if (File(item.path).existsSync()) files.add(item.path);
|
||||
case SharedMediaType.text:
|
||||
case SharedMediaType.url:
|
||||
texts.add(item.path);
|
||||
if (!isAppLink(item.path)) texts.add(item.path);
|
||||
}
|
||||
}
|
||||
if (files.isEmpty && texts.isEmpty) return null;
|
||||
|
||||
+21
-3
@@ -3,9 +3,10 @@ import 'dart:developer';
|
||||
|
||||
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
||||
|
||||
import '../../../../../access/access_requirement.dart';
|
||||
import '../../../../../api/errors/error_mapper.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 '../../loadable_state/loadable_state.dart';
|
||||
import '../../loadable_state/loading_error.dart';
|
||||
@@ -126,18 +127,34 @@ abstract class LoadableHydratedBloc<
|
||||
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;
|
||||
|
||||
/// Runs [body] tagged with the current session: events it adds, also from
|
||||
/// its async continuations, are dropped once the account signed out, so a
|
||||
/// late response of the previous account cannot refill the reset bloc.
|
||||
R runInSession<R>(R Function() body) =>
|
||||
runZoned(body, zoneValues: {_sessionKey: AccountData().sessionEpoch});
|
||||
runZoned(body, zoneValues: {_sessionKey: SessionManager().sessionEpoch});
|
||||
|
||||
@override
|
||||
void add(LoadableHydratedBlocEvent<TState> event) {
|
||||
if (!ownsSession) return;
|
||||
final epoch = Zone.current[_sessionKey];
|
||||
if (epoch is int && !AccountData().isCurrentSession(epoch)) return;
|
||||
if (epoch is int && !SessionManager().isCurrentSession(epoch)) return;
|
||||
super.add(event);
|
||||
}
|
||||
|
||||
@@ -173,6 +190,7 @@ abstract class LoadableHydratedBloc<
|
||||
final SessionSingleFlight _fetchFlight = SessionSingleFlight();
|
||||
|
||||
void fetch() {
|
||||
if (!requirementsMet || !ownsSession) return;
|
||||
unawaited(
|
||||
_fetchFlight.run(() {
|
||||
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';
|
||||
|
||||
class AccountBloc extends Bloc<AccountEvent, AccountState> {
|
||||
AccountBloc({AccountStatus initialStatus = AccountStatus.undefined})
|
||||
: super(AccountState(status: initialStatus)) {
|
||||
AccountBloc({
|
||||
AccountStatus initialStatus = AccountStatus.undefined,
|
||||
String? accountId,
|
||||
}) : super(AccountState(status: initialStatus, accountId: accountId)) {
|
||||
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));
|
||||
|
||||
/// [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
Reference in new issue
Block a user