Compare commits
24
Commits
2ca9feb49a
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9cb6a8af1 | ||
|
|
fcfd21bb4d | ||
|
|
aa176c90e3 | ||
|
|
952f916231 | ||
|
|
9ea0eae53c | ||
|
|
4a4e101532 | ||
|
|
23e4510bf8 | ||
|
|
c5592f117b | ||
|
|
ada4d6a77c | ||
|
|
cd88cc75fc | ||
|
|
279b973e89 | ||
|
|
b90cda62da | ||
|
|
a79b376bb3 | ||
|
|
eddd294a34 | ||
|
|
c455bb1cae | ||
|
|
aca86c506a | ||
|
|
2664378b17 | ||
|
|
ee1240f71a | ||
|
|
3e856d0b1c | ||
|
|
8eddc8f771 | ||
|
|
27563a6dc1 | ||
|
|
5e65c4671a | ||
|
|
79b33ab248 | ||
|
|
1fd6264d10 |
No files matched your search
@@ -362,3 +362,9 @@ android/fastlane/README.md.bak
|
|||||||
# Screenshots unter materials/screenshots/. Nur die leeren Ordner (.gitkeep)
|
# Screenshots unter materials/screenshots/. Nur die leeren Ordner (.gitkeep)
|
||||||
# bleiben eingecheckt, damit supply die Struktur vorfindet.
|
# bleiben eingecheckt, damit supply die Struktur vorfindet.
|
||||||
android/fastlane/metadata/android/**/images/**/*.png
|
android/fastlane/metadata/android/**/images/**/*.png
|
||||||
|
|
||||||
|
# Fastlane (App-Store-Upload)
|
||||||
|
ios/fastlane/report.xml
|
||||||
|
ios/fastlane/Preview.html
|
||||||
|
ios/fastlane/app-store-connect-api-key.json
|
||||||
|
ios/fastlane/screenshots/**/*.jpg
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# MarianumMobile Client
|
# MarianumMobile Client
|
||||||
|
|
||||||
Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Files, Custom MHSL-Backend (Breaker, Custom Events, Push).
|
Flutter-App für die Schul-Community: Stundenplan, Ticker, Newsletter & Co. über das MarianumConnect-Backend, Nextcloud Talk + Files.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -16,7 +16,8 @@ Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Fil
|
|||||||
|
|
||||||
```
|
```
|
||||||
lib/
|
lib/
|
||||||
├── api/ HTTP-Layer pro Backend (mhsl/, marianumcloud/, webuntis/, holidays/)
|
├── api/ HTTP-Layer pro Backend (marianumconnect/, marianumcloud/, mhsl/ Legacy, demo/)
|
||||||
|
├── session/ Session-Modell, SessionManager, SessionLifecycle, Konten-Index
|
||||||
├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...)
|
├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...)
|
||||||
├── state/app/infrastructure LoadableState<T>, DataLoader, geteilte BLoC-Bausteine
|
├── state/app/infrastructure LoadableState<T>, DataLoader, geteilte BLoC-Bausteine
|
||||||
├── view/ Screens
|
├── view/ Screens
|
||||||
@@ -51,6 +52,10 @@ lib/
|
|||||||
|
|
||||||
**Settings:** Pro Feature ein Freezed-Modell unter `lib/storage/`, persistiert via HydratedBloc.
|
**Settings:** Pro Feature ein Freezed-Modell unter `lib/storage/`, persistiert via HydratedBloc.
|
||||||
|
|
||||||
|
**Session:** Die aktive Sitzung liegt in `SessionManager().current` (`lib/session/`). Nextcloud-Zugriffe nur über `SessionManager().requireNextcloud()`. 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.
|
||||||
|
|
||||||
## Build / Run
|
## Build / Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -65,13 +70,12 @@ flutter test # Tests (siehe test
|
|||||||
|
|
||||||
| Backend | Pfad | Zweck |
|
| Backend | Pfad | Zweck |
|
||||||
|---------------------------|-----------------------|----------------------------------------|
|
|---------------------------|-----------------------|----------------------------------------|
|
||||||
| Webuntis | `lib/api/webuntis/` | Stundenplan, Klassen, Räume, Lehrer |
|
| MarianumConnect (Bearer) | `lib/api/marianumconnect/` | Auth, Stundenplan (Webuntis-Proxy), Ticker, Newsletter, Ferien, Abwesenheit, Capabilities, Push |
|
||||||
| Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung |
|
| Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung |
|
||||||
| Custom MHSL-Server | `lib/api/mhsl/` | Breaker, Custom Events, Notify, Noten |
|
| MHSL (Legacy) | `lib/api/mhsl/` | nur noch Einmal-Migration der Custom Events |
|
||||||
| Holiday-Calendar | `lib/api/holidays/` | Ferien |
|
|
||||||
|
|
||||||
`nextcloud`-Paket ist auf einen Custom-Fork gepinnt (siehe `pubspec.yaml` `dependency_overrides`).
|
`nextcloud`-Paket ist auf einen Custom-Fork gepinnt (siehe `pubspec.yaml` `dependency_overrides`).
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
`test/` deckt aktuell nur Kern-Funktionen ab (DateTime-Extensions, AsyncActionController, LessonResolver). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu.
|
`test/` deckt vor allem pure Funktionen ab (DateTime-Extensions, Stundenplan-Logik, Session-Codec). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu.
|
||||||
@@ -4,4 +4,5 @@ targets:
|
|||||||
json_serializable:
|
json_serializable:
|
||||||
options:
|
options:
|
||||||
explicit_to_json: false
|
explicit_to_json: false
|
||||||
|
checked: true
|
||||||
generic_argument_factories: true
|
generic_argument_factories: true
|
||||||
@@ -17,7 +17,8 @@ import 'package:marianum_mobile/view/pages/talk/widgets/chat_tile.dart';
|
|||||||
/// Stundenplan (hell + dunkel), Talk-Liste, Talk-Chat, Dateien, „Mehr"-Bereich
|
/// Stundenplan (hell + dunkel), Talk-Liste, Talk-Chat, Dateien, „Mehr"-Bereich
|
||||||
/// und Einstellungen. Ausgeführt über `flutter drive` mit
|
/// und Einstellungen. Ausgeführt über `flutter drive` mit
|
||||||
/// test_driver/integration_test.dart, orchestriert von tool/screenshots.sh
|
/// test_driver/integration_test.dart, orchestriert von tool/screenshots.sh
|
||||||
/// (Phone / 7″ / 10″).
|
/// (Android: Phone / 7″ / 10″) bzw. tool/screenshots_ios.sh (iPhone 6,5″ /
|
||||||
|
/// iPad 13″).
|
||||||
///
|
///
|
||||||
/// Kein Netzwerk: der Demo-Modus beantwortet allen Backend-Verkehr aus
|
/// Kein Netzwerk: der Demo-Modus beantwortet allen Backend-Verkehr aus
|
||||||
/// Fixtures, daher ist der Lauf deterministisch.
|
/// Fixtures, daher ist der Lauf deterministisch.
|
||||||
@@ -42,11 +43,13 @@ Future<void> main() async {
|
|||||||
_log('Login');
|
_log('Login');
|
||||||
await _login(tester);
|
await _login(tester);
|
||||||
|
|
||||||
|
// Großzügig: auf einem frisch angelegten iOS-Simulator dauert der erste
|
||||||
|
// Post-Login-Aufbau deutlich länger als 20s.
|
||||||
_log('warte auf App-Shell');
|
_log('warte auf App-Shell');
|
||||||
await _pumpUntil(
|
await _pumpUntil(
|
||||||
tester,
|
tester,
|
||||||
find.byType(App),
|
find.byType(App),
|
||||||
timeout: const Duration(seconds: 20),
|
timeout: const Duration(seconds: 60),
|
||||||
);
|
);
|
||||||
_log('warte auf Post-Login-Splash-Ende');
|
_log('warte auf Post-Login-Splash-Ende');
|
||||||
await _pumpUntilGone(
|
await _pumpUntilGone(
|
||||||
@@ -64,6 +67,12 @@ Future<void> main() async {
|
|||||||
await binding.convertFlutterSurfaceToImage();
|
await binding.convertFlutterSurfaceToImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Den einmaligen Benachrichtigungs-Dialog beim ersten Talk-Besuch
|
||||||
|
// unterdrücken. Auf Android erteilt tool/screenshots.sh die Permission
|
||||||
|
// vorab, auf iOS lässt sie sich im Simulator nicht vorab erteilen – der
|
||||||
|
// Dialog würde sonst über der Talk-Liste liegen.
|
||||||
|
_markTalkPermissionPromptShown(tester);
|
||||||
|
|
||||||
// 1 + 2: Stundenplan in beiden Themes.
|
// 1 + 2: Stundenplan in beiden Themes.
|
||||||
await _goTo(tester, Modules.timetable);
|
await _goTo(tester, Modules.timetable);
|
||||||
_setTheme(tester, ThemeMode.light);
|
_setTheme(tester, ThemeMode.light);
|
||||||
@@ -129,6 +138,16 @@ void _setTheme(WidgetTester tester, ThemeMode mode) {
|
|||||||
context.read<SettingsCubit>().val(write: true).appTheme = mode;
|
context.read<SettingsCubit>().val(write: true).appTheme = mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _markTalkPermissionPromptShown(WidgetTester tester) {
|
||||||
|
final context = tester.element(find.byType(App));
|
||||||
|
context
|
||||||
|
.read<SettingsCubit>()
|
||||||
|
.val(write: true)
|
||||||
|
.notificationSettings
|
||||||
|
.talkPermissionPromptShown =
|
||||||
|
true;
|
||||||
|
}
|
||||||
|
|
||||||
/// Navigiert zu [module] – über den Bottom-Tab, wenn er in der Leiste liegt,
|
/// Navigiert zu [module] – über den Bottom-Tab, wenn er in der Leiste liegt,
|
||||||
/// sonst als Vollbild-Push. Größenunabhängig, da die Tab-Anzahl je Gerät variiert.
|
/// sonst als Vollbild-Push. Größenunabhängig, da die Tab-Anzahl je Gerät variiert.
|
||||||
Future<void> _goTo(WidgetTester tester, Modules module) async {
|
Future<void> _goTo(WidgetTester tester, Modules module) async {
|
||||||
|
|||||||
@@ -180,11 +180,11 @@ AppDelegate) exakt so ab: `GenericPassword` + `kSecAttrAccount = <key>` +
|
|||||||
| `push_server_public_key_pem` | **Server**-Public-Key (per User), SPKI-PEM | `push_registration_store.dart` | **NSE** (Signatur prüfen) |
|
| `push_server_public_key_pem` | **Server**-Public-Key (per User), SPKI-PEM | `push_registration_store.dart` | **NSE** (Signatur prüfen) |
|
||||||
| `push_device_identifier` | NC Device-Identifier | `push_registration_store.dart` | Dart |
|
| `push_device_identifier` | NC Device-Identifier | `push_registration_store.dart` | Dart |
|
||||||
| `push_registered_fcm_token` | FCM-Token der Registrierung | `push_registration_store.dart` | Dart |
|
| `push_registered_fcm_token` | FCM-Token der Registrierung | `push_registration_store.dart` | Dart |
|
||||||
| `nextcloud_app_password` | NC App-Password | `account_data.dart` | **AppDelegate** (Basic-Auth) |
|
| `nextcloud_app_password` | NC App-Password | `session_manager.dart` | **AppDelegate** (Basic-Auth) |
|
||||||
| `nextcloud_username` | NC Username | `push_registration_store.dart` (**neu**) | **AppDelegate** (Basic-Auth) |
|
| `nextcloud_username` | NC Username | `push_registration_store.dart` (**neu**) | **AppDelegate** (Basic-Auth) |
|
||||||
| `nextcloud_base_url` | z.B. `https://cloud.marianum-fulda.de` | `push_registration_store.dart` (**neu**) | **AppDelegate** (OCS-URL) |
|
| `nextcloud_base_url` | z.B. `https://cloud.marianum-fulda.de` | `push_registration_store.dart` (**neu**) | **AppDelegate** (OCS-URL) |
|
||||||
|
|
||||||
> `username` und `password` (Realpasswort) liegen in AccountDatas **Default**-Storage
|
> `username` und `password` (Realpasswort) liegen im **Default**-Storage des SessionManagers
|
||||||
> **ohne** `groupId` → nur im primären Access-Group der App, **für die NSE
|
> **ohne** `groupId` → nur im primären Access-Group der App, **für die NSE
|
||||||
> unsichtbar**. Deshalb werden `nextcloud_username` + `nextcloud_base_url` bei
|
> unsichtbar**. Deshalb werden `nextcloud_username` + `nextcloud_base_url` bei
|
||||||
> jeder `register()`-Runde zusätzlich **group-scoped** geschrieben. Das
|
> jeder `register()`-Runde zusätzlich **group-scoped** geschrieben. Das
|
||||||
|
|||||||
+2
-8
@@ -12,8 +12,6 @@ PODS:
|
|||||||
- PhoneNumberKit/PhoneNumberKitCore (3.7.11)
|
- PhoneNumberKit/PhoneNumberKitCore (3.7.11)
|
||||||
- PhoneNumberKit/UIKit (3.7.11):
|
- PhoneNumberKit/UIKit (3.7.11):
|
||||||
- PhoneNumberKit/PhoneNumberKitCore
|
- PhoneNumberKit/PhoneNumberKitCore
|
||||||
- workmanager_apple (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- eraser (from `.symlinks/plugins/eraser/ios`)
|
- eraser (from `.symlinks/plugins/eraser/ios`)
|
||||||
@@ -21,7 +19,6 @@ DEPENDENCIES:
|
|||||||
- flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`)
|
- flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`)
|
||||||
- open_filex (from `.symlinks/plugins/open_filex/ios`)
|
- open_filex (from `.symlinks/plugins/open_filex/ios`)
|
||||||
- PhoneNumberKit (~> 3.7.6)
|
- PhoneNumberKit (~> 3.7.6)
|
||||||
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
|
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
trunk:
|
trunk:
|
||||||
@@ -36,17 +33,14 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/flutter_app_badge/ios"
|
:path: ".symlinks/plugins/flutter_app_badge/ios"
|
||||||
open_filex:
|
open_filex:
|
||||||
:path: ".symlinks/plugins/open_filex/ios"
|
:path: ".symlinks/plugins/open_filex/ios"
|
||||||
workmanager_apple:
|
|
||||||
:path: ".symlinks/plugins/workmanager_apple/ios"
|
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
eraser: 83a4b06985f3702aa3d8dec816f9693266012937
|
eraser: 83a4b06985f3702aa3d8dec816f9693266012937
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
|
||||||
flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf
|
flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf
|
||||||
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
|
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
|
||||||
PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c
|
PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c
|
||||||
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
|
|
||||||
|
|
||||||
PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6
|
PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.17.0
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 54;
|
objectVersion = 70;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
@@ -77,19 +77,19 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
12B96C78930441C73F123636 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = Info.plist; path = Info.plist; sourceTree = "<group>"; };
|
12B96C78930441C73F123636 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||||
17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Release.xcconfig"; path = "Flutter/NotificationServiceExtension-Release.xcconfig"; sourceTree = "<group>"; };
|
17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Release.xcconfig"; path = "Flutter/NotificationServiceExtension-Release.xcconfig"; sourceTree = "<group>"; };
|
||||||
2960F029246C39E2A03F6D87 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NotificationService.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
2960F029246C39E2A03F6D87 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||||
3321F8052FB1C00C0011C712 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
3321F8052FB1C00C0011C712 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
|
33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
|
||||||
36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Debug.xcconfig"; path = "Flutter/NotificationServiceExtension-Debug.xcconfig"; sourceTree = "<group>"; };
|
36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Debug.xcconfig"; path = "Flutter/NotificationServiceExtension-Debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
4509EC31CB08BA9BF367AF6C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
4509EC31CB08BA9BF367AF6C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Share_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Share_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
509DCCD474353408FE5806C5 /* PEM.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PEM.swift; path = PEM.swift; sourceTree = "<group>"; };
|
509DCCD474353408FE5806C5 /* PEM.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PEM.swift; sourceTree = "<group>"; };
|
||||||
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; name = NotificationServiceExtension.entitlements; path = NotificationServiceExtension.entitlements; sourceTree = "<group>"; };
|
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = NotificationServiceExtension.entitlements; sourceTree = "<group>"; };
|
||||||
60E1803A3FB28FCC6F435E99 /* Pods-Share Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.release.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"; sourceTree = "<group>"; };
|
60E1803A3FB28FCC6F435E99 /* Pods-Share Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.release.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
64801C012A9112D500E8B558 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
64801C012A9112D500E8B558 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
@@ -222,7 +222,6 @@
|
|||||||
12B96C78930441C73F123636 /* Info.plist */,
|
12B96C78930441C73F123636 /* Info.plist */,
|
||||||
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */,
|
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */,
|
||||||
);
|
);
|
||||||
name = NotificationServiceExtension;
|
|
||||||
path = NotificationServiceExtension;
|
path = NotificationServiceExtension;
|
||||||
sourceTree = SOURCE_ROOT;
|
sourceTree = SOURCE_ROOT;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/app-check.git",
|
"location" : "https://github.com/google/app-check.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902",
|
"revision" : "97f7d74dd0e8f3d0fe5fc75cc8957aa73bdc948a",
|
||||||
"version" : "11.3.1"
|
"version" : "11.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -50,8 +50,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270",
|
"revision" : "27eaab3918e0bf78711cf1abf240577176326432",
|
||||||
"version" : "12.15.0"
|
"version" : "12.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -59,8 +59,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55",
|
"revision" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee",
|
||||||
"version" : "3.6.1"
|
"version" : "3.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -68,8 +68,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b",
|
"revision" : "b8455dd2ff55cc269fda7a4f9eac6c1e3588c53f",
|
||||||
"version" : "12.15.0"
|
"version" : "12.19.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -77,8 +77,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/GoogleDataTransport.git",
|
"location" : "https://github.com/google/GoogleDataTransport.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
|
"revision" : "ba3358d3c3dbae8ef230b58a46b97ad65e84e974",
|
||||||
"version" : "10.1.0"
|
"version" : "10.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -86,8 +86,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/GoogleUtilities.git",
|
"location" : "https://github.com/google/GoogleUtilities.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "9f183ae842be978784f2963a343682e0c46d8fb3",
|
"revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3",
|
||||||
"version" : "8.1.2"
|
"version" : "8.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -104,8 +104,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/gtm-session-fetcher.git",
|
"location" : "https://github.com/google/gtm-session-fetcher.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a",
|
"revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc",
|
||||||
"version" : "5.3.0"
|
"version" : "5.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -50,8 +50,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270",
|
"revision" : "27eaab3918e0bf78711cf1abf240577176326432",
|
||||||
"version" : "12.15.0"
|
"version" : "12.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -59,8 +59,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64",
|
"revision" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee",
|
||||||
"version" : "3.6.0"
|
"version" : "3.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -68,8 +68,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b",
|
"revision" : "b8455dd2ff55cc269fda7a4f9eac6c1e3588c53f",
|
||||||
"version" : "12.15.0"
|
"version" : "12.19.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -86,8 +86,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/GoogleUtilities.git",
|
"location" : "https://github.com/google/GoogleUtilities.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "c46e5f8b7c23265f17c24ca7f9fa1b13ded7a822",
|
"revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3",
|
||||||
"version" : "8.1.1"
|
"version" : "8.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>AppGroupId</key>
|
<key>AppGroupId</key>
|
||||||
<string>$(CUSTOM_GROUP_ID)</string>
|
<string>$(CUSTOM_GROUP_ID)</string>
|
||||||
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>eu.mhsl.marianum.widget.refresh</string>
|
||||||
|
</array>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
@@ -66,10 +70,6 @@
|
|||||||
</dict>
|
</dict>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
|
||||||
<array>
|
|
||||||
<string>eu.mhsl.marianum.widget.refresh</string>
|
|
||||||
</array>
|
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>fetch</string>
|
<string>fetch</string>
|
||||||
@@ -90,5 +90,5 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
<false/>
|
<false/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -107,20 +107,58 @@ final class ShareViewController: UIViewController {
|
|||||||
(UTType.url.identifier, .url),
|
(UTType.url.identifier, .url),
|
||||||
(UTType.data.identifier, .file),
|
(UTType.data.identifier, .file),
|
||||||
]
|
]
|
||||||
for (typeId, kind) in order {
|
for (typeId, kind) in order where provider.hasItemConformingToTypeIdentifier(typeId) {
|
||||||
if provider.hasItemConformingToTypeIdentifier(typeId) {
|
// Konkreter Typ (z. B. public.png statt public.image) für Dateiendung
|
||||||
|
// und MIME-Type, falls die Quelle nur rohe Daten liefert.
|
||||||
|
let concrete = concreteType(of: provider, conformingTo: typeId)
|
||||||
provider.loadItem(forTypeIdentifier: typeId) { [weak self] data, error in
|
provider.loadItem(forTypeIdentifier: typeId) { [weak self] data, error in
|
||||||
guard let self else { completion(nil); return }
|
guard let self else { completion(nil); return }
|
||||||
if error != nil { completion(nil); return }
|
if error == nil,
|
||||||
completion(self.toSharedFile(data: data, kind: kind))
|
let file = self.toSharedFile(data: data, kind: kind,
|
||||||
}
|
concrete: concrete, provider: provider) {
|
||||||
|
completion(file)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Manche Quellen (z. B. der Screenshot-/Markup-Editor) liefern über
|
||||||
|
// loadItem nichts Verwertbares oder eine URL außerhalb unserer
|
||||||
|
// Sandbox. loadFileRepresentation stellt eine lesbare Kopie bereit.
|
||||||
|
self.loadFileFallback(provider: provider, kind: kind,
|
||||||
|
concrete: concrete, completion: completion)
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
completion(nil)
|
completion(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toSharedFile(data: Any?, kind: SharedMediaType) -> SharedMediaFile? {
|
private func concreteType(of provider: NSItemProvider, conformingTo typeId: String) -> UTType? {
|
||||||
|
guard let base = UTType(typeId) else { return nil }
|
||||||
|
return provider.registeredTypeIdentifiers
|
||||||
|
.compactMap { UTType($0) }
|
||||||
|
.first { $0.conforms(to: base) } ?? base
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadFileFallback(provider: NSItemProvider, kind: SharedMediaType,
|
||||||
|
concrete: UTType?,
|
||||||
|
completion: @escaping (SharedMediaFile?) -> Void) {
|
||||||
|
guard kind == .image || kind == .video || kind == .file, let concrete else {
|
||||||
|
completion(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
provider.loadFileRepresentation(forTypeIdentifier: concrete.identifier) { [weak self] url, _ in
|
||||||
|
// Die URL ist nur innerhalb dieses Callbacks gültig — sofort kopieren.
|
||||||
|
guard let self, let url, let dst = self.copyIntoAppGroup(src: url) else {
|
||||||
|
completion(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let mime = self.mime(for: url) ?? concrete.preferredMIMEType
|
||||||
|
let duration = kind == .video ? self.videoDurationMs(url: dst) : nil
|
||||||
|
completion(SharedMediaFile(path: self.pathString(for: dst), mimeType: mime,
|
||||||
|
thumbnail: nil, duration: duration, message: nil, type: kind))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toSharedFile(data: Any?, kind: SharedMediaType, concrete: UTType?,
|
||||||
|
provider: NSItemProvider) -> SharedMediaFile? {
|
||||||
switch kind {
|
switch kind {
|
||||||
case .text:
|
case .text:
|
||||||
guard let s = data as? String else { return nil }
|
guard let s = data as? String else { return nil }
|
||||||
@@ -139,6 +177,10 @@ final class ShareViewController: UIViewController {
|
|||||||
return SharedMediaFile(path: pathString(for: dst), mimeType: "image/png",
|
return SharedMediaFile(path: pathString(for: dst), mimeType: "image/png",
|
||||||
thumbnail: nil, duration: nil, message: nil, type: .image)
|
thumbnail: nil, duration: nil, message: nil, type: .image)
|
||||||
}
|
}
|
||||||
|
if let d = data as? Data, let dst = writeData(d, type: concrete, provider: provider) {
|
||||||
|
return SharedMediaFile(path: pathString(for: dst), mimeType: concrete?.preferredMIMEType,
|
||||||
|
thumbnail: nil, duration: nil, message: nil, type: .image)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
case .video:
|
case .video:
|
||||||
guard let u = data as? URL, let dst = copyIntoAppGroup(src: u) else { return nil }
|
guard let u = data as? URL, let dst = copyIntoAppGroup(src: u) else { return nil }
|
||||||
@@ -146,10 +188,16 @@ final class ShareViewController: UIViewController {
|
|||||||
thumbnail: nil, duration: videoDurationMs(url: u),
|
thumbnail: nil, duration: videoDurationMs(url: u),
|
||||||
message: nil, type: .video)
|
message: nil, type: .video)
|
||||||
case .file:
|
case .file:
|
||||||
guard let u = data as? URL, let dst = copyIntoAppGroup(src: u) else { return nil }
|
if let u = data as? URL, let dst = copyIntoAppGroup(src: u) {
|
||||||
return SharedMediaFile(path: pathString(for: dst), mimeType: mime(for: u),
|
return SharedMediaFile(path: pathString(for: dst), mimeType: mime(for: u),
|
||||||
thumbnail: nil, duration: nil, message: nil, type: .file)
|
thumbnail: nil, duration: nil, message: nil, type: .file)
|
||||||
}
|
}
|
||||||
|
if let d = data as? Data, let dst = writeData(d, type: concrete, provider: provider) {
|
||||||
|
return SharedMediaFile(path: pathString(for: dst), mimeType: concrete?.preferredMIMEType,
|
||||||
|
thumbnail: nil, duration: nil, message: nil, type: .file)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadItem-Callbacks laufen parallel; Ordneranlage und Namenswahl seriell.
|
// loadItem-Callbacks laufen parallel; Ordneranlage und Namenswahl seriell.
|
||||||
@@ -192,13 +240,11 @@ final class ShareViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func copyIntoShareDir(src: URL) -> URL? {
|
private func copyIntoShareDir(src: URL) -> URL? {
|
||||||
guard let dir = shareDir else { return nil }
|
|
||||||
let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent
|
let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent
|
||||||
var dst = dir.appendingPathComponent(name)
|
guard let dst = uniqueDestination(name: name) else { return nil }
|
||||||
// Mehrfachauswahl mit gleichem Namen innerhalb eines Shares.
|
// Security-scoped URLs (z. B. aus "Dateien") sind sonst nicht lesbar.
|
||||||
if FileManager.default.fileExists(atPath: dst.path) {
|
let scoped = src.startAccessingSecurityScopedResource()
|
||||||
dst = dir.appendingPathComponent("\(UUID().uuidString.prefix(8))-\(name)")
|
defer { if scoped { src.stopAccessingSecurityScopedResource() } }
|
||||||
}
|
|
||||||
do {
|
do {
|
||||||
try FileManager.default.copyItem(at: src, to: dst)
|
try FileManager.default.copyItem(at: src, to: dst)
|
||||||
return dst
|
return dst
|
||||||
@@ -207,6 +253,33 @@ final class ShareViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nur auf fileQueue aufrufen.
|
||||||
|
private func uniqueDestination(name: String) -> URL? {
|
||||||
|
guard let dir = shareDir else { return nil }
|
||||||
|
let dst = dir.appendingPathComponent(name)
|
||||||
|
// Mehrfachauswahl mit gleichem Namen innerhalb eines Shares.
|
||||||
|
if FileManager.default.fileExists(atPath: dst.path) {
|
||||||
|
return dir.appendingPathComponent("\(UUID().uuidString.prefix(8))-\(name)")
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeData(_ data: Data, type: UTType?, provider: NSItemProvider) -> URL? {
|
||||||
|
let ext = type?.preferredFilenameExtension ?? "bin"
|
||||||
|
var base = (provider.suggestedName ?? UUID().uuidString)
|
||||||
|
.replacingOccurrences(of: "/", with: "-")
|
||||||
|
if (base as NSString).pathExtension.lowercased() == ext.lowercased() {
|
||||||
|
base = (base as NSString).deletingPathExtension
|
||||||
|
}
|
||||||
|
// Namenswahl und Schreiben zusammen seriell, sonst können zwei Anhänge
|
||||||
|
// mit gleichem suggestedName denselben Pfad bekommen.
|
||||||
|
return fileQueue.sync {
|
||||||
|
guard let dst = uniqueDestination(name: "\(base).\(ext)"),
|
||||||
|
(try? data.write(to: dst)) != nil else { return nil }
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func writePng(image: UIImage) -> URL? {
|
private func writePng(image: UIImage) -> URL? {
|
||||||
guard let data = image.pngData(),
|
guard let data = image.pngData(),
|
||||||
let dir = fileQueue.sync(execute: { shareDir }) else { return nil }
|
let dir = fileQueue.sync(execute: { shareDir }) else { return nil }
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
app_identifier("eu.mhsl.marianum.mobile.client")
|
||||||
|
team_id("MY55VF3KPG")
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
default_platform(:ios)
|
||||||
|
|
||||||
|
# App-Store-Auslieferung über fastlane deliver. Die Screenshots erzeugt
|
||||||
|
# ../tool/screenshots_ios.sh (Demo-Login + integration_test) direkt in
|
||||||
|
# screenshots/de-DE/; deliver ordnet sie anhand der Pixelgröße iPhone 6,5″ bzw.
|
||||||
|
# iPad 13″ zu.
|
||||||
|
#
|
||||||
|
# Authentifizierung über einen App-Store-Connect-API-Key (JSON im fastlane-
|
||||||
|
# Format, siehe README.md). Pfad per APP_STORE_CONNECT_API_KEY setzen; die Datei
|
||||||
|
# selbst wird per .gitignore nicht eingecheckt.
|
||||||
|
platform :ios do
|
||||||
|
desc "Nur Screenshots hochladen (ersetzt die vorhandenen, verändert keine Texte/Binaries)"
|
||||||
|
lane :upload_screenshots do
|
||||||
|
upload_to_app_store(
|
||||||
|
api_key_path: ENV["APP_STORE_CONNECT_API_KEY"] || "fastlane/app-store-connect-api-key.json",
|
||||||
|
skip_binary_upload: true,
|
||||||
|
skip_metadata: true,
|
||||||
|
skip_app_version_update: true,
|
||||||
|
skip_screenshots: false,
|
||||||
|
overwrite_screenshots: true,
|
||||||
|
screenshots_path: "./fastlane/screenshots",
|
||||||
|
precheck_include_in_app_purchases: false,
|
||||||
|
run_precheck_before_submit: false,
|
||||||
|
force: true, # kein HTML-Vorschau-Bestätigungsschritt
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Fastlane – App-Store-Screenshots & Upload
|
||||||
|
|
||||||
|
iOS-Gegenstück zu `android/fastlane`. Die App muss dafür **nicht** neu gebaut
|
||||||
|
werden – der Demo-Modus (`demo@`-Login) liefert die kompletten Inhalte aus
|
||||||
|
Fixtures.
|
||||||
|
|
||||||
|
## 1. Screenshots aufnehmen
|
||||||
|
|
||||||
|
Aus dem **Client**-Wurzelverzeichnis:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tool/screenshots_ios.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Fährt „iPhone 14 Plus“ (6,5″, 1284×2778) und „iPad Pro 13-inch (M5)“
|
||||||
|
(13″, 2064×2752) nacheinander durch und legt die PNGs in
|
||||||
|
`materials/screenshots/{ios/6-5-inch,ipados/13-inch}/` sowie als JPEG-Upload-Kopie
|
||||||
|
(App Store Connect lehnt Alpha-Kanäle ab) in `screenshots/de-DE/` ab. Fehlende
|
||||||
|
Simulatoren legt das Skript selbst an.
|
||||||
|
|
||||||
|
- Nur eine Größe: `PROFILES="iphone" tool/screenshots_ios.sh`
|
||||||
|
- Andere Simulatoren: `IPHONE_SIM="iPhone 11 Pro Max" IPAD_SIM="…" tool/screenshots_ios.sh`
|
||||||
|
|
||||||
|
App und Schlüsselbund der verwendeten Simulatoren werden dabei zurückgesetzt.
|
||||||
|
|
||||||
|
## 2. Hochladen
|
||||||
|
|
||||||
|
fastlane installieren (`brew install fastlane`), in App Store Connect unter
|
||||||
|
*Benutzer und Zugriff → Integrationen → App Store Connect API* einen Key mit
|
||||||
|
Rolle *App-Manager* anlegen und als JSON ablegen:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key_id": "ABC123XYZ",
|
||||||
|
"issuer_id": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"key": "-----BEGIN PRIVATE KEY-----\n…\n-----END PRIVATE KEY-----",
|
||||||
|
"in_house": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export APP_STORE_CONNECT_API_KEY=/pfad/zu/app-store-connect-api-key.json
|
||||||
|
cd ios
|
||||||
|
fastlane upload_screenshots
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Screenshots landen in der aktuell bearbeitbaren App-Store-Version
|
||||||
|
(„Vorbereitung für Einreichung“); vorhandene Screenshots werden ersetzt. Der
|
||||||
|
API-Key wird **nicht** eingecheckt (siehe `.gitignore`).
|
||||||
Whitespace-only changes.
@@ -7,4 +7,9 @@ abstract class ApiResponse {
|
|||||||
|
|
||||||
@JsonKey(includeIfNull: false)
|
@JsonKey(includeIfNull: false)
|
||||||
Map<String, String>? headers;
|
Map<String, String>? headers;
|
||||||
|
|
||||||
|
/// Entries the decoder skipped (see `decodeListLenient`), reported by the
|
||||||
|
/// API layer once the response is back on the main isolate.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
List<String> parseWarnings = const [];
|
||||||
}
|
}
|
||||||
+68
-19
@@ -5,6 +5,7 @@ import 'dart:io';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../session/session_manager.dart';
|
||||||
import '../utils/directory_size.dart';
|
import '../utils/directory_size.dart';
|
||||||
|
|
||||||
/// One cached response: the raw JSON payload and when it was stored.
|
/// One cached response: the raw JSON payload and when it was stored.
|
||||||
@@ -22,6 +23,9 @@ class CacheEntry {
|
|||||||
/// in memory for the whole session and JSON-encodes payloads a second time.
|
/// in memory for the whole session and JSON-encodes payloads a second time.
|
||||||
/// Here every access is async and touches exactly one file, and the payload is
|
/// Here every access is async and touches exactly one file, and the payload is
|
||||||
/// stored verbatim behind a one-line header (`<lastUpdate>\n<json>`).
|
/// stored verbatim behind a one-line header (`<lastUpdate>\n<json>`).
|
||||||
|
///
|
||||||
|
/// Every account has its own directory; all access goes to the one of the
|
||||||
|
/// active account, in every isolate.
|
||||||
class CacheStore {
|
class CacheStore {
|
||||||
CacheStore._();
|
CacheStore._();
|
||||||
|
|
||||||
@@ -30,15 +34,36 @@ class CacheStore {
|
|||||||
/// Payloads above this size are decoded on a background isolate.
|
/// Payloads above this size are decoded on a background isolate.
|
||||||
static const int isolateDecodeThreshold = 64 * 1024;
|
static const int isolateDecodeThreshold = 64 * 1024;
|
||||||
|
|
||||||
Future<Directory>? _dir;
|
static const _directoryPrefix = 'request_cache';
|
||||||
|
|
||||||
|
final Map<String, Future<Directory>> _dirs = {};
|
||||||
final Map<String, Future<void>> _writes = {};
|
final Map<String, Future<void>> _writes = {};
|
||||||
|
|
||||||
Future<Directory> _directory() => _dir ??= () async {
|
/// Directory name for an account's storage [namespace]; null while no
|
||||||
|
/// account is active. The account from before multi-account support
|
||||||
|
/// (namespace '') keeps the original directory.
|
||||||
|
@visibleForTesting
|
||||||
|
static String directoryName(String? namespace) => switch (namespace) {
|
||||||
|
null => '$_directoryPrefix-signed-out',
|
||||||
|
'' => _directoryPrefix,
|
||||||
|
_ => '$_directoryPrefix-$namespace',
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<Directory> _directory() {
|
||||||
|
final name = directoryName(SessionManager().activeAccount?.namespace);
|
||||||
|
return _dirs[name] ??= () async {
|
||||||
|
try {
|
||||||
final base = await getApplicationCacheDirectory();
|
final base = await getApplicationCacheDirectory();
|
||||||
final dir = Directory('${base.path}/request_cache');
|
final dir = Directory('${base.path}/$name');
|
||||||
await dir.create(recursive: true);
|
await dir.create(recursive: true);
|
||||||
return dir;
|
return dir;
|
||||||
|
} on Object {
|
||||||
|
// Not memoized (disk full at launch): the next call tries again.
|
||||||
|
unawaited(_dirs.remove(name));
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}();
|
}();
|
||||||
|
}
|
||||||
|
|
||||||
Future<File> _file(String key) async =>
|
Future<File> _file(String key) async =>
|
||||||
File('${(await _directory()).path}/${_safeName(key)}');
|
File('${(await _directory()).path}/${_safeName(key)}');
|
||||||
@@ -49,8 +74,9 @@ class CacheStore {
|
|||||||
|
|
||||||
Future<CacheEntry?> read(String key) async {
|
Future<CacheEntry?> read(String key) async {
|
||||||
try {
|
try {
|
||||||
await _writes[key];
|
final file = await _file(key);
|
||||||
return parse(await (await _file(key)).readAsString());
|
await _writes[file.path];
|
||||||
|
return parse(await file.readAsString());
|
||||||
} on Object {
|
} on Object {
|
||||||
// Missing (PathNotFoundException) or unreadable: a cache miss.
|
// Missing (PathNotFoundException) or unreadable: a cache miss.
|
||||||
return null;
|
return null;
|
||||||
@@ -64,18 +90,21 @@ class CacheStore {
|
|||||||
return {for (var i = 0; i < keys.length; i++) keys[i]: ?entries[i]};
|
return {for (var i = 0; i < keys.length; i++) keys[i]: ?entries[i]};
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> write(String key, String json) {
|
Future<void> write(String key, String json) async {
|
||||||
final previous = _writes[key] ?? Future<void>.value();
|
// Resolved up front: a queued write must not follow an account switch
|
||||||
|
// into the next account's directory.
|
||||||
|
final file = await _file(key);
|
||||||
|
final path = file.path;
|
||||||
|
final previous = _writes[path] ?? Future<void>.value();
|
||||||
late final Future<void> current;
|
late final Future<void> current;
|
||||||
current = previous.then((_) => _write(key, json)).whenComplete(() {
|
current = previous.then((_) => _write(file, json)).whenComplete(() {
|
||||||
if (identical(_writes[key], current)) _writes.remove(key);
|
if (identical(_writes[path], current)) _writes.remove(path);
|
||||||
});
|
});
|
||||||
return _writes[key] = current;
|
return _writes[path] = current;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _write(String key, String json) async {
|
Future<void> _write(File file, String json) async {
|
||||||
try {
|
try {
|
||||||
final file = await _file(key);
|
|
||||||
// Write-then-rename so a reader (or the widget background isolate)
|
// Write-then-rename so a reader (or the widget background isolate)
|
||||||
// never sees a half-written file.
|
// never sees a half-written file.
|
||||||
final tmp = File('${file.path}.tmp');
|
final tmp = File('${file.path}.tmp');
|
||||||
@@ -85,19 +114,21 @@ class CacheStore {
|
|||||||
);
|
);
|
||||||
await tmp.rename(file.path);
|
await tmp.rename(file.path);
|
||||||
} on Object catch (e) {
|
} on Object catch (e) {
|
||||||
debugPrint('CacheStore.write($key) failed: $e');
|
debugPrint('CacheStore.write(${file.path}) failed: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> delete(String key) async {
|
Future<void> delete(String key) async {
|
||||||
try {
|
try {
|
||||||
await _writes[key];
|
final file = await _file(key);
|
||||||
await (await _file(key)).delete();
|
await _writes[file.path];
|
||||||
|
await file.delete();
|
||||||
} on Object {
|
} on Object {
|
||||||
// A missing or locked file is as good as deleted for a cache.
|
// A missing or locked file is as good as deleted for a cache.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Empties the active account's cache.
|
||||||
Future<void> clear() async {
|
Future<void> clear() async {
|
||||||
try {
|
try {
|
||||||
final dir = await _directory();
|
final dir = await _directory();
|
||||||
@@ -105,7 +136,20 @@ class CacheStore {
|
|||||||
} on Object catch (e) {
|
} on Object catch (e) {
|
||||||
debugPrint('CacheStore.clear failed: $e');
|
debugPrint('CacheStore.clear failed: $e');
|
||||||
}
|
}
|
||||||
_dir = null;
|
_dirs.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the cache of the account with [namespace], active or not.
|
||||||
|
Future<void> deleteNamespace(String namespace) async {
|
||||||
|
final name = directoryName(namespace);
|
||||||
|
try {
|
||||||
|
final base = await getApplicationCacheDirectory();
|
||||||
|
final dir = Directory('${base.path}/$name');
|
||||||
|
if (dir.existsSync()) await dir.delete(recursive: true);
|
||||||
|
} on Object catch (e) {
|
||||||
|
debugPrint('CacheStore.deleteNamespace failed: $e');
|
||||||
|
}
|
||||||
|
_dirs.removeWhere((key, _) => key == name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All stored keys, optionally filtered by [prefix].
|
/// All stored keys, optionally filtered by [prefix].
|
||||||
@@ -124,18 +168,23 @@ class CacheStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes entries not written for [maxAge], judged by file mtime so nothing
|
/// Removes entries not written for [maxAge] from every account's cache,
|
||||||
/// has to be read or decoded.
|
/// judged by file mtime so nothing has to be read or decoded.
|
||||||
Future<void> deleteOlderThan(Duration maxAge) async {
|
Future<void> deleteOlderThan(Duration maxAge) async {
|
||||||
try {
|
try {
|
||||||
final dir = await _directory();
|
final base = await getApplicationCacheDirectory();
|
||||||
final cutoff = DateTime.now().subtract(maxAge);
|
final cutoff = DateTime.now().subtract(maxAge);
|
||||||
|
await for (final dir in base.list()) {
|
||||||
|
if (dir is! Directory) continue;
|
||||||
|
final name = dir.uri.pathSegments.lastWhere((s) => s.isNotEmpty);
|
||||||
|
if (!name.startsWith(_directoryPrefix)) continue;
|
||||||
await for (final entity in dir.list()) {
|
await for (final entity in dir.list()) {
|
||||||
if (entity is! File) continue;
|
if (entity is! File) continue;
|
||||||
// ignore: avoid_slow_async_io
|
// ignore: avoid_slow_async_io
|
||||||
final modified = (await entity.stat()).modified;
|
final modified = (await entity.stat()).modified;
|
||||||
if (modified.isBefore(cutoff)) await entity.delete();
|
if (modified.isBefore(cutoff)) await entity.delete();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} on Object catch (e) {
|
} on Object catch (e) {
|
||||||
debugPrint('CacheStore.deleteOlderThan failed: $e');
|
debugPrint('CacheStore.deleteOlderThan failed: $e');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import '../../../model/account_data.dart';
|
import '../../../session/session_manager.dart';
|
||||||
import '../../marianumcloud/talk/chat/get_chat_response.dart';
|
import '../../marianumcloud/talk/chat/get_chat_response.dart';
|
||||||
import '../../marianumcloud/talk/room/get_room_response.dart';
|
import '../../marianumcloud/talk/room/get_room_response.dart';
|
||||||
import '../demo_persona.dart';
|
import '../demo_persona.dart';
|
||||||
@@ -204,7 +204,8 @@ class DemoTalk {
|
|||||||
id: base + 2,
|
id: base + 2,
|
||||||
token: token,
|
token: token,
|
||||||
ago: const Duration(days: 1, hours: 6),
|
ago: const Duration(days: 1, hours: 6),
|
||||||
message: 'Danke! Können wir Aufgabe 5 nächste Stunde nochmal besprechen?',
|
message:
|
||||||
|
'Danke! Können wir Aufgabe 5 nächste Stunde nochmal besprechen?',
|
||||||
),
|
),
|
||||||
_msg(
|
_msg(
|
||||||
id: base + 3,
|
id: base + 3,
|
||||||
@@ -366,7 +367,7 @@ class DemoTalk {
|
|||||||
}) => _msg(
|
}) => _msg(
|
||||||
id: id,
|
id: id,
|
||||||
token: token,
|
token: token,
|
||||||
actor: AccountData().getUsername(),
|
actor: SessionManager().requireNextcloud().username,
|
||||||
display: DemoPersona.studentName,
|
display: DemoPersona.studentName,
|
||||||
ago: ago,
|
ago: ago,
|
||||||
message: message,
|
message: message,
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/io_client.dart';
|
||||||
|
|
||||||
|
import '../../model/endpoint_data.dart';
|
||||||
|
import '../errors/network_exception.dart';
|
||||||
|
import 'demo_mode.dart';
|
||||||
|
|
||||||
|
/// Demo sessions carry made-up credentials. Every request they sent to the
|
||||||
|
/// real Nextcloud would count as a failed login there and, in bulk, trip the
|
||||||
|
/// server's brute-force throttle for the whole IP (HTTP 429 for everyone
|
||||||
|
/// behind it). Installed app-wide via [http.runWithClient].
|
||||||
|
class DemoHttpClient extends http.BaseClient {
|
||||||
|
final http.Client _inner = IOClient();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.StreamedResponse> send(http.BaseRequest request) {
|
||||||
|
if (DemoMode.active && isNextcloudHost(request.url)) {
|
||||||
|
return Future.error(NetworkException.demoBlocked(request.url));
|
||||||
|
}
|
||||||
|
return _inner.send(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void close() => _inner.close();
|
||||||
|
|
||||||
|
static bool isNextcloudHost(Uri url) =>
|
||||||
|
url.host == EndpointData().nextcloud().domain;
|
||||||
|
}
|
||||||
@@ -32,6 +32,8 @@ class DemoMarianumConnect {
|
|||||||
return DemoTimetable.timegrid().result.map((e) => e.toJson()).toList();
|
return DemoTimetable.timegrid().result.map((e) => e.toJson()).toList();
|
||||||
case 'timetable/holidays':
|
case 'timetable/holidays':
|
||||||
return DemoTimetable.holidays().result.map((e) => e.toJson()).toList();
|
return DemoTimetable.holidays().result.map((e) => e.toJson()).toList();
|
||||||
|
case 'timetable/custom-events':
|
||||||
|
return DemoTimetable.customEvents().toJson();
|
||||||
case 'holidays':
|
case 'holidays':
|
||||||
return DemoHolidays.upcoming().map((e) => e.toJson()).toList();
|
return DemoHolidays.upcoming().map((e) => e.toJson()).toList();
|
||||||
case 'users/search':
|
case 'users/search':
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import '../../model/account_data.dart';
|
import '../../session/session_manager.dart';
|
||||||
|
|
||||||
/// Central switch for the client-side demo mode.
|
/// Central switch for the client-side demo mode.
|
||||||
///
|
///
|
||||||
@@ -8,7 +8,7 @@ import '../../model/account_data.dart';
|
|||||||
/// Play reviewers and automated screenshot runs see a fully populated app
|
/// Play reviewers and automated screenshot runs see a fully populated app
|
||||||
/// without a real account and without any network dependency.
|
/// without a real account and without any network dependency.
|
||||||
///
|
///
|
||||||
/// The flag is persisted through [AccountData.isDemo], so a demo session
|
/// The flag is persisted through [Session.isDemo], so a demo session
|
||||||
/// survives cold starts exactly like a normal login. It works in release builds
|
/// survives cold starts exactly like a normal login. It works in release builds
|
||||||
/// too — reviewers run the shipped release build.
|
/// too — reviewers run the shipped release build.
|
||||||
class DemoMode {
|
class DemoMode {
|
||||||
@@ -23,5 +23,5 @@ class DemoMode {
|
|||||||
username.trim().toLowerCase().startsWith(usernamePrefix);
|
username.trim().toLowerCase().startsWith(usernamePrefix);
|
||||||
|
|
||||||
/// True while the active session is a demo session.
|
/// True while the active session is a demo session.
|
||||||
static bool get active => AccountData().isDemo;
|
static bool get active => SessionManager().isDemo;
|
||||||
}
|
}
|
||||||
@@ -5,13 +5,12 @@ import 'package:dio/dio.dart';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:nextcloud/nextcloud.dart';
|
import 'package:nextcloud/nextcloud.dart';
|
||||||
|
|
||||||
|
import '../../session/session.dart';
|
||||||
import '../api_error.dart';
|
import '../api_error.dart';
|
||||||
import '../http_errors.dart';
|
import '../http_errors.dart';
|
||||||
import '../marianumcloud/talk/talk_error.dart';
|
import '../marianumcloud/talk/talk_error.dart';
|
||||||
import 'app_exception.dart';
|
import 'app_exception.dart';
|
||||||
import 'auth_exception.dart';
|
|
||||||
import 'network_exception.dart';
|
import 'network_exception.dart';
|
||||||
import 'not_found_exception.dart';
|
|
||||||
import 'parse_exception.dart';
|
import 'parse_exception.dart';
|
||||||
import 'server_exception.dart';
|
import 'server_exception.dart';
|
||||||
import 'talk_exception.dart';
|
import 'talk_exception.dart';
|
||||||
@@ -55,30 +54,74 @@ AppException? _dioToAppException(DioException error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final RegExp _davException = RegExp(
|
||||||
|
r'<s:exception>\s*([^<]*?)\s*</s:exception>',
|
||||||
|
);
|
||||||
|
final RegExp _davMessage = RegExp(r'<s:message>\s*([^<]*?)\s*</s:message>');
|
||||||
|
|
||||||
|
/// The Sabre DAV error body of a failed WebDAV request: the exception class
|
||||||
|
/// (without its `Sabre\DAV\Exception\` namespace) and the server's message.
|
||||||
|
/// The class says what actually went wrong where the status is ambiguous —
|
||||||
|
/// MKCOL on an existing folder is a plain 405.
|
||||||
|
({String exception, String message})? parseDavError(String body) {
|
||||||
|
final exception = _davException.firstMatch(body)?.group(1);
|
||||||
|
if (exception == null || exception.isEmpty) return null;
|
||||||
|
return (
|
||||||
|
exception: exception.split('\\').last,
|
||||||
|
message: _davMessage.firstMatch(body)?.group(1) ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AppException? _davToAppException(
|
||||||
|
int status,
|
||||||
|
({String exception, String message}) dav,
|
||||||
|
String detail,
|
||||||
|
) {
|
||||||
|
final userMessage = switch (dav.exception) {
|
||||||
|
'MethodNotAllowed' when status == 405 =>
|
||||||
|
'Ein Ordner oder eine Datei mit diesem Namen existiert bereits.',
|
||||||
|
'PreconditionFailed' =>
|
||||||
|
'Am Zielort existiert bereits ein Eintrag mit diesem Namen.',
|
||||||
|
'Conflict' => 'Der übergeordnete Ordner existiert nicht mehr.',
|
||||||
|
'Locked' =>
|
||||||
|
'Der Eintrag wird gerade bearbeitet. Bitte versuche es gleich erneut.',
|
||||||
|
'InsufficientStorage' =>
|
||||||
|
'Der verfügbare Speicherplatz reicht dafür nicht aus.',
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
if (userMessage == null) return null;
|
||||||
|
return ServerException(
|
||||||
|
statusCode: status,
|
||||||
|
userMessage: userMessage,
|
||||||
|
technicalDetails: detail,
|
||||||
|
allowRetry: dav.exception == 'Locked',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The nextcloud/dynamite clients throw [DynamiteApiException] on non-2xx.
|
/// The nextcloud/dynamite clients throw [DynamiteApiException] on non-2xx.
|
||||||
/// Its toString() dumps every response header, so the details keep only the
|
/// Its toString() dumps every response header, so the details keep only the
|
||||||
/// status plus a trimmed body preview (same format as the Talk API errors).
|
/// status plus a trimmed body preview (same format as the Talk API errors).
|
||||||
AppException _dynamiteToAppException(DynamiteApiException error) {
|
AppException _dynamiteToAppException(DynamiteApiException error) {
|
||||||
final status = error.statusCode;
|
final status = error.statusCode;
|
||||||
|
final dav = parseDavError(error.body);
|
||||||
|
if (dav != null) {
|
||||||
|
final detail = 'HTTP $status ${dav.exception}: ${dav.message}';
|
||||||
|
return _davToAppException(status, dav, detail) ??
|
||||||
|
exceptionForStatus(status, detail);
|
||||||
|
}
|
||||||
final preview = previewBody(error.body);
|
final preview = previewBody(error.body);
|
||||||
final detail = preview.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview';
|
final detail = preview.isEmpty
|
||||||
switch (status) {
|
? 'HTTP $status'
|
||||||
case 401:
|
: 'HTTP $status body=$preview';
|
||||||
return AuthException.unauthorized(technicalDetails: detail);
|
if (status == 429) {
|
||||||
case 403:
|
|
||||||
return AuthException.forbidden(technicalDetails: detail);
|
|
||||||
case 404:
|
|
||||||
return NotFoundException(technicalDetails: detail);
|
|
||||||
case 429:
|
|
||||||
return ServerException(
|
return ServerException(
|
||||||
statusCode: status,
|
statusCode: status,
|
||||||
userMessage:
|
userMessage:
|
||||||
'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut versuchst.',
|
'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut versuchst.',
|
||||||
technicalDetails: detail,
|
technicalDetails: detail,
|
||||||
);
|
);
|
||||||
default:
|
|
||||||
return ServerException(statusCode: status, technicalDetails: detail);
|
|
||||||
}
|
}
|
||||||
|
return exceptionForStatus(status, detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
|
String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
|
||||||
@@ -86,6 +129,9 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
|
|||||||
if (error is AppException) return error.userMessage;
|
if (error is AppException) return error.userMessage;
|
||||||
|
|
||||||
if (error is TalkError) return TalkException(error).userMessage;
|
if (error is TalkError) return TalkException(error).userMessage;
|
||||||
|
if (error is NextcloudUnavailableException) {
|
||||||
|
return 'Diese Funktion ist mit deinem Konto nicht verfügbar.';
|
||||||
|
}
|
||||||
|
|
||||||
if (error is DioException) {
|
if (error is DioException) {
|
||||||
final mapped = _dioToAppException(error);
|
final mapped = _dioToAppException(error);
|
||||||
@@ -136,6 +182,7 @@ String? errorToTechnicalDetails(Object? error) {
|
|||||||
bool errorAllowsRetry(Object? error) {
|
bool errorAllowsRetry(Object? error) {
|
||||||
if (error == null) return true;
|
if (error == null) return true;
|
||||||
if (error is AppException) return error.allowRetry;
|
if (error is AppException) return error.allowRetry;
|
||||||
|
if (error is NextcloudUnavailableException) return false;
|
||||||
if (error is DioException) {
|
if (error is DioException) {
|
||||||
final mapped = _dioToAppException(error);
|
final mapped = _dioToAppException(error);
|
||||||
if (mapped != null) return mapped.allowRetry;
|
if (mapped != null) return mapped.allowRetry;
|
||||||
|
|||||||
@@ -13,4 +13,10 @@ class NetworkException extends AppException {
|
|||||||
'Der Server hat zu lange gebraucht. Bitte versuche es erneut.',
|
'Der Server hat zu lange gebraucht. Bitte versuche es erneut.',
|
||||||
technicalDetails: technicalDetails,
|
technicalDetails: technicalDetails,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// A request a demo session must never send (see `DemoHttpClient`).
|
||||||
|
factory NetworkException.demoBlocked(Uri url) => NetworkException(
|
||||||
|
userMessage: 'Im Demo-Modus nicht verfügbar.',
|
||||||
|
technicalDetails: 'demo mode blocked $url',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ class ServerException extends AppException {
|
|||||||
required this.statusCode,
|
required this.statusCode,
|
||||||
String? userMessage,
|
String? userMessage,
|
||||||
super.technicalDetails,
|
super.technicalDetails,
|
||||||
|
super.allowRetry,
|
||||||
}) : super(
|
}) : super(
|
||||||
// Status code intentionally not in the user message — it lives in
|
// Status code intentionally not in the user message — it lives in
|
||||||
// technicalDetails behind "Details anzeigen".
|
// technicalDetails behind "Details anzeigen".
|
||||||
@@ -14,6 +15,5 @@ class ServerException extends AppException {
|
|||||||
userMessage ??
|
userMessage ??
|
||||||
'Der Server konnte die Anfrage gerade nicht verarbeiten. '
|
'Der Server konnte die Anfrage gerade nicht verarbeiten. '
|
||||||
'Bitte versuche es in einem Moment erneut.',
|
'Bitte versuche es in einem Moment erneut.',
|
||||||
allowRetry: true,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import 'errors/app_exception.dart';
|
||||||
import 'errors/auth_exception.dart';
|
import 'errors/auth_exception.dart';
|
||||||
import 'errors/network_exception.dart';
|
import 'errors/network_exception.dart';
|
||||||
import 'errors/not_found_exception.dart';
|
import 'errors/not_found_exception.dart';
|
||||||
@@ -42,12 +43,23 @@ String httpErrorDetail(String label, String body, int status) {
|
|||||||
: '$label -> HTTP $status body=$preview';
|
: '$label -> HTTP $status body=$preview';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Throws the [AppException] matching a non-2xx HTTP [status], carrying
|
/// The [AppException] matching a non-2xx HTTP [status], carrying [detail] as
|
||||||
/// [detail] as technical details: 401/403 map to auth errors, 404 to
|
/// technical details: 401/403 map to auth errors, 404 to not-found, everything
|
||||||
/// not-found, everything else to a generic server error.
|
/// else to a generic server error.
|
||||||
Never throwForStatus(int status, String detail) {
|
AppException exceptionForStatus(int status, String detail) => switch (status) {
|
||||||
if (status == 401) throw AuthException.unauthorized(technicalDetails: detail);
|
401 => AuthException.unauthorized(technicalDetails: detail),
|
||||||
if (status == 403) throw AuthException.forbidden(technicalDetails: detail);
|
403 => AuthException.forbidden(technicalDetails: detail),
|
||||||
if (status == 404) throw NotFoundException(technicalDetails: detail);
|
404 => NotFoundException(technicalDetails: detail),
|
||||||
throw ServerException(statusCode: status, technicalDetails: detail);
|
_ => ServerException(statusCode: status, technicalDetails: detail),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Throws [exceptionForStatus].
|
||||||
|
Never throwForStatus(int status, String detail) =>
|
||||||
|
throw exceptionForStatus(status, detail);
|
||||||
|
|
||||||
|
/// Throws [exceptionForStatus] unless [response] is 2xx.
|
||||||
|
void ensureSuccess(String label, http.Response response) {
|
||||||
|
final status = response.statusCode;
|
||||||
|
if (status >= 200 && status < 300) return;
|
||||||
|
throwForStatus(status, httpErrorDetail(label, response.body, status));
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
/// Decodes server lists element by element so one entry the model cannot map
|
||||||
|
/// (a field the server dropped, an enum value it added) is skipped and
|
||||||
|
/// recorded instead of failing the whole response and with it the screen.
|
||||||
|
///
|
||||||
|
/// Skipped entries are collected per isolate; the API layer drains them with
|
||||||
|
/// [drainDecodeWarnings] and reports them, so a drift in the server's format
|
||||||
|
/// still reaches us without taking the feature down for the user.
|
||||||
|
final List<String> _warnings = [];
|
||||||
|
|
||||||
|
List<T> decodeListLenient<T>(
|
||||||
|
Object? raw,
|
||||||
|
T Function(Map<String, dynamic>) fromJson, {
|
||||||
|
required String what,
|
||||||
|
}) {
|
||||||
|
final items = <T>[];
|
||||||
|
if (raw is! List<dynamic>) {
|
||||||
|
throw FormatException(
|
||||||
|
'expected a list of ${what}s, got ${raw.runtimeType}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (final entry in raw) {
|
||||||
|
try {
|
||||||
|
items.add(fromJson(entry as Map<String, dynamic>));
|
||||||
|
} on CheckedFromJsonException catch (e) {
|
||||||
|
_warnings.add('$what: $e');
|
||||||
|
} on TypeError catch (e) {
|
||||||
|
_warnings.add('$what: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<T> decodeSetLenient<T>(
|
||||||
|
Object? raw,
|
||||||
|
T Function(Map<String, dynamic>) fromJson, {
|
||||||
|
required String what,
|
||||||
|
}) => decodeListLenient(raw, fromJson, what: what).toSet();
|
||||||
|
|
||||||
|
/// Returns and clears the entries skipped since the last call.
|
||||||
|
List<String> drainDecodeWarnings() {
|
||||||
|
final out = List<String>.of(_warnings);
|
||||||
|
_warnings.clear();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -16,10 +16,9 @@ class DeleteAppPassword {
|
|||||||
Future<void> run({String? authorizationHeader}) async {
|
Future<void> run({String? authorizationHeader}) async {
|
||||||
await _client.delete(
|
await _client.delete(
|
||||||
NextcloudOcs.uri('core/apppassword'),
|
NextcloudOcs.uri('core/apppassword'),
|
||||||
headers: {
|
// An explicit header may belong to an inactive account, so the active
|
||||||
...NextcloudOcs.headers(),
|
// session's headers must not be required then.
|
||||||
'Authorization': ?authorizationHeader,
|
headers: NextcloudOcs.headers(authorization: authorizationHeader),
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,13 +2,13 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../../../model/account_data.dart';
|
import '../../../session/session_manager.dart';
|
||||||
import '../../http_errors.dart';
|
import '../../http_errors.dart';
|
||||||
import '../nextcloud_ocs.dart';
|
import '../nextcloud_ocs.dart';
|
||||||
|
|
||||||
/// Exchanges the user's real Nextcloud password for a scoped app password via
|
/// Exchanges the user's real Nextcloud password for a scoped app password via
|
||||||
/// `GET /ocs/v2.php/core/getapppassword`. All subsequent Nextcloud calls then
|
/// `GET /ocs/v2.php/core/getapppassword`. All subsequent Nextcloud calls then
|
||||||
/// authenticate with the app password (see [AccountData.getBasicAuthHeader]),
|
/// authenticate with the app password (see [NextcloudCredentials.basicAuthHeader]),
|
||||||
/// which is what the push-v2 registration binds to.
|
/// which is what the push-v2 registration binds to.
|
||||||
///
|
///
|
||||||
/// Must authenticate with the *real* password — an app password cannot mint
|
/// Must authenticate with the *real* password — an app password cannot mint
|
||||||
@@ -28,21 +28,17 @@ class GetAppPassword {
|
|||||||
label,
|
label,
|
||||||
() => _client.get(
|
() => _client.get(
|
||||||
NextcloudOcs.uri('core/getapppassword'),
|
NextcloudOcs.uri('core/getapppassword'),
|
||||||
headers: {
|
|
||||||
...NextcloudOcs.headers(),
|
|
||||||
// Deliberately NOT the shared Authorization value: that one prefers
|
// Deliberately NOT the shared Authorization value: that one prefers
|
||||||
// the app password, but an app password cannot mint another one —
|
// the app password, but an app password cannot mint another one —
|
||||||
// this endpoint requires the real password.
|
// this endpoint requires the real password.
|
||||||
'Authorization': AccountData().getRealPasswordBasicAuthHeader(),
|
headers: NextcloudOcs.headers(
|
||||||
},
|
authorization: SessionManager()
|
||||||
|
.requireNextcloud()
|
||||||
|
.realPasswordBasicAuthHeader,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
))!;
|
))!;
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
ensureSuccess(label, response);
|
||||||
throwForStatus(
|
|
||||||
response.statusCode,
|
|
||||||
httpErrorDetail(label, response.body, response.statusCode),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final json = jsonDecode(utf8.decode(response.bodyBytes));
|
final json = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
final data = (json as Map)['ocs']?['data'];
|
final data = (json as Map)['ocs']?['data'];
|
||||||
final appPassword = data is Map ? data['apppassword'] as String? : null;
|
final appPassword = data is Map ? data['apppassword'] as String? : null;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import 'package:http/http.dart' as http;
|
|||||||
import '../../../model/endpoint_data.dart';
|
import '../../../model/endpoint_data.dart';
|
||||||
import '../../errors/server_exception.dart';
|
import '../../errors/server_exception.dart';
|
||||||
import '../../http_errors.dart';
|
import '../../http_errors.dart';
|
||||||
|
import '../../json_lenient.dart';
|
||||||
|
import '../../marianumconnect/queries/report_client_error/client_error_reporter.dart';
|
||||||
import '../nextcloud_ocs.dart';
|
import '../nextcloud_ocs.dart';
|
||||||
import 'autocomplete_response.dart';
|
import 'autocomplete_response.dart';
|
||||||
|
|
||||||
@@ -43,6 +45,13 @@ class AutocompleteApi {
|
|||||||
technicalDetails: 'core/autocomplete/get: ${response.body}',
|
technicalDetails: 'core/autocomplete/get: ${response.body}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return AutocompleteResponse.fromJson(NextcloudOcs.decode(response.body));
|
final result = AutocompleteResponse.fromJson(
|
||||||
|
NextcloudOcs.decode(response.body),
|
||||||
|
);
|
||||||
|
ClientErrorReporter.reportDecodeWarnings(
|
||||||
|
'Autocomplete',
|
||||||
|
drainDecodeWarnings(),
|
||||||
|
);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
import '../../json_lenient.dart';
|
||||||
import '../files_sharing/queries/share/share.dart';
|
import '../files_sharing/queries/share/share.dart';
|
||||||
|
|
||||||
part 'autocomplete_response.g.dart';
|
part 'autocomplete_response.g.dart';
|
||||||
@@ -14,6 +15,7 @@ int shareTypeFromSource(String? source) {
|
|||||||
|
|
||||||
@JsonSerializable(explicitToJson: true)
|
@JsonSerializable(explicitToJson: true)
|
||||||
class AutocompleteResponse {
|
class AutocompleteResponse {
|
||||||
|
@JsonKey(fromJson: _entriesFromJson)
|
||||||
List<AutocompleteResponseObject> data;
|
List<AutocompleteResponseObject> data;
|
||||||
|
|
||||||
AutocompleteResponse(this.data);
|
AutocompleteResponse(this.data);
|
||||||
@@ -23,15 +25,31 @@ class AutocompleteResponse {
|
|||||||
Map<String, dynamic> toJson() => _$AutocompleteResponseToJson(this);
|
Map<String, dynamic> toJson() => _$AutocompleteResponseToJson(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<AutocompleteResponseObject> _entriesFromJson(Object? raw) =>
|
||||||
|
decodeListLenient(
|
||||||
|
raw,
|
||||||
|
AutocompleteResponseObject.fromJson,
|
||||||
|
what: 'autocomplete entry',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The server sends a user-status object for users that have one and an empty
|
||||||
|
/// string otherwise; only the status string itself is kept.
|
||||||
|
String? _statusFromJson(Object? raw) => switch (raw) {
|
||||||
|
Map<String, dynamic> status => status['status']?.toString(),
|
||||||
|
String status when status.isNotEmpty => status,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class AutocompleteResponseObject {
|
class AutocompleteResponseObject {
|
||||||
String id;
|
String id;
|
||||||
String label;
|
String label;
|
||||||
String? icon;
|
String? icon;
|
||||||
String? source;
|
String? source;
|
||||||
|
@JsonKey(fromJson: _statusFromJson)
|
||||||
String? status;
|
String? status;
|
||||||
String? subline;
|
String? subline;
|
||||||
String? shareWithDisplayNameUniqe;
|
String? shareWithDisplayNameUnique;
|
||||||
|
|
||||||
AutocompleteResponseObject(
|
AutocompleteResponseObject(
|
||||||
this.id,
|
this.id,
|
||||||
@@ -40,7 +58,7 @@ class AutocompleteResponseObject {
|
|||||||
this.source,
|
this.source,
|
||||||
this.status,
|
this.status,
|
||||||
this.subline,
|
this.subline,
|
||||||
this.shareWithDisplayNameUniqe,
|
this.shareWithDisplayNameUnique,
|
||||||
);
|
);
|
||||||
|
|
||||||
factory AutocompleteResponseObject.fromJson(Map<String, dynamic> json) =>
|
factory AutocompleteResponseObject.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -8,13 +8,12 @@ part of 'autocomplete_response.dart';
|
|||||||
|
|
||||||
AutocompleteResponse _$AutocompleteResponseFromJson(
|
AutocompleteResponse _$AutocompleteResponseFromJson(
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
) => AutocompleteResponse(
|
) => $checkedCreate('AutocompleteResponse', json, ($checkedConvert) {
|
||||||
(json['data'] as List<dynamic>)
|
final val = AutocompleteResponse(
|
||||||
.map(
|
$checkedConvert('data', (v) => _entriesFromJson(v)),
|
||||||
(e) => AutocompleteResponseObject.fromJson(e as Map<String, dynamic>),
|
);
|
||||||
)
|
return val;
|
||||||
.toList(),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, dynamic> _$AutocompleteResponseToJson(
|
Map<String, dynamic> _$AutocompleteResponseToJson(
|
||||||
AutocompleteResponse instance,
|
AutocompleteResponse instance,
|
||||||
@@ -22,15 +21,18 @@ Map<String, dynamic> _$AutocompleteResponseToJson(
|
|||||||
|
|
||||||
AutocompleteResponseObject _$AutocompleteResponseObjectFromJson(
|
AutocompleteResponseObject _$AutocompleteResponseObjectFromJson(
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
) => AutocompleteResponseObject(
|
) => $checkedCreate('AutocompleteResponseObject', json, ($checkedConvert) {
|
||||||
json['id'] as String,
|
final val = AutocompleteResponseObject(
|
||||||
json['label'] as String,
|
$checkedConvert('id', (v) => v as String),
|
||||||
json['icon'] as String?,
|
$checkedConvert('label', (v) => v as String),
|
||||||
json['source'] as String?,
|
$checkedConvert('icon', (v) => v as String?),
|
||||||
json['status'] as String?,
|
$checkedConvert('source', (v) => v as String?),
|
||||||
json['subline'] as String?,
|
$checkedConvert('status', (v) => _statusFromJson(v)),
|
||||||
json['shareWithDisplayNameUniqe'] as String?,
|
$checkedConvert('subline', (v) => v as String?),
|
||||||
);
|
$checkedConvert('shareWithDisplayNameUnique', (v) => v as String?),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$AutocompleteResponseObjectToJson(
|
Map<String, dynamic> _$AutocompleteResponseObjectToJson(
|
||||||
AutocompleteResponseObject instance,
|
AutocompleteResponseObject instance,
|
||||||
@@ -41,5 +43,5 @@ Map<String, dynamic> _$AutocompleteResponseObjectToJson(
|
|||||||
'source': instance.source,
|
'source': instance.source,
|
||||||
'status': instance.status,
|
'status': instance.status,
|
||||||
'subline': instance.subline,
|
'subline': instance.subline,
|
||||||
'shareWithDisplayNameUniqe': instance.shareWithDisplayNameUniqe,
|
'shareWithDisplayNameUnique': instance.shareWithDisplayNameUnique,
|
||||||
};
|
};
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../../../model/account_data.dart';
|
|
||||||
import '../../../model/endpoint_data.dart';
|
import '../../../model/endpoint_data.dart';
|
||||||
|
import '../../../session/session_manager.dart';
|
||||||
import '../../errors/parse_exception.dart';
|
import '../../errors/parse_exception.dart';
|
||||||
import '../../http_errors.dart';
|
import '../../http_errors.dart';
|
||||||
import '../nextcloud_ocs.dart';
|
import '../nextcloud_ocs.dart';
|
||||||
@@ -27,12 +26,12 @@ Uri _coreAvatarUri() {
|
|||||||
return Uri.https(endpoint.domain, '${endpoint.path}/avatar/');
|
return Uri.https(endpoint.domain, '${endpoint.path}/avatar/');
|
||||||
}
|
}
|
||||||
|
|
||||||
Uri _userInfoUri() =>
|
Uri _userInfoUri() => NextcloudOcs.uri(
|
||||||
NextcloudOcs.uri('cloud/users/${AccountData().getUsername()}');
|
'cloud/users/${SessionManager().requireNextcloud().username}',
|
||||||
|
);
|
||||||
|
|
||||||
Future<http.Response> _send(
|
Future<http.Response> _send(
|
||||||
Future<http.Response> Function(Uri uri, Map<String, String> headers)
|
Future<http.Response> Function(Uri uri, Map<String, String> headers) perform,
|
||||||
perform,
|
|
||||||
Uri uri,
|
Uri uri,
|
||||||
) async {
|
) async {
|
||||||
final headers = NextcloudOcs.headers();
|
final headers = NextcloudOcs.headers();
|
||||||
@@ -96,18 +95,14 @@ class GetUserInfo {
|
|||||||
uri,
|
uri,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
final root = jsonDecode(response.body) as Map<String, dynamic>;
|
|
||||||
final data =
|
final data =
|
||||||
(root['ocs'] as Map<String, dynamic>)['data']
|
NextcloudOcs.decode(response.body)['data'] as Map<String, dynamic>;
|
||||||
as Map<String, dynamic>;
|
|
||||||
return CloudUserInfo(
|
return CloudUserInfo(
|
||||||
userId: data['id'] as String,
|
userId: data['id'] as String,
|
||||||
displayName: (data['displayname'] as String?) ?? '',
|
displayName: (data['displayname'] as String?) ?? '',
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw ParseException(
|
throw ParseException(technicalDetails: 'Cloud $uri user info parse: $e');
|
||||||
technicalDetails: 'Cloud $uri user info parse: $e',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,16 +8,19 @@ part of 'file_sharing_api_params.dart';
|
|||||||
|
|
||||||
FileSharingApiParams _$FileSharingApiParamsFromJson(
|
FileSharingApiParams _$FileSharingApiParamsFromJson(
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
) => FileSharingApiParams(
|
) => $checkedCreate('FileSharingApiParams', json, ($checkedConvert) {
|
||||||
shareType: (json['shareType'] as num).toInt(),
|
final val = FileSharingApiParams(
|
||||||
shareWith: json['shareWith'] as String,
|
shareType: $checkedConvert('shareType', (v) => (v as num).toInt()),
|
||||||
path: json['path'] as String,
|
shareWith: $checkedConvert('shareWith', (v) => v as String),
|
||||||
referenceId: json['referenceId'] as String?,
|
path: $checkedConvert('path', (v) => v as String),
|
||||||
talkMetaData: json['talkMetaData'] as String?,
|
referenceId: $checkedConvert('referenceId', (v) => v as String?),
|
||||||
permissions: (json['permissions'] as num?)?.toInt(),
|
talkMetaData: $checkedConvert('talkMetaData', (v) => v as String?),
|
||||||
password: json['password'] as String?,
|
permissions: $checkedConvert('permissions', (v) => (v as num?)?.toInt()),
|
||||||
expireDate: json['expireDate'] as String?,
|
password: $checkedConvert('password', (v) => v as String?),
|
||||||
);
|
expireDate: $checkedConvert('expireDate', (v) => v as String?),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$FileSharingApiParamsToJson(
|
Map<String, dynamic> _$FileSharingApiParamsToJson(
|
||||||
FileSharingApiParams instance,
|
FileSharingApiParams instance,
|
||||||
|
|||||||
@@ -29,12 +29,7 @@ class LoginFlowApi {
|
|||||||
headers: {'Accept': 'application/json', 'User-Agent': userAgent},
|
headers: {'Accept': 'application/json', 'User-Agent': userAgent},
|
||||||
),
|
),
|
||||||
))!;
|
))!;
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
ensureSuccess(label, response);
|
||||||
throwForStatus(
|
|
||||||
response.statusCode,
|
|
||||||
httpErrorDetail(label, response.body, response.statusCode),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return LoginFlowInit.fromJson(
|
return LoginFlowInit.fromJson(
|
||||||
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>,
|
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>,
|
||||||
);
|
);
|
||||||
@@ -53,12 +48,7 @@ class LoginFlowApi {
|
|||||||
),
|
),
|
||||||
))!;
|
))!;
|
||||||
if (response.statusCode == 404) return null;
|
if (response.statusCode == 404) return null;
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
ensureSuccess(label, response);
|
||||||
throwForStatus(
|
|
||||||
response.statusCode,
|
|
||||||
httpErrorDetail(label, response.body, response.statusCode),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return LoginFlowCredentials.fromJson(
|
return LoginFlowCredentials.fromJson(
|
||||||
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>,
|
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import '../../model/account_data.dart';
|
|
||||||
import '../../model/endpoint_data.dart';
|
import '../../model/endpoint_data.dart';
|
||||||
|
import '../../session/session_manager.dart';
|
||||||
|
|
||||||
/// Shared headers and URI builder for Nextcloud OCS v2 endpoints. Used by
|
/// Shared headers and URI builder for Nextcloud OCS v2 endpoints. Used by
|
||||||
/// TalkApi, AutocompleteApi, FileSharingApi.
|
/// TalkApi, AutocompleteApi, FileSharingApi.
|
||||||
@@ -13,10 +13,13 @@ class NextcloudOcs {
|
|||||||
static Map<String, dynamic> decode(String raw) =>
|
static Map<String, dynamic> decode(String raw) =>
|
||||||
(jsonDecode(raw) as Map<String, dynamic>)['ocs'] as Map<String, dynamic>;
|
(jsonDecode(raw) as Map<String, dynamic>)['ocs'] as Map<String, dynamic>;
|
||||||
|
|
||||||
static Map<String, String> headers() => {
|
/// [authorization] overrides the active session's credentials, which are
|
||||||
|
/// then not required.
|
||||||
|
static Map<String, String> headers({String? authorization}) => {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'OCS-APIRequest': 'true',
|
'OCS-APIRequest': 'true',
|
||||||
'Authorization': AccountData().getBasicAuthHeader(),
|
'Authorization':
|
||||||
|
authorization ?? SessionManager().requireNextcloud().basicAuthHeader,
|
||||||
};
|
};
|
||||||
|
|
||||||
static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) {
|
static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../nextcloud_ocs.dart';
|
||||||
|
import 'search_chat_messages_response.dart';
|
||||||
|
|
||||||
|
/// Wraps Talk's `talk-message-current` search provider, which searches the
|
||||||
|
/// whole server-side history of one conversation. The conversation is picked
|
||||||
|
/// via `from`, the route the provider would be opened from in the web UI.
|
||||||
|
class SearchChatMessages {
|
||||||
|
Future<SearchChatMessagesResponse> run({
|
||||||
|
required String chatToken,
|
||||||
|
required String term,
|
||||||
|
int limit = 25,
|
||||||
|
Object? cursor,
|
||||||
|
}) async {
|
||||||
|
final endpoint = NextcloudOcs.uri(
|
||||||
|
'search/providers/talk-message-current/search',
|
||||||
|
queryParameters: {
|
||||||
|
'term': term,
|
||||||
|
'from': '/call/$chatToken',
|
||||||
|
'limit': limit.toString(),
|
||||||
|
if (cursor != null) 'cursor': cursor.toString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final response = await http.get(endpoint, headers: NextcloudOcs.headers());
|
||||||
|
if (response.statusCode != HttpStatus.ok) {
|
||||||
|
throw Exception(
|
||||||
|
'Chat search failed with ${response.statusCode}: ${response.body}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final data =
|
||||||
|
NextcloudOcs.decode(response.body)['data'] as Map<String, dynamic>;
|
||||||
|
return SearchChatMessagesResponse.fromJson(data, term: term);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/// Parsed page of Talk's `talk-message-current` search provider.
|
||||||
|
class SearchChatMessagesResponse {
|
||||||
|
final List<SearchChatMessagesEntry> entries;
|
||||||
|
|
||||||
|
/// Opaque pagination cursor; `null` when the provider has no further page.
|
||||||
|
final Object? cursor;
|
||||||
|
|
||||||
|
const SearchChatMessagesResponse({required this.entries, this.cursor});
|
||||||
|
|
||||||
|
factory SearchChatMessagesResponse.fromJson(
|
||||||
|
Map<String, dynamic> json, {
|
||||||
|
required String term,
|
||||||
|
}) {
|
||||||
|
final rawEntries = json['entries'];
|
||||||
|
final entries = rawEntries is List
|
||||||
|
? rawEntries
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map((e) => SearchChatMessagesEntry.fromJson(e, term: term))
|
||||||
|
.whereType<SearchChatMessagesEntry>()
|
||||||
|
.toList()
|
||||||
|
: const <SearchChatMessagesEntry>[];
|
||||||
|
final paginated = json['isPaginated'] == true;
|
||||||
|
final cursor = json['cursor'];
|
||||||
|
return SearchChatMessagesResponse(
|
||||||
|
entries: entries,
|
||||||
|
cursor: paginated && entries.isNotEmpty && cursor != null ? cursor : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SearchChatMessagesEntry {
|
||||||
|
final int messageId;
|
||||||
|
final String? actorId;
|
||||||
|
final String? actorDisplayName;
|
||||||
|
|
||||||
|
/// Server-built excerpt of the message, placeholders already resolved.
|
||||||
|
final String excerpt;
|
||||||
|
final DateTime? sentAt;
|
||||||
|
|
||||||
|
const SearchChatMessagesEntry({
|
||||||
|
required this.messageId,
|
||||||
|
required this.excerpt,
|
||||||
|
this.actorId,
|
||||||
|
this.actorDisplayName,
|
||||||
|
this.sentAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Returns `null` for entries without a recognisable message id.
|
||||||
|
static SearchChatMessagesEntry? fromJson(
|
||||||
|
Map<String, dynamic> json, {
|
||||||
|
required String term,
|
||||||
|
}) {
|
||||||
|
final attributes = json['attributes'];
|
||||||
|
final attrs = attributes is Map ? attributes : const {};
|
||||||
|
final messageId =
|
||||||
|
_parseInt(attrs['messageId']) ??
|
||||||
|
_parseInt(
|
||||||
|
RegExp(
|
||||||
|
r'#message_(\d+)',
|
||||||
|
).firstMatch(json['resourceUrl']?.toString() ?? '')?.group(1),
|
||||||
|
);
|
||||||
|
if (messageId == null) return null;
|
||||||
|
|
||||||
|
// Title and subline have swapped roles across Talk versions (author vs.
|
||||||
|
// excerpt); the one containing the search term is the excerpt.
|
||||||
|
final title = json['title']?.toString() ?? '';
|
||||||
|
final subline = json['subline']?.toString() ?? '';
|
||||||
|
final q = term.trim().toLowerCase();
|
||||||
|
final titleIsExcerpt =
|
||||||
|
q.isNotEmpty &&
|
||||||
|
title.toLowerCase().contains(q) &&
|
||||||
|
!subline.toLowerCase().contains(q);
|
||||||
|
final excerpt = titleIsExcerpt ? title : subline;
|
||||||
|
final author = titleIsExcerpt ? subline : title;
|
||||||
|
|
||||||
|
final timestamp = _parseInt(attrs['timestamp']);
|
||||||
|
final actorId = attrs['actorId']?.toString();
|
||||||
|
return SearchChatMessagesEntry(
|
||||||
|
messageId: messageId,
|
||||||
|
excerpt: excerpt,
|
||||||
|
actorId: actorId == null || actorId.isEmpty ? null : actorId,
|
||||||
|
actorDisplayName: author.isEmpty ? null : author,
|
||||||
|
sentAt: timestamp == null
|
||||||
|
? null
|
||||||
|
: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int? _parseInt(Object? raw) => switch (raw) {
|
||||||
|
final int v => v,
|
||||||
|
final String v => int.tryParse(v),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,14 +7,20 @@ part of 'search_files_response.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
SearchFilesResponse _$SearchFilesResponseFromJson(Map<String, dynamic> json) =>
|
SearchFilesResponse _$SearchFilesResponseFromJson(Map<String, dynamic> json) =>
|
||||||
SearchFilesResponse(
|
$checkedCreate('SearchFilesResponse', json, ($checkedConvert) {
|
||||||
name: json['name'] as String,
|
final val = SearchFilesResponse(
|
||||||
isPaginated: json['isPaginated'] as bool,
|
name: $checkedConvert('name', (v) => v as String),
|
||||||
cursor: (json['cursor'] as num?)?.toInt(),
|
isPaginated: $checkedConvert('isPaginated', (v) => v as bool),
|
||||||
entries: (json['entries'] as List<dynamic>)
|
cursor: $checkedConvert('cursor', (v) => (v as num?)?.toInt()),
|
||||||
|
entries: $checkedConvert(
|
||||||
|
'entries',
|
||||||
|
(v) => (v as List<dynamic>)
|
||||||
.map((e) => SearchFilesEntry.fromJson(e as Map<String, dynamic>))
|
.map((e) => SearchFilesEntry.fromJson(e as Map<String, dynamic>))
|
||||||
.toList(),
|
.toList(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$SearchFilesResponseToJson(
|
Map<String, dynamic> _$SearchFilesResponseToJson(
|
||||||
SearchFilesResponse instance,
|
SearchFilesResponse instance,
|
||||||
@@ -26,13 +32,19 @@ Map<String, dynamic> _$SearchFilesResponseToJson(
|
|||||||
};
|
};
|
||||||
|
|
||||||
SearchFilesEntry _$SearchFilesEntryFromJson(Map<String, dynamic> json) =>
|
SearchFilesEntry _$SearchFilesEntryFromJson(Map<String, dynamic> json) =>
|
||||||
SearchFilesEntry(
|
$checkedCreate('SearchFilesEntry', json, ($checkedConvert) {
|
||||||
title: json['title'] as String,
|
final val = SearchFilesEntry(
|
||||||
subline: json['subline'] as String?,
|
title: $checkedConvert('title', (v) => v as String),
|
||||||
icon: json['icon'] as String?,
|
subline: $checkedConvert('subline', (v) => v as String?),
|
||||||
resourceUrl: json['resourceUrl'] as String?,
|
icon: $checkedConvert('icon', (v) => v as String?),
|
||||||
attributes: json['attributes'] as Map<String, dynamic>?,
|
resourceUrl: $checkedConvert('resourceUrl', (v) => v as String?),
|
||||||
|
attributes: $checkedConvert(
|
||||||
|
'attributes',
|
||||||
|
(v) => v as Map<String, dynamic>?,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$SearchFilesEntryToJson(SearchFilesEntry instance) =>
|
Map<String, dynamic> _$SearchFilesEntryToJson(SearchFilesEntry instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../../../errors/server_exception.dart';
|
||||||
|
import '../../../http_errors.dart';
|
||||||
|
import '../../nextcloud_ocs.dart';
|
||||||
|
import 'get_chat_params.dart';
|
||||||
|
import 'get_chat_response.dart';
|
||||||
|
|
||||||
|
/// GETs a chat page outside [TalkApi], which treats non-2xx as errors: here
|
||||||
|
/// HTTP 304 (nothing new / no older messages) is a normal outcome and yields
|
||||||
|
/// `null`.
|
||||||
|
Future<GetChatResponse?> fetchChatPage({
|
||||||
|
required String label,
|
||||||
|
required String chatToken,
|
||||||
|
required GetChatParams params,
|
||||||
|
Duration? timeout,
|
||||||
|
bool parseOffIsolate = false,
|
||||||
|
}) async {
|
||||||
|
final uri = NextcloudOcs.uri(
|
||||||
|
'apps/spreed/api/v1/chat/$chatToken',
|
||||||
|
queryParameters: params.toJson(),
|
||||||
|
);
|
||||||
|
final headers = NextcloudOcs.headers();
|
||||||
|
|
||||||
|
final response = (await sendGuarded('$label $uri', () {
|
||||||
|
final request = http.get(uri, headers: headers);
|
||||||
|
return timeout == null ? request : request.timeout(timeout);
|
||||||
|
}))!;
|
||||||
|
|
||||||
|
final status = response.statusCode;
|
||||||
|
if (status == 304) return null;
|
||||||
|
if (status >= 200 && status < 300) {
|
||||||
|
final parsed = parseOffIsolate
|
||||||
|
? await compute(_parseChatResponse, response.body)
|
||||||
|
: _parseChatResponse(response.body);
|
||||||
|
return parsed..headers = response.headers;
|
||||||
|
}
|
||||||
|
throw ServerException(
|
||||||
|
statusCode: status,
|
||||||
|
technicalDetails: '$label $uri: HTTP $status',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
GetChatResponse _parseChatResponse(String body) =>
|
||||||
|
GetChatResponse.fromJson(NextcloudOcs.decode(body));
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
import 'package:http/http.dart' as http;
|
|
||||||
import 'package:http/http.dart';
|
|
||||||
|
|
||||||
import '../../nextcloud_ocs.dart';
|
import '../../nextcloud_ocs.dart';
|
||||||
import '../talk_api.dart';
|
import '../talk_api.dart';
|
||||||
import 'get_chat_params.dart';
|
import 'get_chat_params.dart';
|
||||||
@@ -16,11 +13,4 @@ class GetChat extends TalkApi<GetChatResponse> {
|
|||||||
@override
|
@override
|
||||||
GetChatResponse assemble(String raw) =>
|
GetChatResponse assemble(String raw) =>
|
||||||
GetChatResponse.fromJson(NextcloudOcs.decode(raw));
|
GetChatResponse.fromJson(NextcloudOcs.decode(raw));
|
||||||
|
|
||||||
@override
|
|
||||||
Future<Response> request(
|
|
||||||
Uri uri,
|
|
||||||
Object? body,
|
|
||||||
Map<String, String>? headers,
|
|
||||||
) => http.get(uri, headers: headers);
|
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,12 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'fetch_chat_page.dart';
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
|
|
||||||
import '../../../errors/server_exception.dart';
|
|
||||||
import '../../../http_errors.dart';
|
|
||||||
import '../../nextcloud_ocs.dart';
|
|
||||||
import 'get_chat_params.dart';
|
import 'get_chat_params.dart';
|
||||||
import 'get_chat_response.dart';
|
import 'get_chat_response.dart';
|
||||||
|
|
||||||
/// Backwards-paging variant of GetChat (`lookIntoFuture=0` + `lastKnownMessageId`)
|
/// Backwards-paging variant of GetChat (`lookIntoFuture=0` + `lastKnownMessageId`)
|
||||||
/// that fetches the page of messages *older* than a given id. Bypasses [TalkApi]
|
/// that fetches the page of messages *older* than a given id. The server
|
||||||
/// because that layer treats non-2xx as errors, and the server answers HTTP 304
|
/// answers HTTP 304 when there are no older messages left — a normal "start of
|
||||||
/// when there are no older messages left — a normal "start of chat" outcome here.
|
/// chat" outcome (see [fetchChatPage]). `setReadMarker=off` so paging into
|
||||||
/// `setReadMarker=off` so paging into history never moves the read cursor.
|
/// history never moves the read cursor.
|
||||||
class GetChatHistory {
|
class GetChatHistory {
|
||||||
final String chatToken;
|
final String chatToken;
|
||||||
final int lastKnownMessageId;
|
final int lastKnownMessageId;
|
||||||
@@ -24,39 +19,18 @@ class GetChatHistory {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/// Returns the older page, or `null` on HTTP 304 (no older messages).
|
/// Returns the older page, or `null` on HTTP 304 (no older messages).
|
||||||
Future<GetChatResponse?> run() async {
|
Future<GetChatResponse?> run() => fetchChatPage(
|
||||||
final params = GetChatParams(
|
label: 'GetChatHistory',
|
||||||
|
chatToken: chatToken,
|
||||||
|
params: GetChatParams(
|
||||||
lookIntoFuture: GetChatParamsSwitch.off,
|
lookIntoFuture: GetChatParamsSwitch.off,
|
||||||
lastKnownMessageId: lastKnownMessageId,
|
lastKnownMessageId: lastKnownMessageId,
|
||||||
includeLastKnown: GetChatParamsSwitch.off,
|
includeLastKnown: GetChatParamsSwitch.off,
|
||||||
setReadMarker: GetChatParamsSwitch.off,
|
setReadMarker: GetChatParamsSwitch.off,
|
||||||
limit: limit,
|
limit: limit,
|
||||||
);
|
),
|
||||||
final uri = NextcloudOcs.uri(
|
|
||||||
'apps/spreed/api/v1/chat/$chatToken',
|
|
||||||
queryParameters: params.toJson(),
|
|
||||||
);
|
|
||||||
final headers = NextcloudOcs.headers();
|
|
||||||
|
|
||||||
final response = (await sendGuarded(
|
|
||||||
'GetChatHistory $uri',
|
|
||||||
() => http.get(uri, headers: headers),
|
|
||||||
))!;
|
|
||||||
|
|
||||||
final status = response.statusCode;
|
|
||||||
if (status == 304) return null;
|
|
||||||
if (status >= 200 && status < 300) {
|
|
||||||
// A page holds up to 200 messages and lands while the user is scrolling;
|
// A page holds up to 200 messages and lands while the user is scrolling;
|
||||||
// decoding it on the UI isolate stalls the fling.
|
// decoding it on the UI isolate stalls the fling.
|
||||||
final parsed = await compute(_parseChatResponse, response.body);
|
parseOffIsolate: true,
|
||||||
return parsed..headers = response.headers;
|
|
||||||
}
|
|
||||||
throw ServerException(
|
|
||||||
statusCode: status,
|
|
||||||
technicalDetails: 'GetChatHistory $uri: HTTP $status',
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
GetChatResponse _parseChatResponse(String body) =>
|
|
||||||
GetChatResponse.fromJson(NextcloudOcs.decode(body));
|
|
||||||
@@ -7,24 +7,33 @@ part of 'get_chat_params.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
GetChatParams _$GetChatParamsFromJson(Map<String, dynamic> json) =>
|
GetChatParams _$GetChatParamsFromJson(Map<String, dynamic> json) =>
|
||||||
GetChatParams(
|
$checkedCreate('GetChatParams', json, ($checkedConvert) {
|
||||||
lookIntoFuture: $enumDecode(
|
final val = GetChatParams(
|
||||||
_$GetChatParamsSwitchEnumMap,
|
lookIntoFuture: $checkedConvert(
|
||||||
json['lookIntoFuture'],
|
'lookIntoFuture',
|
||||||
|
(v) => $enumDecode(_$GetChatParamsSwitchEnumMap, v),
|
||||||
),
|
),
|
||||||
limit: (json['limit'] as num?)?.toInt(),
|
limit: $checkedConvert('limit', (v) => (v as num?)?.toInt()),
|
||||||
lastKnownMessageId: (json['lastKnownMessageId'] as num?)?.toInt(),
|
lastKnownMessageId: $checkedConvert(
|
||||||
lastCommonReadId: (json['lastCommonReadId'] as num?)?.toInt(),
|
'lastKnownMessageId',
|
||||||
timeout: (json['timeout'] as num?)?.toInt(),
|
(v) => (v as num?)?.toInt(),
|
||||||
setReadMarker: $enumDecodeNullable(
|
|
||||||
_$GetChatParamsSwitchEnumMap,
|
|
||||||
json['setReadMarker'],
|
|
||||||
),
|
),
|
||||||
includeLastKnown: $enumDecodeNullable(
|
lastCommonReadId: $checkedConvert(
|
||||||
_$GetChatParamsSwitchEnumMap,
|
'lastCommonReadId',
|
||||||
json['includeLastKnown'],
|
(v) => (v as num?)?.toInt(),
|
||||||
|
),
|
||||||
|
timeout: $checkedConvert('timeout', (v) => (v as num?)?.toInt()),
|
||||||
|
setReadMarker: $checkedConvert(
|
||||||
|
'setReadMarker',
|
||||||
|
(v) => $enumDecodeNullable(_$GetChatParamsSwitchEnumMap, v),
|
||||||
|
),
|
||||||
|
includeLastKnown: $checkedConvert(
|
||||||
|
'includeLastKnown',
|
||||||
|
(v) => $enumDecodeNullable(_$GetChatParamsSwitchEnumMap, v),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$GetChatParamsToJson(
|
Map<String, dynamic> _$GetChatParamsToJson(
|
||||||
GetChatParams instance,
|
GetChatParams instance,
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import 'package:json_annotation/json_annotation.dart';
|
|||||||
|
|
||||||
import '../../../../extensions/date_time.dart';
|
import '../../../../extensions/date_time.dart';
|
||||||
import '../../../api_response.dart';
|
import '../../../api_response.dart';
|
||||||
|
import '../../../json_lenient.dart';
|
||||||
import '../room/get_room_response.dart';
|
import '../room/get_room_response.dart';
|
||||||
|
|
||||||
part 'get_chat_response.g.dart';
|
part 'get_chat_response.g.dart';
|
||||||
|
|
||||||
@JsonSerializable(explicitToJson: true)
|
@JsonSerializable(explicitToJson: true)
|
||||||
class GetChatResponse extends ApiResponse {
|
class GetChatResponse extends ApiResponse {
|
||||||
|
@JsonKey(fromJson: _messagesFromJson)
|
||||||
Set<GetChatResponseObject> data;
|
Set<GetChatResponseObject> data;
|
||||||
|
|
||||||
GetChatResponse(this.data);
|
GetChatResponse(this.data);
|
||||||
@@ -23,15 +25,20 @@ class GetChatResponse extends ApiResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Set<GetChatResponseObject> _messagesFromJson(Object? raw) =>
|
||||||
|
decodeSetLenient(raw, GetChatResponseObject.fromJson, what: 'message');
|
||||||
|
|
||||||
@JsonSerializable(explicitToJson: true)
|
@JsonSerializable(explicitToJson: true)
|
||||||
class GetChatResponseObject {
|
class GetChatResponseObject {
|
||||||
int id;
|
int id;
|
||||||
String token;
|
String token;
|
||||||
|
@JsonKey(unknownEnumValue: GetRoomResponseObjectMessageActorType.unknown)
|
||||||
GetRoomResponseObjectMessageActorType actorType;
|
GetRoomResponseObjectMessageActorType actorType;
|
||||||
String actorId;
|
String actorId;
|
||||||
String actorDisplayName;
|
String actorDisplayName;
|
||||||
int timestamp;
|
int timestamp;
|
||||||
String systemMessage;
|
String systemMessage;
|
||||||
|
@JsonKey(unknownEnumValue: GetRoomResponseObjectMessageType.unknown)
|
||||||
GetRoomResponseObjectMessageType messageType;
|
GetRoomResponseObjectMessageType messageType;
|
||||||
bool isReplyable;
|
bool isReplyable;
|
||||||
String referenceId;
|
String referenceId;
|
||||||
@@ -40,6 +47,11 @@ class GetChatResponseObject {
|
|||||||
List<String>? reactionsSelf;
|
List<String>? reactionsSelf;
|
||||||
@JsonKey(fromJson: _fromJson)
|
@JsonKey(fromJson: _fromJson)
|
||||||
Map<String, RichObjectString>? messageParameters;
|
Map<String, RichObjectString>? messageParameters;
|
||||||
|
|
||||||
|
/// The quoted message. When the original is no longer available (expired,
|
||||||
|
/// purged) the server only sends `{id, deleted: true}`; that becomes a
|
||||||
|
/// deleted-comment placeholder so the quote still renders.
|
||||||
|
@JsonKey(fromJson: _parentFromJson)
|
||||||
GetChatResponseObject? parent;
|
GetChatResponseObject? parent;
|
||||||
|
|
||||||
/// Whether the server flagged this message to be rendered as Markdown
|
/// Whether the server flagged this message to be rendered as Markdown
|
||||||
@@ -70,10 +82,33 @@ class GetChatResponseObject {
|
|||||||
_$GetChatResponseObjectFromJson(json);
|
_$GetChatResponseObjectFromJson(json);
|
||||||
Map<String, dynamic> toJson() => _$GetChatResponseObjectToJson(this);
|
Map<String, dynamic> toJson() => _$GetChatResponseObjectToJson(this);
|
||||||
|
|
||||||
static GetChatResponseObject getDateDummy(int timestamp) {
|
/// [timestamp] is in seconds.
|
||||||
var elementDate = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
|
DateTime get sentAt => DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
|
||||||
return getTextDummy(elementDate.formatDateRelativeShort());
|
|
||||||
}
|
static GetChatResponseObject getDateDummy(int timestamp) => getTextDummy(
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
timestamp * 1000,
|
||||||
|
).formatDateRelativeShort(),
|
||||||
|
);
|
||||||
|
|
||||||
|
static GetChatResponseObject deletedPlaceholder(int id) =>
|
||||||
|
GetChatResponseObject(
|
||||||
|
id,
|
||||||
|
'',
|
||||||
|
GetRoomResponseObjectMessageActorType.user,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
0,
|
||||||
|
'',
|
||||||
|
GetRoomResponseObjectMessageType.deletedComment,
|
||||||
|
false,
|
||||||
|
'',
|
||||||
|
'Nachricht wurde gelöscht',
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
static GetChatResponseObject getTextDummy(String text) =>
|
static GetChatResponseObject getTextDummy(String text) =>
|
||||||
GetChatResponseObject(
|
GetChatResponseObject(
|
||||||
@@ -95,14 +130,24 @@ class GetChatResponseObject {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GetChatResponseObject? _parentFromJson(Object? json) {
|
||||||
|
if (json is! Map<String, dynamic>) return null;
|
||||||
|
if (json['deleted'] == true) {
|
||||||
|
return GetChatResponseObject.deletedPlaceholder(
|
||||||
|
(json['id'] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return GetChatResponseObject.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, RichObjectString>? _fromJson(dynamic json) {
|
Map<String, RichObjectString>? _fromJson(dynamic json) {
|
||||||
if (json is Map<String, dynamic>) {
|
if (json is Map<String, dynamic>) {
|
||||||
final data = <String, RichObjectString>{};
|
final data = <String, RichObjectString>{};
|
||||||
for (final element in json.keys) {
|
for (final entry in json.entries) {
|
||||||
data.putIfAbsent(
|
final value = entry.value;
|
||||||
element,
|
if (value is Map<String, dynamic>) {
|
||||||
() => RichObjectString.fromJson(json[element] as Map<String, dynamic>),
|
data[entry.key] = RichObjectString.fromJson(value);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -112,6 +157,7 @@ Map<String, RichObjectString>? _fromJson(dynamic json) {
|
|||||||
|
|
||||||
@JsonSerializable(explicitToJson: true)
|
@JsonSerializable(explicitToJson: true)
|
||||||
class RichObjectString {
|
class RichObjectString {
|
||||||
|
@JsonKey(unknownEnumValue: RichObjectStringObjectType.unknown)
|
||||||
RichObjectStringObjectType type;
|
RichObjectStringObjectType type;
|
||||||
String id;
|
String id;
|
||||||
String name;
|
String name;
|
||||||
@@ -119,7 +165,17 @@ class RichObjectString {
|
|||||||
String? path;
|
String? path;
|
||||||
String? link;
|
String? link;
|
||||||
|
|
||||||
RichObjectString(this.type, this.id, this.name, this.path, this.link);
|
/// Only set for `file` objects.
|
||||||
|
String? mimetype;
|
||||||
|
|
||||||
|
RichObjectString(
|
||||||
|
this.type,
|
||||||
|
this.id,
|
||||||
|
this.name,
|
||||||
|
this.path,
|
||||||
|
this.link, [
|
||||||
|
this.mimetype,
|
||||||
|
]);
|
||||||
|
|
||||||
factory RichObjectString.fromJson(Map<String, dynamic> json) =>
|
factory RichObjectString.fromJson(Map<String, dynamic> json) =>
|
||||||
_$RichObjectStringFromJson(json);
|
_$RichObjectStringFromJson(json);
|
||||||
@@ -143,4 +199,14 @@ enum RichObjectStringObjectType {
|
|||||||
geoLocation,
|
geoLocation,
|
||||||
@JsonValue('call')
|
@JsonValue('call')
|
||||||
call,
|
call,
|
||||||
|
@JsonValue('user-group')
|
||||||
|
userGroup,
|
||||||
|
@JsonValue('circle')
|
||||||
|
circle,
|
||||||
|
@JsonValue('email')
|
||||||
|
email,
|
||||||
|
|
||||||
|
/// Anything else (deck cards, contacts, calendar events, ...); the
|
||||||
|
/// placeholder still resolves to the object's name.
|
||||||
|
unknown,
|
||||||
}
|
}
|
||||||
@@ -7,16 +7,18 @@ part of 'get_chat_response.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
GetChatResponse _$GetChatResponseFromJson(Map<String, dynamic> json) =>
|
GetChatResponse _$GetChatResponseFromJson(Map<String, dynamic> json) =>
|
||||||
GetChatResponse(
|
$checkedCreate('GetChatResponse', json, ($checkedConvert) {
|
||||||
(json['data'] as List<dynamic>)
|
final val = GetChatResponse(
|
||||||
.map(
|
$checkedConvert('data', (v) => _messagesFromJson(v)),
|
||||||
(e) => GetChatResponseObject.fromJson(e as Map<String, dynamic>),
|
|
||||||
)
|
|
||||||
.toSet(),
|
|
||||||
)
|
|
||||||
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
|
|
||||||
(k, e) => MapEntry(k, e as String),
|
|
||||||
);
|
);
|
||||||
|
$checkedConvert(
|
||||||
|
'headers',
|
||||||
|
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
|
||||||
|
(k, e) => MapEntry(k, e as String),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$GetChatResponseToJson(GetChatResponse instance) =>
|
Map<String, dynamic> _$GetChatResponseToJson(GetChatResponse instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
@@ -26,31 +28,49 @@ Map<String, dynamic> _$GetChatResponseToJson(GetChatResponse instance) =>
|
|||||||
|
|
||||||
GetChatResponseObject _$GetChatResponseObjectFromJson(
|
GetChatResponseObject _$GetChatResponseObjectFromJson(
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
) => GetChatResponseObject(
|
) => $checkedCreate('GetChatResponseObject', json, ($checkedConvert) {
|
||||||
(json['id'] as num).toInt(),
|
final val = GetChatResponseObject(
|
||||||
json['token'] as String,
|
$checkedConvert('id', (v) => (v as num).toInt()),
|
||||||
$enumDecode(
|
$checkedConvert('token', (v) => v as String),
|
||||||
|
$checkedConvert(
|
||||||
|
'actorType',
|
||||||
|
(v) => $enumDecode(
|
||||||
_$GetRoomResponseObjectMessageActorTypeEnumMap,
|
_$GetRoomResponseObjectMessageActorTypeEnumMap,
|
||||||
json['actorType'],
|
v,
|
||||||
|
unknownValue: GetRoomResponseObjectMessageActorType.unknown,
|
||||||
),
|
),
|
||||||
json['actorId'] as String,
|
),
|
||||||
json['actorDisplayName'] as String,
|
$checkedConvert('actorId', (v) => v as String),
|
||||||
(json['timestamp'] as num).toInt(),
|
$checkedConvert('actorDisplayName', (v) => v as String),
|
||||||
json['systemMessage'] as String,
|
$checkedConvert('timestamp', (v) => (v as num).toInt()),
|
||||||
$enumDecode(_$GetRoomResponseObjectMessageTypeEnumMap, json['messageType']),
|
$checkedConvert('systemMessage', (v) => v as String),
|
||||||
json['isReplyable'] as bool,
|
$checkedConvert(
|
||||||
json['referenceId'] as String,
|
'messageType',
|
||||||
json['message'] as String,
|
(v) => $enumDecode(
|
||||||
_fromJson(json['messageParameters']),
|
_$GetRoomResponseObjectMessageTypeEnumMap,
|
||||||
(json['reactions'] as Map<String, dynamic>?)?.map(
|
v,
|
||||||
|
unknownValue: GetRoomResponseObjectMessageType.unknown,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
$checkedConvert('isReplyable', (v) => v as bool),
|
||||||
|
$checkedConvert('referenceId', (v) => v as String),
|
||||||
|
$checkedConvert('message', (v) => v as String),
|
||||||
|
$checkedConvert('messageParameters', (v) => _fromJson(v)),
|
||||||
|
$checkedConvert(
|
||||||
|
'reactions',
|
||||||
|
(v) => (v as Map<String, dynamic>?)?.map(
|
||||||
(k, e) => MapEntry(k, (e as num).toInt()),
|
(k, e) => MapEntry(k, (e as num).toInt()),
|
||||||
),
|
),
|
||||||
(json['reactionsSelf'] as List<dynamic>?)?.map((e) => e as String).toList(),
|
),
|
||||||
json['parent'] == null
|
$checkedConvert(
|
||||||
? null
|
'reactionsSelf',
|
||||||
: GetChatResponseObject.fromJson(json['parent'] as Map<String, dynamic>),
|
(v) => (v as List<dynamic>?)?.map((e) => e as String).toList(),
|
||||||
json['markdown'] as bool? ?? false,
|
),
|
||||||
);
|
$checkedConvert('parent', (v) => _parentFromJson(v)),
|
||||||
|
$checkedConvert('markdown', (v) => v as bool? ?? false),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$GetChatResponseObjectToJson(
|
Map<String, dynamic> _$GetChatResponseObjectToJson(
|
||||||
GetChatResponseObject instance,
|
GetChatResponseObject instance,
|
||||||
@@ -83,6 +103,12 @@ const _$GetRoomResponseObjectMessageActorTypeEnumMap = {
|
|||||||
GetRoomResponseObjectMessageActorType.guest: 'guests',
|
GetRoomResponseObjectMessageActorType.guest: 'guests',
|
||||||
GetRoomResponseObjectMessageActorType.bot: 'bots',
|
GetRoomResponseObjectMessageActorType.bot: 'bots',
|
||||||
GetRoomResponseObjectMessageActorType.bridge: 'bridged',
|
GetRoomResponseObjectMessageActorType.bridge: 'bridged',
|
||||||
|
GetRoomResponseObjectMessageActorType.group: 'groups',
|
||||||
|
GetRoomResponseObjectMessageActorType.email: 'emails',
|
||||||
|
GetRoomResponseObjectMessageActorType.circle: 'circles',
|
||||||
|
GetRoomResponseObjectMessageActorType.federatedUser: 'federated_users',
|
||||||
|
GetRoomResponseObjectMessageActorType.phone: 'phones',
|
||||||
|
GetRoomResponseObjectMessageActorType.unknown: 'unknown',
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$GetRoomResponseObjectMessageTypeEnumMap = {
|
const _$GetRoomResponseObjectMessageTypeEnumMap = {
|
||||||
@@ -91,16 +117,28 @@ const _$GetRoomResponseObjectMessageTypeEnumMap = {
|
|||||||
GetRoomResponseObjectMessageType.deletedComment: 'comment_deleted',
|
GetRoomResponseObjectMessageType.deletedComment: 'comment_deleted',
|
||||||
GetRoomResponseObjectMessageType.system: 'system',
|
GetRoomResponseObjectMessageType.system: 'system',
|
||||||
GetRoomResponseObjectMessageType.command: 'command',
|
GetRoomResponseObjectMessageType.command: 'command',
|
||||||
|
GetRoomResponseObjectMessageType.unknown: 'unknown',
|
||||||
};
|
};
|
||||||
|
|
||||||
RichObjectString _$RichObjectStringFromJson(Map<String, dynamic> json) =>
|
RichObjectString _$RichObjectStringFromJson(Map<String, dynamic> json) =>
|
||||||
RichObjectString(
|
$checkedCreate('RichObjectString', json, ($checkedConvert) {
|
||||||
$enumDecode(_$RichObjectStringObjectTypeEnumMap, json['type']),
|
final val = RichObjectString(
|
||||||
json['id'] as String,
|
$checkedConvert(
|
||||||
json['name'] as String,
|
'type',
|
||||||
json['path'] as String?,
|
(v) => $enumDecode(
|
||||||
json['link'] as String?,
|
_$RichObjectStringObjectTypeEnumMap,
|
||||||
|
v,
|
||||||
|
unknownValue: RichObjectStringObjectType.unknown,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
$checkedConvert('id', (v) => v as String),
|
||||||
|
$checkedConvert('name', (v) => v as String),
|
||||||
|
$checkedConvert('path', (v) => v as String?),
|
||||||
|
$checkedConvert('link', (v) => v as String?),
|
||||||
|
$checkedConvert('mimetype', (v) => v as String?),
|
||||||
);
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$RichObjectStringToJson(RichObjectString instance) =>
|
Map<String, dynamic> _$RichObjectStringToJson(RichObjectString instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
@@ -109,6 +147,7 @@ Map<String, dynamic> _$RichObjectStringToJson(RichObjectString instance) =>
|
|||||||
'name': instance.name,
|
'name': instance.name,
|
||||||
'path': instance.path,
|
'path': instance.path,
|
||||||
'link': instance.link,
|
'link': instance.link,
|
||||||
|
'mimetype': instance.mimetype,
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$RichObjectStringObjectTypeEnumMap = {
|
const _$RichObjectStringObjectTypeEnumMap = {
|
||||||
@@ -120,4 +159,8 @@ const _$RichObjectStringObjectTypeEnumMap = {
|
|||||||
RichObjectStringObjectType.talkPoll: 'talk-poll',
|
RichObjectStringObjectType.talkPoll: 'talk-poll',
|
||||||
RichObjectStringObjectType.geoLocation: 'geo-location',
|
RichObjectStringObjectType.geoLocation: 'geo-location',
|
||||||
RichObjectStringObjectType.call: 'call',
|
RichObjectStringObjectType.call: 'call',
|
||||||
|
RichObjectStringObjectType.userGroup: 'user-group',
|
||||||
|
RichObjectStringObjectType.circle: 'circle',
|
||||||
|
RichObjectStringObjectType.email: 'email',
|
||||||
|
RichObjectStringObjectType.unknown: 'unknown',
|
||||||
};
|
};
|
||||||
@@ -1,15 +1,10 @@
|
|||||||
import 'package:http/http.dart' as http;
|
import 'fetch_chat_page.dart';
|
||||||
|
|
||||||
import '../../../errors/server_exception.dart';
|
|
||||||
import '../../../http_errors.dart';
|
|
||||||
import '../../nextcloud_ocs.dart';
|
|
||||||
import 'get_chat_params.dart';
|
import 'get_chat_params.dart';
|
||||||
import 'get_chat_response.dart';
|
import 'get_chat_response.dart';
|
||||||
|
|
||||||
/// Long-poll variant of GetChat (`lookIntoFuture=1`). Bypasses [TalkApi]
|
/// Long-poll variant of GetChat (`lookIntoFuture=1`); HTTP 304 is the normal
|
||||||
/// because that layer treats non-2xx as errors, and we need 304 to be a
|
/// "no new messages" outcome (see [fetchChatPage]). `setReadMarker=on` lets
|
||||||
/// normal "no new messages" outcome. `setReadMarker=on` lets the server
|
/// the server move the read cursor whenever the call returns messages.
|
||||||
/// move the read cursor whenever the call returns messages.
|
|
||||||
class LongPollChat {
|
class LongPollChat {
|
||||||
final String chatToken;
|
final String chatToken;
|
||||||
final int lastKnownMessageId;
|
final int lastKnownMessageId;
|
||||||
@@ -22,37 +17,17 @@ class LongPollChat {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/// Returns the response, or `null` on HTTP 304 (server timeout, nothing new).
|
/// Returns the response, or `null` on HTTP 304 (server timeout, nothing new).
|
||||||
Future<GetChatResponse?> run() async {
|
Future<GetChatResponse?> run() => fetchChatPage(
|
||||||
final params = GetChatParams(
|
label: 'LongPollChat',
|
||||||
|
chatToken: chatToken,
|
||||||
|
params: GetChatParams(
|
||||||
lookIntoFuture: GetChatParamsSwitch.on,
|
lookIntoFuture: GetChatParamsSwitch.on,
|
||||||
timeout: timeoutSeconds,
|
timeout: timeoutSeconds,
|
||||||
lastKnownMessageId: lastKnownMessageId,
|
lastKnownMessageId: lastKnownMessageId,
|
||||||
includeLastKnown: GetChatParamsSwitch.off,
|
includeLastKnown: GetChatParamsSwitch.off,
|
||||||
setReadMarker: GetChatParamsSwitch.on,
|
setReadMarker: GetChatParamsSwitch.on,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
|
),
|
||||||
|
timeout: Duration(seconds: timeoutSeconds + 15),
|
||||||
);
|
);
|
||||||
final uri = NextcloudOcs.uri(
|
|
||||||
'apps/spreed/api/v1/chat/$chatToken',
|
|
||||||
queryParameters: params.toJson(),
|
|
||||||
);
|
|
||||||
final headers = NextcloudOcs.headers();
|
|
||||||
|
|
||||||
final response = (await sendGuarded(
|
|
||||||
'LongPollChat $uri',
|
|
||||||
() => http
|
|
||||||
.get(uri, headers: headers)
|
|
||||||
.timeout(Duration(seconds: timeoutSeconds + 15)),
|
|
||||||
))!;
|
|
||||||
|
|
||||||
final status = response.statusCode;
|
|
||||||
if (status == 304) return null;
|
|
||||||
if (status >= 200 && status < 300) {
|
|
||||||
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
|
|
||||||
..headers = response.headers;
|
|
||||||
}
|
|
||||||
throw ServerException(
|
|
||||||
statusCode: status,
|
|
||||||
technicalDetails: 'LongPollChat $uri: HTTP $status',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -7,14 +7,18 @@ part of 'create_poll_params.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
CreatePollParams _$CreatePollParamsFromJson(Map<String, dynamic> json) =>
|
CreatePollParams _$CreatePollParamsFromJson(Map<String, dynamic> json) =>
|
||||||
CreatePollParams(
|
$checkedCreate('CreatePollParams', json, ($checkedConvert) {
|
||||||
question: json['question'] as String,
|
final val = CreatePollParams(
|
||||||
options: (json['options'] as List<dynamic>)
|
question: $checkedConvert('question', (v) => v as String),
|
||||||
.map((e) => e as String)
|
options: $checkedConvert(
|
||||||
.toList(),
|
'options',
|
||||||
resultMode: (json['resultMode'] as num).toInt(),
|
(v) => (v as List<dynamic>).map((e) => e as String).toList(),
|
||||||
maxVotes: (json['maxVotes'] as num).toInt(),
|
),
|
||||||
|
resultMode: $checkedConvert('resultMode', (v) => (v as num).toInt()),
|
||||||
|
maxVotes: $checkedConvert('maxVotes', (v) => (v as num).toInt()),
|
||||||
);
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$CreatePollParamsToJson(CreatePollParams instance) =>
|
Map<String, dynamic> _$CreatePollParamsToJson(CreatePollParams instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
|
|||||||
@@ -7,14 +7,17 @@ part of 'create_room_params.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
CreateRoomParams _$CreateRoomParamsFromJson(Map<String, dynamic> json) =>
|
CreateRoomParams _$CreateRoomParamsFromJson(Map<String, dynamic> json) =>
|
||||||
CreateRoomParams(
|
$checkedCreate('CreateRoomParams', json, ($checkedConvert) {
|
||||||
roomType: (json['roomType'] as num).toInt(),
|
final val = CreateRoomParams(
|
||||||
invite: json['invite'] as String,
|
roomType: $checkedConvert('roomType', (v) => (v as num).toInt()),
|
||||||
source: json['source'] as String?,
|
invite: $checkedConvert('invite', (v) => v as String),
|
||||||
roomName: json['roomName'] as String?,
|
source: $checkedConvert('source', (v) => v as String?),
|
||||||
objectType: json['objectType'] as String?,
|
roomName: $checkedConvert('roomName', (v) => v as String?),
|
||||||
objectId: json['objectId'] as String?,
|
objectType: $checkedConvert('objectType', (v) => v as String?),
|
||||||
|
objectId: $checkedConvert('objectId', (v) => v as String?),
|
||||||
);
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$CreateRoomParamsToJson(CreateRoomParams instance) =>
|
Map<String, dynamic> _$CreateRoomParamsToJson(CreateRoomParams instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
|
|||||||
@@ -7,12 +7,21 @@ part of 'create_room_response.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
CreateRoomResponse _$CreateRoomResponseFromJson(Map<String, dynamic> json) =>
|
CreateRoomResponse _$CreateRoomResponseFromJson(Map<String, dynamic> json) =>
|
||||||
CreateRoomResponse(
|
$checkedCreate('CreateRoomResponse', json, ($checkedConvert) {
|
||||||
CreateRoomResponseData.fromJson(json['data'] as Map<String, dynamic>),
|
final val = CreateRoomResponse(
|
||||||
)
|
$checkedConvert(
|
||||||
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
|
'data',
|
||||||
(k, e) => MapEntry(k, e as String),
|
(v) => CreateRoomResponseData.fromJson(v as Map<String, dynamic>),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
$checkedConvert(
|
||||||
|
'headers',
|
||||||
|
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
|
||||||
|
(k, e) => MapEntry(k, e as String),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$CreateRoomResponseToJson(CreateRoomResponse instance) =>
|
Map<String, dynamic> _$CreateRoomResponseToJson(CreateRoomResponse instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
@@ -22,7 +31,12 @@ Map<String, dynamic> _$CreateRoomResponseToJson(CreateRoomResponse instance) =>
|
|||||||
|
|
||||||
CreateRoomResponseData _$CreateRoomResponseDataFromJson(
|
CreateRoomResponseData _$CreateRoomResponseDataFromJson(
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
) => CreateRoomResponseData(json['token'] as String);
|
) => $checkedCreate('CreateRoomResponseData', json, ($checkedConvert) {
|
||||||
|
final val = CreateRoomResponseData(
|
||||||
|
$checkedConvert('token', (v) => v as String),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$CreateRoomResponseDataToJson(
|
Map<String, dynamic> _$CreateRoomResponseDataToJson(
|
||||||
CreateRoomResponseData instance,
|
CreateRoomResponseData instance,
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ part of 'delete_react_message_params.dart';
|
|||||||
|
|
||||||
DeleteReactMessageParams _$DeleteReactMessageParamsFromJson(
|
DeleteReactMessageParams _$DeleteReactMessageParamsFromJson(
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
) => DeleteReactMessageParams(json['reaction'] as String);
|
) => $checkedCreate('DeleteReactMessageParams', json, ($checkedConvert) {
|
||||||
|
final val = DeleteReactMessageParams(
|
||||||
|
$checkedConvert('reaction', (v) => v as String),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$DeleteReactMessageParamsToJson(
|
Map<String, dynamic> _$DeleteReactMessageParamsToJson(
|
||||||
DeleteReactMessageParams instance,
|
DeleteReactMessageParams instance,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import 'package:http/http.dart' as http;
|
|
||||||
|
|
||||||
import '../../nextcloud_ocs.dart';
|
import '../../nextcloud_ocs.dart';
|
||||||
import '../talk_api.dart';
|
import '../talk_api.dart';
|
||||||
import 'get_participants_response.dart';
|
import 'get_participants_response.dart';
|
||||||
@@ -11,11 +9,4 @@ class GetParticipants extends TalkApi<GetParticipantsResponse> {
|
|||||||
@override
|
@override
|
||||||
GetParticipantsResponse assemble(String raw) =>
|
GetParticipantsResponse assemble(String raw) =>
|
||||||
GetParticipantsResponse.fromJson(NextcloudOcs.decode(raw));
|
GetParticipantsResponse.fromJson(NextcloudOcs.decode(raw));
|
||||||
|
|
||||||
@override
|
|
||||||
Future<http.Response> request(
|
|
||||||
Uri uri,
|
|
||||||
Object? body,
|
|
||||||
Map<String, String>? headers,
|
|
||||||
) => http.get(uri, headers: headers);
|
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
import '../../../api_response.dart';
|
import '../../../api_response.dart';
|
||||||
|
import '../../../json_lenient.dart';
|
||||||
|
|
||||||
part 'get_participants_response.g.dart';
|
part 'get_participants_response.g.dart';
|
||||||
|
|
||||||
@JsonSerializable(explicitToJson: true)
|
@JsonSerializable(explicitToJson: true)
|
||||||
class GetParticipantsResponse extends ApiResponse {
|
class GetParticipantsResponse extends ApiResponse {
|
||||||
|
@JsonKey(fromJson: _participantsFromJson)
|
||||||
Set<GetParticipantsResponseObject> data;
|
Set<GetParticipantsResponseObject> data;
|
||||||
|
|
||||||
GetParticipantsResponse(this.data);
|
GetParticipantsResponse(this.data);
|
||||||
@@ -15,15 +17,25 @@ class GetParticipantsResponse extends ApiResponse {
|
|||||||
Map<String, dynamic> toJson() => _$GetParticipantsResponseToJson(this);
|
Map<String, dynamic> toJson() => _$GetParticipantsResponseToJson(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Set<GetParticipantsResponseObject> _participantsFromJson(Object? raw) =>
|
||||||
|
decodeSetLenient(
|
||||||
|
raw,
|
||||||
|
GetParticipantsResponseObject.fromJson,
|
||||||
|
what: 'participant',
|
||||||
|
);
|
||||||
|
|
||||||
@JsonSerializable(explicitToJson: true)
|
@JsonSerializable(explicitToJson: true)
|
||||||
class GetParticipantsResponseObject {
|
class GetParticipantsResponseObject {
|
||||||
int attendeeId;
|
int attendeeId;
|
||||||
String actorType;
|
String actorType;
|
||||||
String actorId;
|
String actorId;
|
||||||
String displayName;
|
String displayName;
|
||||||
|
@JsonKey(unknownEnumValue: GetParticipantsResponseObjectParticipantType.user)
|
||||||
GetParticipantsResponseObjectParticipantType participantType;
|
GetParticipantsResponseObjectParticipantType participantType;
|
||||||
int lastPing;
|
int lastPing;
|
||||||
GetParticipantsResponseObjectParticipantsInCallFlags inCall;
|
|
||||||
|
/// Bitmask: 1 = in call, 2 = with audio, 4 = with video, 8 = via phone.
|
||||||
|
int inCall;
|
||||||
int permissions;
|
int permissions;
|
||||||
int attendeePermissions;
|
int attendeePermissions;
|
||||||
String? sessionId;
|
String? sessionId;
|
||||||
@@ -54,6 +66,11 @@ class GetParticipantsResponseObject {
|
|||||||
factory GetParticipantsResponseObject.fromJson(Map<String, dynamic> json) =>
|
factory GetParticipantsResponseObject.fromJson(Map<String, dynamic> json) =>
|
||||||
_$GetParticipantsResponseObjectFromJson(json);
|
_$GetParticipantsResponseObjectFromJson(json);
|
||||||
Map<String, dynamic> toJson() => _$GetParticipantsResponseObjectToJson(this);
|
Map<String, dynamic> toJson() => _$GetParticipantsResponseObjectToJson(this);
|
||||||
|
|
||||||
|
bool get isInCall => inCall & 1 != 0;
|
||||||
|
bool get inCallWithAudio => inCall & 2 != 0;
|
||||||
|
bool get inCallWithVideo => inCall & 4 != 0;
|
||||||
|
bool get inCallViaPhone => inCall & 8 != 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum GetParticipantsResponseObjectParticipantType {
|
enum GetParticipantsResponseObjectParticipantType {
|
||||||
@@ -73,16 +90,3 @@ enum GetParticipantsResponseObjectParticipantType {
|
|||||||
const GetParticipantsResponseObjectParticipantType(this.prettyName);
|
const GetParticipantsResponseObjectParticipantType(this.prettyName);
|
||||||
final String prettyName;
|
final String prettyName;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum GetParticipantsResponseObjectParticipantsInCallFlags {
|
|
||||||
@JsonValue(0)
|
|
||||||
disconnected,
|
|
||||||
@JsonValue(1)
|
|
||||||
inCall,
|
|
||||||
@JsonValue(2)
|
|
||||||
providesAudio,
|
|
||||||
@JsonValue(3)
|
|
||||||
providesVideo,
|
|
||||||
@JsonValue(4)
|
|
||||||
usesSipDialIn,
|
|
||||||
}
|
|
||||||
@@ -8,19 +8,18 @@ part of 'get_participants_response.dart';
|
|||||||
|
|
||||||
GetParticipantsResponse _$GetParticipantsResponseFromJson(
|
GetParticipantsResponse _$GetParticipantsResponseFromJson(
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
) =>
|
) => $checkedCreate('GetParticipantsResponse', json, ($checkedConvert) {
|
||||||
GetParticipantsResponse(
|
final val = GetParticipantsResponse(
|
||||||
(json['data'] as List<dynamic>)
|
$checkedConvert('data', (v) => _participantsFromJson(v)),
|
||||||
.map(
|
|
||||||
(e) => GetParticipantsResponseObject.fromJson(
|
|
||||||
e as Map<String, dynamic>,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toSet(),
|
|
||||||
)
|
|
||||||
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
|
|
||||||
(k, e) => MapEntry(k, e as String),
|
|
||||||
);
|
);
|
||||||
|
$checkedConvert(
|
||||||
|
'headers',
|
||||||
|
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
|
||||||
|
(k, e) => MapEntry(k, e as String),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$GetParticipantsResponseToJson(
|
Map<String, dynamic> _$GetParticipantsResponseToJson(
|
||||||
GetParticipantsResponse instance,
|
GetParticipantsResponse instance,
|
||||||
@@ -31,29 +30,36 @@ Map<String, dynamic> _$GetParticipantsResponseToJson(
|
|||||||
|
|
||||||
GetParticipantsResponseObject _$GetParticipantsResponseObjectFromJson(
|
GetParticipantsResponseObject _$GetParticipantsResponseObjectFromJson(
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
) => GetParticipantsResponseObject(
|
) => $checkedCreate('GetParticipantsResponseObject', json, ($checkedConvert) {
|
||||||
(json['attendeeId'] as num).toInt(),
|
final val = GetParticipantsResponseObject(
|
||||||
json['actorType'] as String,
|
$checkedConvert('attendeeId', (v) => (v as num).toInt()),
|
||||||
json['actorId'] as String,
|
$checkedConvert('actorType', (v) => v as String),
|
||||||
json['displayName'] as String,
|
$checkedConvert('actorId', (v) => v as String),
|
||||||
$enumDecode(
|
$checkedConvert('displayName', (v) => v as String),
|
||||||
|
$checkedConvert(
|
||||||
|
'participantType',
|
||||||
|
(v) => $enumDecode(
|
||||||
_$GetParticipantsResponseObjectParticipantTypeEnumMap,
|
_$GetParticipantsResponseObjectParticipantTypeEnumMap,
|
||||||
json['participantType'],
|
v,
|
||||||
|
unknownValue: GetParticipantsResponseObjectParticipantType.user,
|
||||||
),
|
),
|
||||||
(json['lastPing'] as num).toInt(),
|
|
||||||
$enumDecode(
|
|
||||||
_$GetParticipantsResponseObjectParticipantsInCallFlagsEnumMap,
|
|
||||||
json['inCall'],
|
|
||||||
),
|
),
|
||||||
(json['permissions'] as num).toInt(),
|
$checkedConvert('lastPing', (v) => (v as num).toInt()),
|
||||||
(json['attendeePermissions'] as num).toInt(),
|
$checkedConvert('inCall', (v) => (v as num).toInt()),
|
||||||
json['sessionId'] as String?,
|
$checkedConvert('permissions', (v) => (v as num).toInt()),
|
||||||
(json['sessionIds'] as List<dynamic>).map((e) => e as String).toList(),
|
$checkedConvert('attendeePermissions', (v) => (v as num).toInt()),
|
||||||
json['status'] as String?,
|
$checkedConvert('sessionId', (v) => v as String?),
|
||||||
json['statusIcon'] as String?,
|
$checkedConvert(
|
||||||
json['statusMessage'] as String?,
|
'sessionIds',
|
||||||
json['roomToken'] as String?,
|
(v) => (v as List<dynamic>).map((e) => e as String).toList(),
|
||||||
);
|
),
|
||||||
|
$checkedConvert('status', (v) => v as String?),
|
||||||
|
$checkedConvert('statusIcon', (v) => v as String?),
|
||||||
|
$checkedConvert('statusMessage', (v) => v as String?),
|
||||||
|
$checkedConvert('roomToken', (v) => v as String?),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$GetParticipantsResponseObjectToJson(
|
Map<String, dynamic> _$GetParticipantsResponseObjectToJson(
|
||||||
GetParticipantsResponseObject instance,
|
GetParticipantsResponseObject instance,
|
||||||
@@ -66,9 +72,7 @@ Map<String, dynamic> _$GetParticipantsResponseObjectToJson(
|
|||||||
_$GetParticipantsResponseObjectParticipantTypeEnumMap[instance
|
_$GetParticipantsResponseObjectParticipantTypeEnumMap[instance
|
||||||
.participantType]!,
|
.participantType]!,
|
||||||
'lastPing': instance.lastPing,
|
'lastPing': instance.lastPing,
|
||||||
'inCall':
|
'inCall': instance.inCall,
|
||||||
_$GetParticipantsResponseObjectParticipantsInCallFlagsEnumMap[instance
|
|
||||||
.inCall]!,
|
|
||||||
'permissions': instance.permissions,
|
'permissions': instance.permissions,
|
||||||
'attendeePermissions': instance.attendeePermissions,
|
'attendeePermissions': instance.attendeePermissions,
|
||||||
'sessionId': instance.sessionId,
|
'sessionId': instance.sessionId,
|
||||||
@@ -87,11 +91,3 @@ const _$GetParticipantsResponseObjectParticipantTypeEnumMap = {
|
|||||||
GetParticipantsResponseObjectParticipantType.userFollowingPublicLink: 5,
|
GetParticipantsResponseObjectParticipantType.userFollowingPublicLink: 5,
|
||||||
GetParticipantsResponseObjectParticipantType.guestWithModeratorPermissions: 6,
|
GetParticipantsResponseObjectParticipantType.guestWithModeratorPermissions: 6,
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$GetParticipantsResponseObjectParticipantsInCallFlagsEnumMap = {
|
|
||||||
GetParticipantsResponseObjectParticipantsInCallFlags.disconnected: 0,
|
|
||||||
GetParticipantsResponseObjectParticipantsInCallFlags.inCall: 1,
|
|
||||||
GetParticipantsResponseObjectParticipantsInCallFlags.providesAudio: 2,
|
|
||||||
GetParticipantsResponseObjectParticipantsInCallFlags.providesVideo: 3,
|
|
||||||
GetParticipantsResponseObjectParticipantsInCallFlags.usesSipDialIn: 4,
|
|
||||||
};
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import 'package:http/http.dart' as http;
|
|
||||||
|
|
||||||
import '../../nextcloud_ocs.dart';
|
import '../../nextcloud_ocs.dart';
|
||||||
import '../talk_api.dart';
|
import '../talk_api.dart';
|
||||||
import 'get_poll_state_response.dart';
|
import 'get_poll_state_response.dart';
|
||||||
@@ -13,11 +11,4 @@ class GetPollState extends TalkApi<GetPollStateResponse> {
|
|||||||
@override
|
@override
|
||||||
GetPollStateResponse assemble(String raw) =>
|
GetPollStateResponse assemble(String raw) =>
|
||||||
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
|
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
|
||||||
|
|
||||||
@override
|
|
||||||
Future<http.Response> request(
|
|
||||||
Uri uri,
|
|
||||||
Object? body,
|
|
||||||
Map<String, String>? headers,
|
|
||||||
) => http.get(uri, headers: headers);
|
|
||||||
}
|
}
|
||||||
@@ -8,15 +8,21 @@ part of 'get_poll_state_response.dart';
|
|||||||
|
|
||||||
GetPollStateResponse _$GetPollStateResponseFromJson(
|
GetPollStateResponse _$GetPollStateResponseFromJson(
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
) =>
|
) => $checkedCreate('GetPollStateResponse', json, ($checkedConvert) {
|
||||||
GetPollStateResponse(
|
final val = GetPollStateResponse(
|
||||||
GetPollStateResponseObject.fromJson(
|
$checkedConvert(
|
||||||
json['data'] as Map<String, dynamic>,
|
'data',
|
||||||
|
(v) => GetPollStateResponseObject.fromJson(v as Map<String, dynamic>),
|
||||||
),
|
),
|
||||||
)
|
|
||||||
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
|
|
||||||
(k, e) => MapEntry(k, e as String),
|
|
||||||
);
|
);
|
||||||
|
$checkedConvert(
|
||||||
|
'headers',
|
||||||
|
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
|
||||||
|
(k, e) => MapEntry(k, e as String),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$GetPollStateResponseToJson(
|
Map<String, dynamic> _$GetPollStateResponseToJson(
|
||||||
GetPollStateResponse instance,
|
GetPollStateResponse instance,
|
||||||
@@ -27,21 +33,30 @@ Map<String, dynamic> _$GetPollStateResponseToJson(
|
|||||||
|
|
||||||
GetPollStateResponseObject _$GetPollStateResponseObjectFromJson(
|
GetPollStateResponseObject _$GetPollStateResponseObjectFromJson(
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
) => GetPollStateResponseObject(
|
) => $checkedCreate('GetPollStateResponseObject', json, ($checkedConvert) {
|
||||||
(json['id'] as num).toInt(),
|
final val = GetPollStateResponseObject(
|
||||||
json['question'] as String,
|
$checkedConvert('id', (v) => (v as num).toInt()),
|
||||||
(json['options'] as List<dynamic>).map((e) => e as String).toList(),
|
$checkedConvert('question', (v) => v as String),
|
||||||
json['votes'],
|
$checkedConvert(
|
||||||
json['actorType'] as String,
|
'options',
|
||||||
json['actorId'] as String,
|
(v) => (v as List<dynamic>).map((e) => e as String).toList(),
|
||||||
json['actorDisplayName'] as String,
|
),
|
||||||
(json['status'] as num).toInt(),
|
$checkedConvert('votes', (v) => v),
|
||||||
(json['resultMode'] as num).toInt(),
|
$checkedConvert('actorType', (v) => v as String),
|
||||||
(json['maxVotes'] as num).toInt(),
|
$checkedConvert('actorId', (v) => v as String),
|
||||||
(json['votedSelf'] as List<dynamic>).map((e) => (e as num).toInt()).toList(),
|
$checkedConvert('actorDisplayName', (v) => v as String),
|
||||||
(json['numVoters'] as num?)?.toInt(),
|
$checkedConvert('status', (v) => (v as num).toInt()),
|
||||||
json['details'] as List<dynamic>?,
|
$checkedConvert('resultMode', (v) => (v as num).toInt()),
|
||||||
);
|
$checkedConvert('maxVotes', (v) => (v as num).toInt()),
|
||||||
|
$checkedConvert(
|
||||||
|
'votedSelf',
|
||||||
|
(v) => (v as List<dynamic>).map((e) => (e as num).toInt()).toList(),
|
||||||
|
),
|
||||||
|
$checkedConvert('numVoters', (v) => (v as num?)?.toInt()),
|
||||||
|
$checkedConvert('details', (v) => v as List<dynamic>?),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$GetPollStateResponseObjectToJson(
|
Map<String, dynamic> _$GetPollStateResponseObjectToJson(
|
||||||
GetPollStateResponseObject instance,
|
GetPollStateResponseObject instance,
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
import 'package:http/http.dart' as http;
|
|
||||||
import 'package:http/http.dart';
|
|
||||||
|
|
||||||
import '../../../api_params.dart';
|
|
||||||
import '../../nextcloud_ocs.dart';
|
import '../../nextcloud_ocs.dart';
|
||||||
import '../talk_api.dart';
|
import '../talk_api.dart';
|
||||||
import 'get_reactions_response.dart';
|
import 'get_reactions_response.dart';
|
||||||
@@ -15,11 +11,4 @@ class GetReactions extends TalkApi<GetReactionsResponse> {
|
|||||||
@override
|
@override
|
||||||
GetReactionsResponse assemble(String raw) =>
|
GetReactionsResponse assemble(String raw) =>
|
||||||
GetReactionsResponse.fromJson(NextcloudOcs.decode(raw));
|
GetReactionsResponse.fromJson(NextcloudOcs.decode(raw));
|
||||||
|
|
||||||
@override
|
|
||||||
Future<Response>? request(
|
|
||||||
Uri uri,
|
|
||||||
ApiParams? body,
|
|
||||||
Map<String, String>? headers,
|
|
||||||
) => http.get(uri, headers: headers);
|
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,7 @@ class GetReactionsResponse extends ApiResponse {
|
|||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class GetReactionsResponseObject {
|
class GetReactionsResponseObject {
|
||||||
|
@JsonKey(unknownEnumValue: GetReactionsResponseObjectActorType.unknown)
|
||||||
GetReactionsResponseObjectActorType actorType;
|
GetReactionsResponseObjectActorType actorType;
|
||||||
String actorId;
|
String actorId;
|
||||||
String actorDisplayName;
|
String actorDisplayName;
|
||||||
@@ -39,4 +40,11 @@ enum GetReactionsResponseObjectActorType {
|
|||||||
guests,
|
guests,
|
||||||
@JsonValue('users')
|
@JsonValue('users')
|
||||||
users,
|
users,
|
||||||
|
@JsonValue('emails')
|
||||||
|
emails,
|
||||||
|
@JsonValue('bots')
|
||||||
|
bots,
|
||||||
|
@JsonValue('federated_users')
|
||||||
|
federatedUsers,
|
||||||
|
unknown,
|
||||||
}
|
}
|
||||||
@@ -8,9 +8,11 @@ part of 'get_reactions_response.dart';
|
|||||||
|
|
||||||
GetReactionsResponse _$GetReactionsResponseFromJson(
|
GetReactionsResponse _$GetReactionsResponseFromJson(
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
) =>
|
) => $checkedCreate('GetReactionsResponse', json, ($checkedConvert) {
|
||||||
GetReactionsResponse(
|
final val = GetReactionsResponse(
|
||||||
(json['data'] as Map<String, dynamic>).map(
|
$checkedConvert(
|
||||||
|
'data',
|
||||||
|
(v) => (v as Map<String, dynamic>).map(
|
||||||
(k, e) => MapEntry(
|
(k, e) => MapEntry(
|
||||||
k,
|
k,
|
||||||
(e as List<dynamic>)
|
(e as List<dynamic>)
|
||||||
@@ -22,10 +24,16 @@ GetReactionsResponse _$GetReactionsResponseFromJson(
|
|||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
|
|
||||||
(k, e) => MapEntry(k, e as String),
|
|
||||||
);
|
);
|
||||||
|
$checkedConvert(
|
||||||
|
'headers',
|
||||||
|
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
|
||||||
|
(k, e) => MapEntry(k, e as String),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$GetReactionsResponseToJson(
|
Map<String, dynamic> _$GetReactionsResponseToJson(
|
||||||
GetReactionsResponse instance,
|
GetReactionsResponse instance,
|
||||||
@@ -38,12 +46,22 @@ Map<String, dynamic> _$GetReactionsResponseToJson(
|
|||||||
|
|
||||||
GetReactionsResponseObject _$GetReactionsResponseObjectFromJson(
|
GetReactionsResponseObject _$GetReactionsResponseObjectFromJson(
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
) => GetReactionsResponseObject(
|
) => $checkedCreate('GetReactionsResponseObject', json, ($checkedConvert) {
|
||||||
$enumDecode(_$GetReactionsResponseObjectActorTypeEnumMap, json['actorType']),
|
final val = GetReactionsResponseObject(
|
||||||
json['actorId'] as String,
|
$checkedConvert(
|
||||||
json['actorDisplayName'] as String,
|
'actorType',
|
||||||
(json['timestamp'] as num).toInt(),
|
(v) => $enumDecode(
|
||||||
);
|
_$GetReactionsResponseObjectActorTypeEnumMap,
|
||||||
|
v,
|
||||||
|
unknownValue: GetReactionsResponseObjectActorType.unknown,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
$checkedConvert('actorId', (v) => v as String),
|
||||||
|
$checkedConvert('actorDisplayName', (v) => v as String),
|
||||||
|
$checkedConvert('timestamp', (v) => (v as num).toInt()),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$GetReactionsResponseObjectToJson(
|
Map<String, dynamic> _$GetReactionsResponseObjectToJson(
|
||||||
GetReactionsResponseObject instance,
|
GetReactionsResponseObject instance,
|
||||||
@@ -58,4 +76,8 @@ Map<String, dynamic> _$GetReactionsResponseObjectToJson(
|
|||||||
const _$GetReactionsResponseObjectActorTypeEnumMap = {
|
const _$GetReactionsResponseObjectActorTypeEnumMap = {
|
||||||
GetReactionsResponseObjectActorType.guests: 'guests',
|
GetReactionsResponseObjectActorType.guests: 'guests',
|
||||||
GetReactionsResponseObjectActorType.users: 'users',
|
GetReactionsResponseObjectActorType.users: 'users',
|
||||||
|
GetReactionsResponseObjectActorType.emails: 'emails',
|
||||||
|
GetReactionsResponseObjectActorType.bots: 'bots',
|
||||||
|
GetReactionsResponseObjectActorType.federatedUsers: 'federated_users',
|
||||||
|
GetReactionsResponseObjectActorType.unknown: 'unknown',
|
||||||
};
|
};
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import 'package:http/http.dart' as http;
|
|
||||||
|
|
||||||
import '../../nextcloud_ocs.dart';
|
import '../../nextcloud_ocs.dart';
|
||||||
import '../talk_api.dart';
|
import '../talk_api.dart';
|
||||||
import 'get_shared_items_response.dart';
|
import 'get_shared_items_response.dart';
|
||||||
@@ -30,11 +28,4 @@ class GetSharedItems extends TalkApi<GetSharedItemsResponse> {
|
|||||||
@override
|
@override
|
||||||
GetSharedItemsResponse assemble(String raw) =>
|
GetSharedItemsResponse assemble(String raw) =>
|
||||||
GetSharedItemsResponse.fromOcs(NextcloudOcs.decode(raw));
|
GetSharedItemsResponse.fromOcs(NextcloudOcs.decode(raw));
|
||||||
|
|
||||||
@override
|
|
||||||
Future<http.Response> request(
|
|
||||||
Uri uri,
|
|
||||||
Object? body,
|
|
||||||
Map<String, String>? headers,
|
|
||||||
) => http.get(uri, headers: headers);
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import 'package:http/http.dart' as http;
|
|
||||||
|
|
||||||
import '../../nextcloud_ocs.dart';
|
import '../../nextcloud_ocs.dart';
|
||||||
import '../talk_api.dart';
|
import '../talk_api.dart';
|
||||||
import 'get_shared_items_overview_response.dart';
|
import 'get_shared_items_overview_response.dart';
|
||||||
@@ -18,11 +16,4 @@ class GetSharedItemsOverview extends TalkApi<GetSharedItemsOverviewResponse> {
|
|||||||
@override
|
@override
|
||||||
GetSharedItemsOverviewResponse assemble(String raw) =>
|
GetSharedItemsOverviewResponse assemble(String raw) =>
|
||||||
GetSharedItemsOverviewResponse.fromOcs(NextcloudOcs.decode(raw));
|
GetSharedItemsOverviewResponse.fromOcs(NextcloudOcs.decode(raw));
|
||||||
|
|
||||||
@override
|
|
||||||
Future<http.Response> request(
|
|
||||||
Uri uri,
|
|
||||||
Object? body,
|
|
||||||
Map<String, String>? headers,
|
|
||||||
) => http.get(uri, headers: headers);
|
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,12 @@ part of 'react_message_params.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
ReactMessageParams _$ReactMessageParamsFromJson(Map<String, dynamic> json) =>
|
ReactMessageParams _$ReactMessageParamsFromJson(Map<String, dynamic> json) =>
|
||||||
ReactMessageParams(json['reaction'] as String);
|
$checkedCreate('ReactMessageParams', json, ($checkedConvert) {
|
||||||
|
final val = ReactMessageParams(
|
||||||
|
$checkedConvert('reaction', (v) => v as String),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$ReactMessageParamsToJson(ReactMessageParams instance) =>
|
Map<String, dynamic> _$ReactMessageParamsToJson(ReactMessageParams instance) =>
|
||||||
<String, dynamic>{'reaction': instance.reaction};
|
<String, dynamic>{'reaction': instance.reaction};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
|
|
||||||
|
import '../../../json_lenient.dart';
|
||||||
import '../../nextcloud_ocs.dart';
|
import '../../nextcloud_ocs.dart';
|
||||||
import '../talk_api.dart';
|
import '../talk_api.dart';
|
||||||
import 'get_room_params.dart';
|
import 'get_room_params.dart';
|
||||||
@@ -17,14 +17,8 @@ class GetRoom extends TalkApi<GetRoomResponse> {
|
|||||||
@override
|
@override
|
||||||
Future<GetRoomResponse> assembleAsync(String raw) =>
|
Future<GetRoomResponse> assembleAsync(String raw) =>
|
||||||
compute(_parseRooms, raw);
|
compute(_parseRooms, raw);
|
||||||
|
|
||||||
@override
|
|
||||||
Future<http.Response> request(
|
|
||||||
Uri uri,
|
|
||||||
Object? body,
|
|
||||||
Map<String, String>? headers,
|
|
||||||
) => http.get(uri, headers: headers);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
GetRoomResponse _parseRooms(String raw) =>
|
GetRoomResponse _parseRooms(String raw) =>
|
||||||
GetRoomResponse.fromJson(NextcloudOcs.decode(raw));
|
GetRoomResponse.fromJson(NextcloudOcs.decode(raw))
|
||||||
|
..parseWarnings = drainDecodeWarnings();
|
||||||
@@ -4,7 +4,7 @@ import '../../../api_params.dart';
|
|||||||
|
|
||||||
part 'get_room_params.g.dart';
|
part 'get_room_params.g.dart';
|
||||||
|
|
||||||
@JsonSerializable(explicitToJson: true)
|
@JsonSerializable(explicitToJson: true, includeIfNull: false)
|
||||||
class GetRoomParams extends ApiParams {
|
class GetRoomParams extends ApiParams {
|
||||||
GetRoomParamsStatusUpdate? noStatusUpdate;
|
GetRoomParamsStatusUpdate? noStatusUpdate;
|
||||||
@JsonKey(toJson: _format)
|
@JsonKey(toJson: _format)
|
||||||
|
|||||||
@@ -7,22 +7,28 @@ part of 'get_room_params.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
GetRoomParams _$GetRoomParamsFromJson(Map<String, dynamic> json) =>
|
GetRoomParams _$GetRoomParamsFromJson(Map<String, dynamic> json) =>
|
||||||
GetRoomParams(
|
$checkedCreate('GetRoomParams', json, ($checkedConvert) {
|
||||||
noStatusUpdate: $enumDecodeNullable(
|
final val = GetRoomParams(
|
||||||
_$GetRoomParamsStatusUpdateEnumMap,
|
noStatusUpdate: $checkedConvert(
|
||||||
json['noStatusUpdate'],
|
'noStatusUpdate',
|
||||||
|
(v) => $enumDecodeNullable(_$GetRoomParamsStatusUpdateEnumMap, v),
|
||||||
|
),
|
||||||
|
includeStatus: $checkedConvert('includeStatus', (v) => v as bool?),
|
||||||
|
modifiedSince: $checkedConvert(
|
||||||
|
'modifiedSince',
|
||||||
|
(v) => (v as num?)?.toInt(),
|
||||||
),
|
),
|
||||||
includeStatus: json['includeStatus'] as bool?,
|
|
||||||
modifiedSince: (json['modifiedSince'] as num?)?.toInt(),
|
|
||||||
);
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$GetRoomParamsToJson(
|
Map<String, dynamic> _$GetRoomParamsToJson(GetRoomParams instance) =>
|
||||||
GetRoomParams instance,
|
<String, dynamic>{
|
||||||
) => <String, dynamic>{
|
'noStatusUpdate':
|
||||||
'noStatusUpdate': _$GetRoomParamsStatusUpdateEnumMap[instance.noStatusUpdate],
|
?_$GetRoomParamsStatusUpdateEnumMap[instance.noStatusUpdate],
|
||||||
'includeStatus': GetRoomParams._format(instance.includeStatus),
|
'includeStatus': GetRoomParams._format(instance.includeStatus),
|
||||||
'modifiedSince': instance.modifiedSince,
|
'modifiedSince': ?instance.modifiedSince,
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$GetRoomParamsStatusUpdateEnumMap = {
|
const _$GetRoomParamsStatusUpdateEnumMap = {
|
||||||
GetRoomParamsStatusUpdate.defaults: 0,
|
GetRoomParamsStatusUpdate.defaults: 0,
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
import '../../../api_response.dart';
|
import '../../../api_response.dart';
|
||||||
|
import '../../../json_lenient.dart';
|
||||||
import '../chat/get_chat_response.dart';
|
import '../chat/get_chat_response.dart';
|
||||||
|
|
||||||
part 'get_room_response.g.dart';
|
part 'get_room_response.g.dart';
|
||||||
|
|
||||||
@JsonSerializable(explicitToJson: true)
|
@JsonSerializable(explicitToJson: true)
|
||||||
class GetRoomResponse extends ApiResponse {
|
class GetRoomResponse extends ApiResponse {
|
||||||
|
@JsonKey(fromJson: _roomsFromJson)
|
||||||
Set<GetRoomResponseObject> data;
|
Set<GetRoomResponseObject> data;
|
||||||
|
|
||||||
GetRoomResponse(this.data);
|
GetRoomResponse(this.data);
|
||||||
@@ -33,10 +35,14 @@ class GetRoomResponse extends ApiResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Set<GetRoomResponseObject> _roomsFromJson(Object? raw) =>
|
||||||
|
decodeSetLenient(raw, GetRoomResponseObject.fromJson, what: 'room');
|
||||||
|
|
||||||
@JsonSerializable(explicitToJson: true)
|
@JsonSerializable(explicitToJson: true)
|
||||||
class GetRoomResponseObject {
|
class GetRoomResponseObject {
|
||||||
int id;
|
int id;
|
||||||
String token;
|
String token;
|
||||||
|
@JsonKey(unknownEnumValue: GetRoomResponseObjectConversationType.unknown)
|
||||||
GetRoomResponseObjectConversationType type;
|
GetRoomResponseObjectConversationType type;
|
||||||
String name;
|
String name;
|
||||||
String displayName;
|
String displayName;
|
||||||
@@ -55,13 +61,20 @@ class GetRoomResponseObject {
|
|||||||
bool canLeaveConversation;
|
bool canLeaveConversation;
|
||||||
int lastActivity;
|
int lastActivity;
|
||||||
bool isFavorite;
|
bool isFavorite;
|
||||||
|
@JsonKey(
|
||||||
|
unknownEnumValue:
|
||||||
|
GetRoomResponseObjectParticipantNotificationLevel.defaultLevel,
|
||||||
|
)
|
||||||
GetRoomResponseObjectParticipantNotificationLevel notificationLevel;
|
GetRoomResponseObjectParticipantNotificationLevel notificationLevel;
|
||||||
int unreadMessages;
|
int unreadMessages;
|
||||||
bool unreadMention;
|
bool unreadMention;
|
||||||
bool unreadMentionDirect;
|
bool unreadMentionDirect;
|
||||||
int lastReadMessage;
|
int lastReadMessage;
|
||||||
int lastCommonReadMessage;
|
int lastCommonReadMessage;
|
||||||
GetChatResponseObject lastMessage;
|
|
||||||
|
/// Omitted by the server for rooms without a visible message (empty room,
|
||||||
|
/// last message expired or hidden from this user, federated conversation).
|
||||||
|
GetChatResponseObject? lastMessage;
|
||||||
String? status;
|
String? status;
|
||||||
String? statusIcon;
|
String? statusIcon;
|
||||||
String? statusMessage;
|
String? statusMessage;
|
||||||
@@ -117,6 +130,9 @@ enum GetRoomResponseObjectConversationType {
|
|||||||
deleted,
|
deleted,
|
||||||
@JsonValue(6)
|
@JsonValue(6)
|
||||||
noteToSelf,
|
noteToSelf,
|
||||||
|
|
||||||
|
/// Fallback for types this app does not know yet; treated like a group.
|
||||||
|
unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum GetRoomResponseObjectParticipantNotificationLevel {
|
enum GetRoomResponseObjectParticipantNotificationLevel {
|
||||||
@@ -147,6 +163,17 @@ enum GetRoomResponseObjectMessageActorType {
|
|||||||
bot,
|
bot,
|
||||||
@JsonValue('bridged')
|
@JsonValue('bridged')
|
||||||
bridge,
|
bridge,
|
||||||
|
@JsonValue('groups')
|
||||||
|
group,
|
||||||
|
@JsonValue('emails')
|
||||||
|
email,
|
||||||
|
@JsonValue('circles')
|
||||||
|
circle,
|
||||||
|
@JsonValue('federated_users')
|
||||||
|
federatedUser,
|
||||||
|
@JsonValue('phones')
|
||||||
|
phone,
|
||||||
|
unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum GetRoomResponseObjectMessageType {
|
enum GetRoomResponseObjectMessageType {
|
||||||
@@ -160,4 +187,8 @@ enum GetRoomResponseObjectMessageType {
|
|||||||
system,
|
system,
|
||||||
@JsonValue('command')
|
@JsonValue('command')
|
||||||
command,
|
command,
|
||||||
|
|
||||||
|
/// Fallback for types this app does not know yet (e.g. call recordings);
|
||||||
|
/// rendered like a plain comment.
|
||||||
|
unknown,
|
||||||
}
|
}
|
||||||
@@ -7,16 +7,18 @@ part of 'get_room_response.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
GetRoomResponse _$GetRoomResponseFromJson(Map<String, dynamic> json) =>
|
GetRoomResponse _$GetRoomResponseFromJson(Map<String, dynamic> json) =>
|
||||||
GetRoomResponse(
|
$checkedCreate('GetRoomResponse', json, ($checkedConvert) {
|
||||||
(json['data'] as List<dynamic>)
|
final val = GetRoomResponse(
|
||||||
.map(
|
$checkedConvert('data', (v) => _roomsFromJson(v)),
|
||||||
(e) => GetRoomResponseObject.fromJson(e as Map<String, dynamic>),
|
|
||||||
)
|
|
||||||
.toSet(),
|
|
||||||
)
|
|
||||||
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
|
|
||||||
(k, e) => MapEntry(k, e as String),
|
|
||||||
);
|
);
|
||||||
|
$checkedConvert(
|
||||||
|
'headers',
|
||||||
|
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
|
||||||
|
(k, e) => MapEntry(k, e as String),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$GetRoomResponseToJson(GetRoomResponse instance) =>
|
Map<String, dynamic> _$GetRoomResponseToJson(GetRoomResponse instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
@@ -26,41 +28,61 @@ Map<String, dynamic> _$GetRoomResponseToJson(GetRoomResponse instance) =>
|
|||||||
|
|
||||||
GetRoomResponseObject _$GetRoomResponseObjectFromJson(
|
GetRoomResponseObject _$GetRoomResponseObjectFromJson(
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
) => GetRoomResponseObject(
|
) => $checkedCreate('GetRoomResponseObject', json, ($checkedConvert) {
|
||||||
(json['id'] as num).toInt(),
|
final val = GetRoomResponseObject(
|
||||||
json['token'] as String,
|
$checkedConvert('id', (v) => (v as num).toInt()),
|
||||||
$enumDecode(_$GetRoomResponseObjectConversationTypeEnumMap, json['type']),
|
$checkedConvert('token', (v) => v as String),
|
||||||
json['name'] as String,
|
$checkedConvert(
|
||||||
json['displayName'] as String,
|
'type',
|
||||||
json['description'] as String,
|
(v) => $enumDecode(
|
||||||
(json['participantType'] as num).toInt(),
|
_$GetRoomResponseObjectConversationTypeEnumMap,
|
||||||
(json['participantFlags'] as num).toInt(),
|
v,
|
||||||
(json['readOnly'] as num).toInt(),
|
unknownValue: GetRoomResponseObjectConversationType.unknown,
|
||||||
(json['listable'] as num).toInt(),
|
|
||||||
(json['lastPing'] as num).toInt(),
|
|
||||||
json['sessionId'] as String,
|
|
||||||
json['hasPassword'] as bool,
|
|
||||||
json['hasCall'] as bool,
|
|
||||||
(json['callFlag'] as num).toInt(),
|
|
||||||
json['canStartCall'] as bool,
|
|
||||||
json['canDeleteConversation'] as bool,
|
|
||||||
json['canLeaveConversation'] as bool,
|
|
||||||
(json['lastActivity'] as num).toInt(),
|
|
||||||
json['isFavorite'] as bool,
|
|
||||||
$enumDecode(
|
|
||||||
_$GetRoomResponseObjectParticipantNotificationLevelEnumMap,
|
|
||||||
json['notificationLevel'],
|
|
||||||
),
|
),
|
||||||
(json['unreadMessages'] as num).toInt(),
|
),
|
||||||
json['unreadMention'] as bool,
|
$checkedConvert('name', (v) => v as String),
|
||||||
json['unreadMentionDirect'] as bool,
|
$checkedConvert('displayName', (v) => v as String),
|
||||||
(json['lastReadMessage'] as num).toInt(),
|
$checkedConvert('description', (v) => v as String),
|
||||||
(json['lastCommonReadMessage'] as num).toInt(),
|
$checkedConvert('participantType', (v) => (v as num).toInt()),
|
||||||
GetChatResponseObject.fromJson(json['lastMessage'] as Map<String, dynamic>),
|
$checkedConvert('participantFlags', (v) => (v as num).toInt()),
|
||||||
json['status'] as String?,
|
$checkedConvert('readOnly', (v) => (v as num).toInt()),
|
||||||
json['statusIcon'] as String?,
|
$checkedConvert('listable', (v) => (v as num).toInt()),
|
||||||
json['statusMessage'] as String?,
|
$checkedConvert('lastPing', (v) => (v as num).toInt()),
|
||||||
);
|
$checkedConvert('sessionId', (v) => v as String),
|
||||||
|
$checkedConvert('hasPassword', (v) => v as bool),
|
||||||
|
$checkedConvert('hasCall', (v) => v as bool),
|
||||||
|
$checkedConvert('callFlag', (v) => (v as num).toInt()),
|
||||||
|
$checkedConvert('canStartCall', (v) => v as bool),
|
||||||
|
$checkedConvert('canDeleteConversation', (v) => v as bool),
|
||||||
|
$checkedConvert('canLeaveConversation', (v) => v as bool),
|
||||||
|
$checkedConvert('lastActivity', (v) => (v as num).toInt()),
|
||||||
|
$checkedConvert('isFavorite', (v) => v as bool),
|
||||||
|
$checkedConvert(
|
||||||
|
'notificationLevel',
|
||||||
|
(v) => $enumDecode(
|
||||||
|
_$GetRoomResponseObjectParticipantNotificationLevelEnumMap,
|
||||||
|
v,
|
||||||
|
unknownValue:
|
||||||
|
GetRoomResponseObjectParticipantNotificationLevel.defaultLevel,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
$checkedConvert('unreadMessages', (v) => (v as num).toInt()),
|
||||||
|
$checkedConvert('unreadMention', (v) => v as bool),
|
||||||
|
$checkedConvert('unreadMentionDirect', (v) => v as bool),
|
||||||
|
$checkedConvert('lastReadMessage', (v) => (v as num).toInt()),
|
||||||
|
$checkedConvert('lastCommonReadMessage', (v) => (v as num).toInt()),
|
||||||
|
$checkedConvert(
|
||||||
|
'lastMessage',
|
||||||
|
(v) => v == null
|
||||||
|
? null
|
||||||
|
: GetChatResponseObject.fromJson(v as Map<String, dynamic>),
|
||||||
|
),
|
||||||
|
$checkedConvert('status', (v) => v as String?),
|
||||||
|
$checkedConvert('statusIcon', (v) => v as String?),
|
||||||
|
$checkedConvert('statusMessage', (v) => v as String?),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$GetRoomResponseObjectToJson(
|
Map<String, dynamic> _$GetRoomResponseObjectToJson(
|
||||||
GetRoomResponseObject instance,
|
GetRoomResponseObject instance,
|
||||||
@@ -93,7 +115,7 @@ Map<String, dynamic> _$GetRoomResponseObjectToJson(
|
|||||||
'unreadMentionDirect': instance.unreadMentionDirect,
|
'unreadMentionDirect': instance.unreadMentionDirect,
|
||||||
'lastReadMessage': instance.lastReadMessage,
|
'lastReadMessage': instance.lastReadMessage,
|
||||||
'lastCommonReadMessage': instance.lastCommonReadMessage,
|
'lastCommonReadMessage': instance.lastCommonReadMessage,
|
||||||
'lastMessage': instance.lastMessage.toJson(),
|
'lastMessage': instance.lastMessage?.toJson(),
|
||||||
'status': instance.status,
|
'status': instance.status,
|
||||||
'statusIcon': instance.statusIcon,
|
'statusIcon': instance.statusIcon,
|
||||||
'statusMessage': instance.statusMessage,
|
'statusMessage': instance.statusMessage,
|
||||||
@@ -106,6 +128,7 @@ const _$GetRoomResponseObjectConversationTypeEnumMap = {
|
|||||||
GetRoomResponseObjectConversationType.changelog: 4,
|
GetRoomResponseObjectConversationType.changelog: 4,
|
||||||
GetRoomResponseObjectConversationType.deleted: 5,
|
GetRoomResponseObjectConversationType.deleted: 5,
|
||||||
GetRoomResponseObjectConversationType.noteToSelf: 6,
|
GetRoomResponseObjectConversationType.noteToSelf: 6,
|
||||||
|
GetRoomResponseObjectConversationType.unknown: 'unknown',
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$GetRoomResponseObjectParticipantNotificationLevelEnumMap = {
|
const _$GetRoomResponseObjectParticipantNotificationLevelEnumMap = {
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ part of 'send_message_params.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
SendMessageParams _$SendMessageParamsFromJson(Map<String, dynamic> json) =>
|
SendMessageParams _$SendMessageParamsFromJson(Map<String, dynamic> json) =>
|
||||||
SendMessageParams(
|
$checkedCreate('SendMessageParams', json, ($checkedConvert) {
|
||||||
json['message'] as String,
|
final val = SendMessageParams(
|
||||||
replyTo: json['replyTo'] as String?,
|
$checkedConvert('message', (v) => v as String),
|
||||||
|
replyTo: $checkedConvert('replyTo', (v) => v as String?),
|
||||||
);
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$SendMessageParamsToJson(SendMessageParams instance) =>
|
Map<String, dynamic> _$SendMessageParamsToJson(SendMessageParams instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
|
|||||||
@@ -7,9 +7,15 @@ part of 'set_read_marker_params.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
SetReadMarkerParams _$SetReadMarkerParamsFromJson(Map<String, dynamic> json) =>
|
SetReadMarkerParams _$SetReadMarkerParamsFromJson(Map<String, dynamic> json) =>
|
||||||
SetReadMarkerParams(
|
$checkedCreate('SetReadMarkerParams', json, ($checkedConvert) {
|
||||||
lastReadMessage: (json['lastReadMessage'] as num?)?.toInt(),
|
final val = SetReadMarkerParams(
|
||||||
|
lastReadMessage: $checkedConvert(
|
||||||
|
'lastReadMessage',
|
||||||
|
(v) => (v as num?)?.toInt(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$SetReadMarkerParamsToJson(
|
Map<String, dynamic> _$SetReadMarkerParamsToJson(
|
||||||
SetReadMarkerParams instance,
|
SetReadMarkerParams instance,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import '../../errors/network_exception.dart';
|
|||||||
import '../../errors/parse_exception.dart';
|
import '../../errors/parse_exception.dart';
|
||||||
import '../../errors/talk_exception.dart';
|
import '../../errors/talk_exception.dart';
|
||||||
import '../../http_errors.dart';
|
import '../../http_errors.dart';
|
||||||
|
import '../../json_lenient.dart';
|
||||||
|
import '../../marianumconnect/queries/report_client_error/client_error_reporter.dart';
|
||||||
import '../nextcloud_ocs.dart';
|
import '../nextcloud_ocs.dart';
|
||||||
import 'talk_error.dart';
|
import 'talk_error.dart';
|
||||||
|
|
||||||
@@ -23,11 +25,12 @@ abstract class TalkApi<T extends ApiResponse?> {
|
|||||||
|
|
||||||
TalkApi(this.path, this.body, {this.headers, this.getParameters});
|
TalkApi(this.path, this.body, {this.headers, this.getParameters});
|
||||||
|
|
||||||
|
/// Defaults to a plain GET; endpoints with a body or another verb override it.
|
||||||
Future<http.Response>? request(
|
Future<http.Response>? request(
|
||||||
Uri uri,
|
Uri uri,
|
||||||
ApiParams? body,
|
ApiParams? body,
|
||||||
Map<String, String>? headers,
|
Map<String, String>? headers,
|
||||||
);
|
) => http.get(uri, headers: headers);
|
||||||
T assemble(String raw);
|
T assemble(String raw);
|
||||||
|
|
||||||
/// Override to parse large payloads off the UI isolate (e.g. via `compute`
|
/// Override to parse large payloads off the UI isolate (e.g. via `compute`
|
||||||
@@ -72,12 +75,17 @@ abstract class TalkApi<T extends ApiResponse?> {
|
|||||||
throwForStatus(status, detail);
|
throwForStatus(status, detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final T assembled;
|
||||||
try {
|
try {
|
||||||
final assembled = await assembleAsync(data.body);
|
assembled = await assembleAsync(data.body);
|
||||||
assembled?.headers = data.headers;
|
|
||||||
return assembled;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw ParseException(technicalDetails: 'Talk $endpoint assemble: $e');
|
throw ParseException(technicalDetails: 'Talk $endpoint assemble: $e');
|
||||||
}
|
}
|
||||||
|
assembled?.headers = data.headers;
|
||||||
|
ClientErrorReporter.reportDecodeWarnings('Talk $path', [
|
||||||
|
...?assembled?.parseWarnings,
|
||||||
|
...drainDecodeWarnings(),
|
||||||
|
]);
|
||||||
|
return assembled;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,11 +7,15 @@ part of 'vote_poll_params.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
VotePollParams _$VotePollParamsFromJson(Map<String, dynamic> json) =>
|
VotePollParams _$VotePollParamsFromJson(Map<String, dynamic> json) =>
|
||||||
VotePollParams(
|
$checkedCreate('VotePollParams', json, ($checkedConvert) {
|
||||||
optionIds: (json['optionIds'] as List<dynamic>)
|
final val = VotePollParams(
|
||||||
.map((e) => (e as num).toInt())
|
optionIds: $checkedConvert(
|
||||||
.toList(),
|
'optionIds',
|
||||||
|
(v) => (v as List<dynamic>).map((e) => (e as num).toInt()).toList(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$VotePollParamsToJson(VotePollParams instance) =>
|
Map<String, dynamic> _$VotePollParamsToJson(VotePollParams instance) =>
|
||||||
<String, dynamic>{'optionIds': instance.optionIds};
|
<String, dynamic>{'optionIds': instance.optionIds};
|
||||||
@@ -7,23 +7,28 @@ part of 'cacheable_file.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
CacheableFile _$CacheableFileFromJson(Map<String, dynamic> json) =>
|
CacheableFile _$CacheableFileFromJson(Map<String, dynamic> json) =>
|
||||||
CacheableFile(
|
$checkedCreate('CacheableFile', json, ($checkedConvert) {
|
||||||
path: json['path'] as String,
|
final val = CacheableFile(
|
||||||
isDirectory: json['isDirectory'] as bool,
|
path: $checkedConvert('path', (v) => v as String),
|
||||||
name: json['name'] as String,
|
isDirectory: $checkedConvert('isDirectory', (v) => v as bool),
|
||||||
mimeType: json['mimeType'] as String?,
|
name: $checkedConvert('name', (v) => v as String),
|
||||||
size: (json['size'] as num?)?.toInt(),
|
mimeType: $checkedConvert('mimeType', (v) => v as String?),
|
||||||
eTag: json['eTag'] as String?,
|
size: $checkedConvert('size', (v) => (v as num?)?.toInt()),
|
||||||
createdAt: json['createdAt'] == null
|
eTag: $checkedConvert('eTag', (v) => v as String?),
|
||||||
? null
|
createdAt: $checkedConvert(
|
||||||
: DateTime.parse(json['createdAt'] as String),
|
'createdAt',
|
||||||
modifiedAt: json['modifiedAt'] == null
|
(v) => v == null ? null : DateTime.parse(v as String),
|
||||||
? null
|
),
|
||||||
: DateTime.parse(json['modifiedAt'] as String),
|
modifiedAt: $checkedConvert(
|
||||||
fileId: (json['fileId'] as num?)?.toInt(),
|
'modifiedAt',
|
||||||
hasPreview: json['hasPreview'] as bool?,
|
(v) => v == null ? null : DateTime.parse(v as String),
|
||||||
isSharedWithMe: json['isSharedWithMe'] as bool?,
|
),
|
||||||
|
fileId: $checkedConvert('fileId', (v) => (v as num?)?.toInt()),
|
||||||
|
hasPreview: $checkedConvert('hasPreview', (v) => v as bool?),
|
||||||
|
isSharedWithMe: $checkedConvert('isSharedWithMe', (v) => v as bool?),
|
||||||
);
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$CacheableFileToJson(CacheableFile instance) =>
|
Map<String, dynamic> _$CacheableFileToJson(CacheableFile instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ part of 'list_files_params.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
ListFilesParams _$ListFilesParamsFromJson(Map<String, dynamic> json) =>
|
ListFilesParams _$ListFilesParamsFromJson(Map<String, dynamic> json) =>
|
||||||
ListFilesParams(json['path'] as String);
|
$checkedCreate('ListFilesParams', json, ($checkedConvert) {
|
||||||
|
final val = ListFilesParams($checkedConvert('path', (v) => v as String));
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$ListFilesParamsToJson(ListFilesParams instance) =>
|
Map<String, dynamic> _$ListFilesParamsToJson(ListFilesParams instance) =>
|
||||||
<String, dynamic>{'path': instance.path};
|
<String, dynamic>{'path': instance.path};
|
||||||
@@ -7,14 +7,23 @@ part of 'list_files_response.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
ListFilesResponse _$ListFilesResponseFromJson(Map<String, dynamic> json) =>
|
ListFilesResponse _$ListFilesResponseFromJson(Map<String, dynamic> json) =>
|
||||||
ListFilesResponse(
|
$checkedCreate('ListFilesResponse', json, ($checkedConvert) {
|
||||||
(json['files'] as List<dynamic>)
|
final val = ListFilesResponse(
|
||||||
|
$checkedConvert(
|
||||||
|
'files',
|
||||||
|
(v) => (v as List<dynamic>)
|
||||||
.map((e) => CacheableFile.fromJson(e as Map<String, dynamic>))
|
.map((e) => CacheableFile.fromJson(e as Map<String, dynamic>))
|
||||||
.toSet(),
|
.toSet(),
|
||||||
)
|
),
|
||||||
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
|
|
||||||
(k, e) => MapEntry(k, e as String),
|
|
||||||
);
|
);
|
||||||
|
$checkedConvert(
|
||||||
|
'headers',
|
||||||
|
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
|
||||||
|
(k, e) => MapEntry(k, e as String),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$ListFilesResponseToJson(ListFilesResponse instance) =>
|
Map<String, dynamic> _$ListFilesResponseToJson(ListFilesResponse instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'package:nextcloud/nextcloud.dart';
|
import 'package:nextcloud/nextcloud.dart';
|
||||||
|
|
||||||
import '../../../model/account_data.dart';
|
|
||||||
import '../../../model/endpoint_data.dart';
|
import '../../../model/endpoint_data.dart';
|
||||||
|
import '../../../session/session_manager.dart';
|
||||||
import '../../api_response.dart';
|
import '../../api_response.dart';
|
||||||
|
import '../../demo/demo_mode.dart';
|
||||||
|
import '../../errors/network_exception.dart';
|
||||||
|
|
||||||
abstract class WebdavApi<T> {
|
abstract class WebdavApi<T> {
|
||||||
T genericParams;
|
T genericParams;
|
||||||
@@ -18,10 +20,18 @@ abstract class WebdavApi<T> {
|
|||||||
/// changes (app password minted/renewed, account switch) so it never keeps
|
/// changes (app password minted/renewed, account switch) so it never keeps
|
||||||
/// authenticating with stale credentials.
|
/// authenticating with stale credentials.
|
||||||
static Future<WebDavClient> get webdav {
|
static Future<WebDavClient> get webdav {
|
||||||
|
// The WebDAV client talks dart:io directly, past DemoHttpClient.
|
||||||
|
if (DemoMode.active) {
|
||||||
|
return Future.error(
|
||||||
|
NetworkException.demoBlocked(
|
||||||
|
Uri.parse(EndpointData().nextcloud().origin()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
// Keyed by user too: two accounts may share a password (no app password
|
// Keyed by user too: two accounts may share a password (no app password
|
||||||
// minted), and the client would keep the previous login name.
|
// minted), and the client would keep the previous login name.
|
||||||
final secret =
|
final nextcloud = SessionManager().requireNextcloud();
|
||||||
'${AccountData().getUsername()}:${AccountData().getNextcloudSecret()}';
|
final secret = '${nextcloud.username}:${nextcloud.secret}';
|
||||||
if (_webdav == null || _webdavSecret != secret) {
|
if (_webdav == null || _webdavSecret != secret) {
|
||||||
_webdavSecret = secret;
|
_webdavSecret = secret;
|
||||||
_webdav = establishWebdavConnection();
|
_webdav = establishWebdavConnection();
|
||||||
@@ -31,15 +41,15 @@ abstract class WebdavApi<T> {
|
|||||||
|
|
||||||
static Future<WebDavClient> establishWebdavConnection() async =>
|
static Future<WebDavClient> establishWebdavConnection() async =>
|
||||||
NextcloudClient(
|
NextcloudClient(
|
||||||
Uri.parse('https://${EndpointData().nextcloud().full()}'),
|
Uri.parse(EndpointData().nextcloud().origin()),
|
||||||
// App password preferred — with 2FA the real password is not accepted
|
// App password preferred — with 2FA the real password is not accepted
|
||||||
// by Nextcloud at all (see AccountData.usesLoginFlow).
|
// by Nextcloud at all (see NextcloudCredentials.usesLoginFlow).
|
||||||
password: AccountData().getNextcloudSecret(),
|
password: SessionManager().requireNextcloud().secret,
|
||||||
loginName: AccountData().getUsername(),
|
loginName: SessionManager().requireNextcloud().username,
|
||||||
).webdav;
|
).webdav;
|
||||||
|
|
||||||
/// Builds the WebDAV download URL without embedded credentials. Callers must
|
/// Builds the WebDAV download URL without embedded credentials. Callers must
|
||||||
/// authenticate via the [AccountData.authHeaders] header instead.
|
/// authenticate via the [NextcloudCredentials.authHeaders] header instead.
|
||||||
static String buildWebdavUrl() =>
|
static String buildWebdavUrl() =>
|
||||||
'https://${EndpointData().nextcloud().full()}/remote.php/dav/files/${AccountData().getUsername()}/';
|
'${EndpointData().nextcloud().origin()}/remote.php/dav/files/${SessionManager().requireNextcloud().username}/';
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../../../model/account_data.dart';
|
import '../../../session/session.dart';
|
||||||
|
import '../../../session/session_manager.dart';
|
||||||
import '../queries/auth_login/auth_login.dart';
|
import '../queries/auth_login/auth_login.dart';
|
||||||
import 'device_token_name.dart';
|
import 'device_token_name.dart';
|
||||||
import 'token_storage.dart';
|
import 'token_storage.dart';
|
||||||
|
|
||||||
/// Adds the bearer token to outgoing Marianum-Connect requests and, on 401,
|
/// Adds the bearer token to outgoing Marianum-Connect requests and, on 401,
|
||||||
/// re-logs in once with the credentials in [AccountData] before retrying.
|
/// renews the token once before retrying.
|
||||||
class MarianumConnectAuthInterceptor extends Interceptor {
|
class MarianumConnectAuthInterceptor extends Interceptor {
|
||||||
static const _retriedKey = 'mc_auth_retried';
|
static const _retriedKey = 'mc_auth_retried';
|
||||||
|
|
||||||
@@ -18,6 +19,15 @@ class MarianumConnectAuthInterceptor extends Interceptor {
|
|||||||
// each spawning a fresh row in api_tokens.
|
// each spawning a fresh row in api_tokens.
|
||||||
Future<bool>? _pendingReLogin;
|
Future<bool>? _pendingReLogin;
|
||||||
|
|
||||||
|
static Future<bool>? _anyPendingReLogin;
|
||||||
|
|
||||||
|
/// Resolves once no silent re-login is running. An account switch waits for
|
||||||
|
/// it — the renewed token would otherwise land in the next account's slot.
|
||||||
|
static Future<void> idle() async {
|
||||||
|
final pending = _anyPendingReLogin;
|
||||||
|
if (pending != null) await pending;
|
||||||
|
}
|
||||||
|
|
||||||
MarianumConnectAuthInterceptor({
|
MarianumConnectAuthInterceptor({
|
||||||
MarianumConnectTokenStorage tokenStorage =
|
MarianumConnectTokenStorage tokenStorage =
|
||||||
const MarianumConnectTokenStorage(),
|
const MarianumConnectTokenStorage(),
|
||||||
@@ -80,32 +90,35 @@ class MarianumConnectAuthInterceptor extends Interceptor {
|
|||||||
if (inFlight != null) return inFlight;
|
if (inFlight != null) return inFlight;
|
||||||
final fresh = _performReLogin();
|
final fresh = _performReLogin();
|
||||||
_pendingReLogin = fresh;
|
_pendingReLogin = fresh;
|
||||||
|
_anyPendingReLogin = fresh;
|
||||||
fresh.whenComplete(() {
|
fresh.whenComplete(() {
|
||||||
if (identical(_pendingReLogin, fresh)) _pendingReLogin = null;
|
if (identical(_pendingReLogin, fresh)) _pendingReLogin = null;
|
||||||
|
if (identical(_anyPendingReLogin, fresh)) _anyPendingReLogin = null;
|
||||||
});
|
});
|
||||||
return fresh;
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _performReLogin() async {
|
Future<bool> _performReLogin() async {
|
||||||
if (!AccountData().isPopulated()) return false;
|
final session = SessionManager().current;
|
||||||
final username = AccountData().getUsername();
|
if (session is! CredentialSession) return false;
|
||||||
|
final username = session.username;
|
||||||
// A background engine (widget task) keeps the account it loaded. When
|
// A background engine (widget task) keeps the account it loaded. When
|
||||||
// the app signed that account out, its revoked token answers 401 — a
|
// the app signed that account out, its revoked token answers 401 — a
|
||||||
// re-login would mint a fresh token for it into the shared keystore.
|
// re-login would mint a fresh token for it into the shared keystore.
|
||||||
if (await AccountData().readStoredUsername() != username) return false;
|
if (await SessionManager().readStoredUsername() != username) return false;
|
||||||
try {
|
try {
|
||||||
await _loginClient.run(
|
await _loginClient.run(
|
||||||
username: username,
|
username: username,
|
||||||
password: AccountData().getPassword(),
|
password: session.password,
|
||||||
tokenName: await DeviceTokenName.resolve(),
|
tokenName: await DeviceTokenName.resolve(),
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (await AccountData().readStoredUsername() == username) {
|
if (await SessionManager().readStoredUsername() == username) {
|
||||||
await _tokenStorage.clear();
|
await _tokenStorage.clear();
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
final stored = await AccountData().readStoredUsername();
|
final stored = await SessionManager().readStoredUsername();
|
||||||
if (stored == username) return true;
|
if (stored == username) return true;
|
||||||
// Signed out during the login: drop the orphaned token, unless another
|
// Signed out during the login: drop the orphaned token, unless another
|
||||||
// account already stored its own.
|
// account already stored its own.
|
||||||
|
|||||||
@@ -1,39 +1,44 @@
|
|||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
|
||||||
import '../../../model/account_data.dart';
|
import '../../../session/session.dart';
|
||||||
|
import '../../../session/session_lifecycle.dart';
|
||||||
|
import '../../../session/session_manager.dart';
|
||||||
import '../../errors/auth_exception.dart';
|
import '../../errors/auth_exception.dart';
|
||||||
import '../queries/auth_logout/auth_logout.dart';
|
|
||||||
import '../queries/auth_verify/auth_verify.dart';
|
import '../queries/auth_verify/auth_verify.dart';
|
||||||
import 'token_storage.dart';
|
|
||||||
|
|
||||||
/// Background credential probe — a server-side password rotation forces a
|
/// Credential probe. A server-side password rotation forces a re-login on the
|
||||||
/// re-login on the next cold start even when the bearer token would still
|
/// next cold start even when the bearer token would still be accepted.
|
||||||
/// be accepted.
|
/// Another stored account then takes over.
|
||||||
class SessionValidator {
|
class SessionValidator {
|
||||||
static Future<void> probeStored({
|
static Future<void> probeStored({
|
||||||
required Future<void> Function() onInvalidated,
|
required Future<void> Function(String? nextAccountId) onInvalidated,
|
||||||
}) async {
|
}) async {
|
||||||
if (!AccountData().isPopulated()) return;
|
final session = SessionManager().current;
|
||||||
// AuthVerify uses its own dio (bypassing the demo interceptor), so a demo
|
// The probes use their own dio (bypassing the demo interceptor), so a demo
|
||||||
// session must be skipped here or its missing token would 401 into a logout.
|
// session must be skipped or its missing token would 401 into a logout.
|
||||||
if (AccountData().isDemo) return;
|
if (session == null || session.isDemo) return;
|
||||||
final username = AccountData().getUsername();
|
final epoch = SessionManager().sessionEpoch;
|
||||||
final password = AccountData().getPassword();
|
|
||||||
final epoch = AccountData().sessionEpoch;
|
|
||||||
try {
|
try {
|
||||||
|
switch (session) {
|
||||||
|
case CredentialSession(:final username, :final password):
|
||||||
await AuthVerify().run(username: username, password: password);
|
await AuthVerify().run(username: username, password: password);
|
||||||
|
}
|
||||||
} on AuthException catch (e) {
|
} on AuthException catch (e) {
|
||||||
if (e.statusCode != 401) return;
|
if (e.statusCode != 401) return;
|
||||||
// The probed account already signed out; the 401 must not sign out
|
// The probed account is no longer the active one; the 401 must not
|
||||||
// whoever logged in meanwhile.
|
// sign out whoever took over meanwhile.
|
||||||
if (!AccountData().isCurrentSession(epoch)) return;
|
if (!SessionManager().isCurrentSession(epoch)) return;
|
||||||
log('MC: stored credentials rejected — forcing re-login');
|
log('MC: stored session rejected — forcing re-login');
|
||||||
await AuthLogout().run();
|
final next = await SessionLifecycle.signOut(
|
||||||
await const MarianumConnectTokenStorage().clear();
|
notice: switch (session) {
|
||||||
await AccountData().removeData();
|
CredentialSession() =>
|
||||||
await onInvalidated();
|
'Deine Zugangsdaten wurden vom Server abgelehnt. Vermutlich '
|
||||||
|
'wurde dein Passwort geändert. Bitte melde dich erneut an.',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await onInvalidated(next);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log('MC: background credential check failed (transient): $e');
|
log('MC: background session check failed (transient): $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
import '../../../storage/keychain_write.dart';
|
||||||
|
import '../../errors/auth_exception.dart';
|
||||||
|
|
||||||
/// `first_unlock` accessibility so the token can be read during background
|
/// `first_unlock` accessibility so the token can be read during background
|
||||||
/// requests (telemetry heartbeat, push-triggered syncs) after the first device
|
/// requests (telemetry heartbeat, push-triggered syncs) after the first device
|
||||||
/// unlock following a reboot. The keychain default (`whenUnlocked`) throws
|
/// unlock following a reboot. The keychain default (`whenUnlocked`) throws
|
||||||
@@ -9,7 +13,7 @@ const IOSOptions _mcIosOptions = IOSOptions(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// Persists the Marianum-Connect bearer token in the platform keystore. Kept
|
/// Persists the Marianum-Connect bearer token in the platform keystore. Kept
|
||||||
/// separate from `AccountData` because the username/password live on (Nextcloud
|
/// separate from `SessionManager` because the username/password live on (Nextcloud
|
||||||
/// + MHSL still need them) while the MC token is short-lived and per-endpoint.
|
/// + MHSL still need them) while the MC token is short-lived and per-endpoint.
|
||||||
class MarianumConnectTokenStorage {
|
class MarianumConnectTokenStorage {
|
||||||
static const _tokenKey = 'mc_bearer_token';
|
static const _tokenKey = 'mc_bearer_token';
|
||||||
@@ -24,6 +28,18 @@ class MarianumConnectTokenStorage {
|
|||||||
|
|
||||||
Future<String?> readToken() => _storage.read(key: _tokenKey);
|
Future<String?> readToken() => _storage.read(key: _tokenKey);
|
||||||
|
|
||||||
|
/// Request options carrying the stored token, for probes that bypass the
|
||||||
|
/// auth interceptor. Throws [AuthException] when no token is stored.
|
||||||
|
Future<Options> requireBearerOptions(String caller) async {
|
||||||
|
final token = await readToken();
|
||||||
|
if (token == null || token.isEmpty) {
|
||||||
|
throw AuthException.unauthorized(
|
||||||
|
technicalDetails: '$caller: no bearer token in storage',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Options(headers: {'Authorization': 'Bearer $token'});
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> readTokenId() => _storage.read(key: _tokenIdKey);
|
Future<String?> readTokenId() => _storage.read(key: _tokenIdKey);
|
||||||
|
|
||||||
Future<DateTime?> readExpiresAt() async {
|
Future<DateTime?> readExpiresAt() async {
|
||||||
@@ -37,17 +53,37 @@ class MarianumConnectTokenStorage {
|
|||||||
required String tokenId,
|
required String tokenId,
|
||||||
required DateTime? expiresAt,
|
required DateTime? expiresAt,
|
||||||
}) async {
|
}) async {
|
||||||
await _storage.write(key: _tokenKey, value: token);
|
await _storage.writeRetrying(key: _tokenKey, value: token);
|
||||||
await _storage.write(key: _tokenIdKey, value: tokenId);
|
await _storage.writeRetrying(key: _tokenIdKey, value: tokenId);
|
||||||
await _storage.write(
|
await _storage.writeRetrying(
|
||||||
key: _expiresAtKey,
|
key: _expiresAtKey,
|
||||||
value: expiresAt?.toIso8601String() ?? '',
|
value: expiresAt?.toIso8601String() ?? '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.writeRetrying(key: key, value: value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> clear() async {
|
Future<void> clear() async {
|
||||||
await _storage.delete(key: _tokenKey);
|
for (final key in fieldKeys) {
|
||||||
await _storage.delete(key: _tokenIdKey);
|
await _storage.delete(key: key);
|
||||||
await _storage.delete(key: _expiresAtKey);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
/// Platform wire value MarianumConnect expects (`android`/`ios`), null elsewhere.
|
||||||
|
String? clientPlatform() {
|
||||||
|
if (Platform.isAndroid) return 'android';
|
||||||
|
if (Platform.isIOS) return 'ios';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../errors/app_exception.dart';
|
||||||
import 'errors/marianumconnect_error.dart';
|
import 'errors/marianumconnect_error.dart';
|
||||||
import 'marianumconnect_api.dart';
|
import 'marianumconnect_api.dart';
|
||||||
import 'marianumconnect_endpoint.dart';
|
import 'marianumconnect_endpoint.dart';
|
||||||
@@ -23,10 +26,14 @@ abstract class MarianumConnectQuery {
|
|||||||
try {
|
try {
|
||||||
return await body();
|
return await body();
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
throw mapMarianumConnectError(e);
|
throw mapError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The AppException a failed call surfaces as. Query families with domain
|
||||||
|
/// error codes override this instead of re-implementing [guard].
|
||||||
|
AppException mapError(DioException error) => mapMarianumConnectError(error);
|
||||||
|
|
||||||
/// GETs [path] and parses the JSON object body with [fromJson].
|
/// GETs [path] and parses the JSON object body with [fromJson].
|
||||||
Future<T> getObject<T>(
|
Future<T> getObject<T>(
|
||||||
String path,
|
String path,
|
||||||
@@ -55,6 +62,16 @@ abstract class MarianumConnectQuery {
|
|||||||
.toList();
|
.toList();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// GETs the raw bytes of [path] (files that need the bearer token).
|
||||||
|
Future<Uint8List> getBytes(String path) => guard(() async {
|
||||||
|
final response = await dio.get<List<int>>(
|
||||||
|
endpoint(path),
|
||||||
|
options: Options(responseType: ResponseType.bytes),
|
||||||
|
);
|
||||||
|
final bytes = response.data!;
|
||||||
|
return bytes is Uint8List ? bytes : Uint8List.fromList(bytes);
|
||||||
|
});
|
||||||
|
|
||||||
/// Formats [d] as an ISO `yyyy-MM-dd` day for MarianumConnect query params.
|
/// Formats [d] as an ISO `yyyy-MM-dd` day for MarianumConnect query params.
|
||||||
String isoDate(DateTime d) =>
|
String isoDate(DateTime d) =>
|
||||||
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||||
|
|||||||
@@ -6,12 +6,22 @@ part of 'mc_holiday.dart';
|
|||||||
// JsonSerializableGenerator
|
// JsonSerializableGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
McHoliday _$McHolidayFromJson(Map<String, dynamic> json) => McHoliday(
|
McHoliday _$McHolidayFromJson(Map<String, dynamic> json) =>
|
||||||
shortName: json['shortName'] as String,
|
$checkedCreate('McHoliday', json, ($checkedConvert) {
|
||||||
longName: json['longName'] as String,
|
final val = McHoliday(
|
||||||
startDate: McHoliday._dateFromJson(json['startDate'] as String),
|
shortName: $checkedConvert('shortName', (v) => v as String),
|
||||||
endDate: McHoliday._dateFromJson(json['endDate'] as String),
|
longName: $checkedConvert('longName', (v) => v as String),
|
||||||
);
|
startDate: $checkedConvert(
|
||||||
|
'startDate',
|
||||||
|
(v) => McHoliday._dateFromJson(v as String),
|
||||||
|
),
|
||||||
|
endDate: $checkedConvert(
|
||||||
|
'endDate',
|
||||||
|
(v) => McHoliday._dateFromJson(v as String),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$McHolidayToJson(McHoliday instance) => <String, dynamic>{
|
Map<String, dynamic> _$McHolidayToJson(McHoliday instance) => <String, dynamic>{
|
||||||
'shortName': instance.shortName,
|
'shortName': instance.shortName,
|
||||||
|
|||||||
@@ -8,12 +8,15 @@ part of 'absence_prefill_response.dart';
|
|||||||
|
|
||||||
AbsencePrefillResponse _$AbsencePrefillResponseFromJson(
|
AbsencePrefillResponse _$AbsencePrefillResponseFromJson(
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
) => AbsencePrefillResponse(
|
) => $checkedCreate('AbsencePrefillResponse', json, ($checkedConvert) {
|
||||||
firstName: json['firstName'] as String? ?? '',
|
final val = AbsencePrefillResponse(
|
||||||
lastName: json['lastName'] as String? ?? '',
|
firstName: $checkedConvert('firstName', (v) => v as String? ?? ''),
|
||||||
className: json['className'] as String? ?? '',
|
lastName: $checkedConvert('lastName', (v) => v as String? ?? ''),
|
||||||
phone: json['phone'] as String? ?? '',
|
className: $checkedConvert('className', (v) => v as String? ?? ''),
|
||||||
);
|
phone: $checkedConvert('phone', (v) => v as String? ?? ''),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$AbsencePrefillResponseToJson(
|
Map<String, dynamic> _$AbsencePrefillResponseToJson(
|
||||||
AbsencePrefillResponse instance,
|
AbsencePrefillResponse instance,
|
||||||
|
|||||||
@@ -7,14 +7,17 @@ part of 'auth_login_response.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
AuthLoginUser _$AuthLoginUserFromJson(Map<String, dynamic> json) =>
|
AuthLoginUser _$AuthLoginUserFromJson(Map<String, dynamic> json) =>
|
||||||
AuthLoginUser(
|
$checkedCreate('AuthLoginUser', json, ($checkedConvert) {
|
||||||
id: json['id'] as String,
|
final val = AuthLoginUser(
|
||||||
username: json['username'] as String,
|
id: $checkedConvert('id', (v) => v as String),
|
||||||
firstName: json['firstName'] as String,
|
username: $checkedConvert('username', (v) => v as String),
|
||||||
lastName: json['lastName'] as String,
|
firstName: $checkedConvert('firstName', (v) => v as String),
|
||||||
userType: json['userType'] as String?,
|
lastName: $checkedConvert('lastName', (v) => v as String),
|
||||||
className: json['className'] as String?,
|
userType: $checkedConvert('userType', (v) => v as String?),
|
||||||
|
className: $checkedConvert('className', (v) => v as String?),
|
||||||
);
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$AuthLoginUserToJson(AuthLoginUser instance) =>
|
Map<String, dynamic> _$AuthLoginUserToJson(AuthLoginUser instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
@@ -27,12 +30,21 @@ Map<String, dynamic> _$AuthLoginUserToJson(AuthLoginUser instance) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
AuthLoginResponse _$AuthLoginResponseFromJson(Map<String, dynamic> json) =>
|
AuthLoginResponse _$AuthLoginResponseFromJson(Map<String, dynamic> json) =>
|
||||||
AuthLoginResponse(
|
$checkedCreate('AuthLoginResponse', json, ($checkedConvert) {
|
||||||
token: json['token'] as String,
|
final val = AuthLoginResponse(
|
||||||
tokenId: json['tokenId'] as String,
|
token: $checkedConvert('token', (v) => v as String),
|
||||||
expiresAt: AuthLoginResponse._expiresFromJson(json['expiresAt']),
|
tokenId: $checkedConvert('tokenId', (v) => v as String),
|
||||||
user: AuthLoginUser.fromJson(json['user'] as Map<String, dynamic>),
|
expiresAt: $checkedConvert(
|
||||||
|
'expiresAt',
|
||||||
|
(v) => AuthLoginResponse._expiresFromJson(v),
|
||||||
|
),
|
||||||
|
user: $checkedConvert(
|
||||||
|
'user',
|
||||||
|
(v) => AuthLoginUser.fromJson(v as Map<String, dynamic>),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$AuthLoginResponseToJson(AuthLoginResponse instance) =>
|
Map<String, dynamic> _$AuthLoginResponseToJson(AuthLoginResponse instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../../auth/token_storage.dart';
|
import '../../auth/token_storage.dart';
|
||||||
|
import '../../marianumconnect_api.dart';
|
||||||
|
import '../../marianumconnect_endpoint.dart';
|
||||||
import '../../marianumconnect_query.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
/// Revokes the stored MC bearer token both server-side and locally. Best-effort
|
/// Revokes the stored MC bearer token both server-side and locally. Best-effort
|
||||||
@@ -24,4 +26,17 @@ class AuthLogout extends MarianumConnectQuery {
|
|||||||
await _tokenStorage.clear();
|
await _tokenStorage.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Revokes the token of an inactive account; the stored (active) token is
|
||||||
|
/// left alone. Best-effort.
|
||||||
|
static Future<void> revoke(String token) async {
|
||||||
|
try {
|
||||||
|
await MarianumConnectApi.plainDio().post<void>(
|
||||||
|
MarianumConnectEndpoint.resolve('auth/logout'),
|
||||||
|
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
||||||
|
);
|
||||||
|
} on DioException catch (_) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../../errors/auth_exception.dart';
|
||||||
|
import '../../auth/token_storage.dart';
|
||||||
|
import '../../marianumconnect_api.dart';
|
||||||
|
import '../../marianumconnect_query.dart';
|
||||||
|
import '../auth_login/auth_login_response.dart';
|
||||||
|
|
||||||
|
/// Reads the user behind the stored bearer token, which also probes that the
|
||||||
|
/// token is still accepted.
|
||||||
|
///
|
||||||
|
/// Bypasses the shared dio singleton so the auth interceptor does not react
|
||||||
|
/// to the 401 this probe is meant to observe.
|
||||||
|
class AuthMe extends MarianumConnectQuery {
|
||||||
|
final MarianumConnectTokenStorage _tokenStorage;
|
||||||
|
|
||||||
|
AuthMe({
|
||||||
|
MarianumConnectTokenStorage tokenStorage =
|
||||||
|
const MarianumConnectTokenStorage(),
|
||||||
|
Dio? dio,
|
||||||
|
}) : _tokenStorage = tokenStorage,
|
||||||
|
super(dio: dio ?? MarianumConnectApi.plainDio());
|
||||||
|
|
||||||
|
/// Throws [AuthException] when the token is missing or rejected.
|
||||||
|
Future<void> run() async {
|
||||||
|
await user();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The signed-in user (names, type). Throws like [run].
|
||||||
|
Future<AuthLoginUser> user() async {
|
||||||
|
final options = await _tokenStorage.requireBearerOptions('AuthMe');
|
||||||
|
return guard(() async {
|
||||||
|
final response = await dio.get<Map<String, dynamic>>(
|
||||||
|
endpoint('auth/me'),
|
||||||
|
options: options,
|
||||||
|
);
|
||||||
|
return AuthLoginUser.fromJson(response.data!);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,17 +29,12 @@ class AuthVerify extends MarianumConnectQuery {
|
|||||||
required String username,
|
required String username,
|
||||||
required String password,
|
required String password,
|
||||||
}) async {
|
}) async {
|
||||||
final token = await _tokenStorage.readToken();
|
final options = await _tokenStorage.requireBearerOptions('AuthVerify');
|
||||||
if (token == null || token.isEmpty) {
|
|
||||||
throw AuthException.unauthorized(
|
|
||||||
technicalDetails: 'AuthVerify: no bearer token in storage',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return guard(() async {
|
return guard(() async {
|
||||||
await dio.post<void>(
|
await dio.post<void>(
|
||||||
endpoint('auth/verify'),
|
endpoint('auth/verify'),
|
||||||
data: {'username': username, 'password': password},
|
data: {'username': username, 'password': password},
|
||||||
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
options: options,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class GetBreakersResponse extends ApiResponse {
|
|||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class BreakerRule {
|
class BreakerRule {
|
||||||
|
@JsonKey(unknownEnumValue: BreakerArea.unknown)
|
||||||
List<BreakerArea> areas;
|
List<BreakerArea> areas;
|
||||||
String message;
|
String message;
|
||||||
|
|
||||||
@@ -69,4 +70,7 @@ enum BreakerArea {
|
|||||||
feedback,
|
feedback,
|
||||||
@JsonValue('MORE')
|
@JsonValue('MORE')
|
||||||
more,
|
more,
|
||||||
|
|
||||||
|
/// An area the backend added after this build; no module maps to it.
|
||||||
|
unknown,
|
||||||
}
|
}
|
||||||
@@ -7,15 +7,25 @@ part of 'get_breakers_response.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
GetBreakersResponse _$GetBreakersResponseFromJson(Map<String, dynamic> json) =>
|
GetBreakersResponse _$GetBreakersResponseFromJson(Map<String, dynamic> json) =>
|
||||||
GetBreakersResponse(
|
$checkedCreate('GetBreakersResponse', json, ($checkedConvert) {
|
||||||
(json['rules'] as List<dynamic>?)
|
final val = GetBreakersResponse(
|
||||||
|
$checkedConvert(
|
||||||
|
'rules',
|
||||||
|
(v) =>
|
||||||
|
(v as List<dynamic>?)
|
||||||
?.map((e) => BreakerRule.fromJson(e as Map<String, dynamic>))
|
?.map((e) => BreakerRule.fromJson(e as Map<String, dynamic>))
|
||||||
.toList() ??
|
.toList() ??
|
||||||
[],
|
[],
|
||||||
)
|
),
|
||||||
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
|
|
||||||
(k, e) => MapEntry(k, e as String),
|
|
||||||
);
|
);
|
||||||
|
$checkedConvert(
|
||||||
|
'headers',
|
||||||
|
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
|
||||||
|
(k, e) => MapEntry(k, e as String),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$GetBreakersResponseToJson(
|
Map<String, dynamic> _$GetBreakersResponseToJson(
|
||||||
GetBreakersResponse instance,
|
GetBreakersResponse instance,
|
||||||
@@ -24,13 +34,26 @@ Map<String, dynamic> _$GetBreakersResponseToJson(
|
|||||||
'rules': instance.rules.map((e) => e.toJson()).toList(),
|
'rules': instance.rules.map((e) => e.toJson()).toList(),
|
||||||
};
|
};
|
||||||
|
|
||||||
BreakerRule _$BreakerRuleFromJson(Map<String, dynamic> json) => BreakerRule(
|
BreakerRule _$BreakerRuleFromJson(Map<String, dynamic> json) =>
|
||||||
(json['areas'] as List<dynamic>)
|
$checkedCreate('BreakerRule', json, ($checkedConvert) {
|
||||||
.map((e) => $enumDecode(_$BreakerAreaEnumMap, e))
|
final val = BreakerRule(
|
||||||
|
$checkedConvert(
|
||||||
|
'areas',
|
||||||
|
(v) => (v as List<dynamic>)
|
||||||
|
.map(
|
||||||
|
(e) => $enumDecode(
|
||||||
|
_$BreakerAreaEnumMap,
|
||||||
|
e,
|
||||||
|
unknownValue: BreakerArea.unknown,
|
||||||
|
),
|
||||||
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
json['message'] as String,
|
),
|
||||||
(json['maxBuild'] as num?)?.toInt(),
|
$checkedConvert('message', (v) => v as String),
|
||||||
);
|
$checkedConvert('maxBuild', (v) => (v as num?)?.toInt()),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$BreakerRuleToJson(BreakerRule instance) =>
|
Map<String, dynamic> _$BreakerRuleToJson(BreakerRule instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
@@ -52,4 +75,5 @@ const _$BreakerAreaEnumMap = {
|
|||||||
BreakerArea.dates: 'DATES',
|
BreakerArea.dates: 'DATES',
|
||||||
BreakerArea.feedback: 'FEEDBACK',
|
BreakerArea.feedback: 'FEEDBACK',
|
||||||
BreakerArea.more: 'MORE',
|
BreakerArea.more: 'MORE',
|
||||||
|
BreakerArea.unknown: 'unknown',
|
||||||
};
|
};
|
||||||
@@ -8,13 +8,28 @@ part of 'get_capabilities_response.dart';
|
|||||||
|
|
||||||
CapabilitiesResponse _$CapabilitiesResponseFromJson(
|
CapabilitiesResponse _$CapabilitiesResponseFromJson(
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
) => CapabilitiesResponse(
|
) => $checkedCreate('CapabilitiesResponse', json, ($checkedConvert) {
|
||||||
viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false,
|
final val = CapabilitiesResponse(
|
||||||
pushNotifications: json['pushNotifications'] as bool? ?? false,
|
viewForeignTimetables: $checkedConvert(
|
||||||
timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(),
|
'viewForeignTimetables',
|
||||||
timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(),
|
(v) => v as bool? ?? false,
|
||||||
userType: json['userType'] as String?,
|
),
|
||||||
);
|
pushNotifications: $checkedConvert(
|
||||||
|
'pushNotifications',
|
||||||
|
(v) => v as bool? ?? false,
|
||||||
|
),
|
||||||
|
timetablePastDays: $checkedConvert(
|
||||||
|
'timetablePastDays',
|
||||||
|
(v) => (v as num?)?.toInt(),
|
||||||
|
),
|
||||||
|
timetableFutureDays: $checkedConvert(
|
||||||
|
'timetableFutureDays',
|
||||||
|
(v) => (v as num?)?.toInt(),
|
||||||
|
),
|
||||||
|
userType: $checkedConvert('userType', (v) => v as String?),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$CapabilitiesResponseToJson(
|
Map<String, dynamic> _$CapabilitiesResponseToJson(
|
||||||
CapabilitiesResponse instance,
|
CapabilitiesResponse instance,
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
|
|
||||||
import '../../marianumconnect_query.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
/// Downloads the raw PDF bytes of a Marianum Message from
|
/// Downloads the raw PDF bytes of a Marianum Message from
|
||||||
@@ -15,11 +13,6 @@ class GetNewsletterFile extends MarianumConnectQuery {
|
|||||||
|
|
||||||
GetNewsletterFile(this.id, {super.dio});
|
GetNewsletterFile(this.id, {super.dio});
|
||||||
|
|
||||||
Future<Uint8List> run() => guard(() async {
|
Future<Uint8List> run() =>
|
||||||
final response = await dio.get<List<int>>(
|
getBytes('newsletter/${Uri.encodeComponent(id)}/file');
|
||||||
endpoint('newsletter/${Uri.encodeComponent(id)}/file'),
|
|
||||||
options: Options(responseType: ResponseType.bytes),
|
|
||||||
);
|
|
||||||
return Uint8List.fromList(response.data!);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -7,14 +7,20 @@ part of 'get_ticker_response.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
TickerResponse _$TickerResponseFromJson(Map<String, dynamic> json) =>
|
TickerResponse _$TickerResponseFromJson(Map<String, dynamic> json) =>
|
||||||
TickerResponse(
|
$checkedCreate('TickerResponse', json, ($checkedConvert) {
|
||||||
schemaVersion: (json['schemaVersion'] as num?)?.toInt() ?? 1,
|
final val = TickerResponse(
|
||||||
available: json['available'] as bool? ?? false,
|
schemaVersion: $checkedConvert(
|
||||||
hash: json['hash'] as String?,
|
'schemaVersion',
|
||||||
publishedAt: json['publishedAt'] as String?,
|
(v) => (v as num?)?.toInt() ?? 1,
|
||||||
webUrl: json['webUrl'] as String? ?? '/ticker',
|
),
|
||||||
content: json['content'] as Map<String, dynamic>?,
|
available: $checkedConvert('available', (v) => v as bool? ?? false),
|
||||||
|
hash: $checkedConvert('hash', (v) => v as String?),
|
||||||
|
publishedAt: $checkedConvert('publishedAt', (v) => v as String?),
|
||||||
|
webUrl: $checkedConvert('webUrl', (v) => v as String? ?? '/ticker'),
|
||||||
|
content: $checkedConvert('content', (v) => v as Map<String, dynamic>?),
|
||||||
);
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$TickerResponseToJson(TickerResponse instance) =>
|
Map<String, dynamic> _$TickerResponseToJson(TickerResponse instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
|
|||||||
@@ -7,15 +7,26 @@ part of 'get_ticker_nav_response.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
TickerNavResponse _$TickerNavResponseFromJson(Map<String, dynamic> json) =>
|
TickerNavResponse _$TickerNavResponseFromJson(Map<String, dynamic> json) =>
|
||||||
TickerNavResponse(
|
$checkedCreate('TickerNavResponse', json, ($checkedConvert) {
|
||||||
schemaVersion: (json['schemaVersion'] as num?)?.toInt() ?? 1,
|
final val = TickerNavResponse(
|
||||||
navHash: json['navHash'] as String? ?? '',
|
schemaVersion: $checkedConvert(
|
||||||
sections:
|
'schemaVersion',
|
||||||
(json['sections'] as List<dynamic>?)
|
(v) => (v as num?)?.toInt() ?? 1,
|
||||||
?.map((e) => TickerNavSection.fromJson(e as Map<String, dynamic>))
|
),
|
||||||
|
navHash: $checkedConvert('navHash', (v) => v as String? ?? ''),
|
||||||
|
sections: $checkedConvert(
|
||||||
|
'sections',
|
||||||
|
(v) =>
|
||||||
|
(v as List<dynamic>?)
|
||||||
|
?.map(
|
||||||
|
(e) => TickerNavSection.fromJson(e as Map<String, dynamic>),
|
||||||
|
)
|
||||||
.toList() ??
|
.toList() ??
|
||||||
[],
|
[],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$TickerNavResponseToJson(TickerNavResponse instance) =>
|
Map<String, dynamic> _$TickerNavResponseToJson(TickerNavResponse instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
@@ -25,14 +36,22 @@ Map<String, dynamic> _$TickerNavResponseToJson(TickerNavResponse instance) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
TickerNavSection _$TickerNavSectionFromJson(Map<String, dynamic> json) =>
|
TickerNavSection _$TickerNavSectionFromJson(Map<String, dynamic> json) =>
|
||||||
TickerNavSection(
|
$checkedCreate('TickerNavSection', json, ($checkedConvert) {
|
||||||
title: json['title'] as String? ?? '',
|
final val = TickerNavSection(
|
||||||
pages:
|
title: $checkedConvert('title', (v) => v as String? ?? ''),
|
||||||
(json['pages'] as List<dynamic>?)
|
pages: $checkedConvert(
|
||||||
?.map((e) => TickerNavPage.fromJson(e as Map<String, dynamic>))
|
'pages',
|
||||||
|
(v) =>
|
||||||
|
(v as List<dynamic>?)
|
||||||
|
?.map(
|
||||||
|
(e) => TickerNavPage.fromJson(e as Map<String, dynamic>),
|
||||||
|
)
|
||||||
.toList() ??
|
.toList() ??
|
||||||
[],
|
[],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$TickerNavSectionToJson(TickerNavSection instance) =>
|
Map<String, dynamic> _$TickerNavSectionToJson(TickerNavSection instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
@@ -41,15 +60,21 @@ Map<String, dynamic> _$TickerNavSectionToJson(TickerNavSection instance) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
TickerNavPage _$TickerNavPageFromJson(Map<String, dynamic> json) =>
|
TickerNavPage _$TickerNavPageFromJson(Map<String, dynamic> json) =>
|
||||||
TickerNavPage(
|
$checkedCreate('TickerNavPage', json, ($checkedConvert) {
|
||||||
title: json['title'] as String? ?? '',
|
final val = TickerNavPage(
|
||||||
slug: json['slug'] as String? ?? '',
|
title: $checkedConvert('title', (v) => v as String? ?? ''),
|
||||||
kind: json['kind'] as String? ?? 'CONTENT',
|
slug: $checkedConvert('slug', (v) => v as String? ?? ''),
|
||||||
hash: json['hash'] as String?,
|
kind: $checkedConvert('kind', (v) => v as String? ?? 'CONTENT'),
|
||||||
externalUrl: json['externalUrl'] as String?,
|
hash: $checkedConvert('hash', (v) => v as String?),
|
||||||
externalUrlNewTab: json['externalUrlNewTab'] as bool?,
|
externalUrl: $checkedConvert('externalUrl', (v) => v as String?),
|
||||||
fileUrl: json['fileUrl'] as String?,
|
externalUrlNewTab: $checkedConvert(
|
||||||
|
'externalUrlNewTab',
|
||||||
|
(v) => v as bool?,
|
||||||
|
),
|
||||||
|
fileUrl: $checkedConvert('fileUrl', (v) => v as String?),
|
||||||
);
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$TickerNavPageToJson(TickerNavPage instance) =>
|
Map<String, dynamic> _$TickerNavPageToJson(TickerNavPage instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../../errors/app_exception.dart';
|
||||||
import '../../../errors/ticker_content_unavailable_exception.dart';
|
import '../../../errors/ticker_content_unavailable_exception.dart';
|
||||||
import '../../errors/marianumconnect_error.dart';
|
|
||||||
import '../../marianumconnect_query.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
import 'get_ticker_page_response.dart';
|
import 'get_ticker_page_response.dart';
|
||||||
|
|
||||||
@@ -14,26 +14,20 @@ import 'get_ticker_page_response.dart';
|
|||||||
/// 404 `{ "error": "CONTENT_UNAVAILABLE", "webUrl": ... }`; that case is mapped
|
/// 404 `{ "error": "CONTENT_UNAVAILABLE", "webUrl": ... }`; that case is mapped
|
||||||
/// to a dedicated [TickerContentUnavailableException] carrying the browser
|
/// to a dedicated [TickerContentUnavailableException] carrying the browser
|
||||||
/// fallback URL, so the detail screen can offer "open in browser" instead of a
|
/// fallback URL, so the detail screen can offer "open in browser" instead of a
|
||||||
/// generic error. The bespoke 404 handling is why this keeps its own try/catch
|
/// generic error.
|
||||||
/// instead of the base [guard].
|
|
||||||
class GetTickerPage extends MarianumConnectQuery {
|
class GetTickerPage extends MarianumConnectQuery {
|
||||||
final String slug;
|
final String slug;
|
||||||
|
|
||||||
GetTickerPage(this.slug, {super.dio});
|
GetTickerPage(this.slug, {super.dio});
|
||||||
|
|
||||||
Future<TickerPageResponse> run() async {
|
Future<TickerPageResponse> run() => getObject(
|
||||||
try {
|
'ticker/pages/${Uri.encodeComponent(slug)}',
|
||||||
final response = await dio.get<Map<String, dynamic>>(
|
TickerPageResponse.fromJson,
|
||||||
endpoint('ticker/pages/${Uri.encodeComponent(slug)}'),
|
|
||||||
);
|
);
|
||||||
return TickerPageResponse.fromJson(response.data!);
|
|
||||||
} on DioException catch (e) {
|
|
||||||
throw _mapError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Object _mapError(DioException e) {
|
@override
|
||||||
final response = e.response;
|
AppException mapError(DioException error) {
|
||||||
|
final response = error.response;
|
||||||
if (response?.statusCode == 404) {
|
if (response?.statusCode == 404) {
|
||||||
final body = _asMap(response?.data);
|
final body = _asMap(response?.data);
|
||||||
if (body != null && body['error'] == 'CONTENT_UNAVAILABLE') {
|
if (body != null && body['error'] == 'CONTENT_UNAVAILABLE') {
|
||||||
@@ -44,7 +38,7 @@ class GetTickerPage extends MarianumConnectQuery {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return mapMarianumConnectError(e);
|
return super.mapError(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic>? _asMap(dynamic data) {
|
Map<String, dynamic>? _asMap(dynamic data) {
|
||||||
|
|||||||
@@ -7,22 +7,31 @@ part of 'get_ticker_page_response.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
TickerPageResponse _$TickerPageResponseFromJson(Map<String, dynamic> json) =>
|
TickerPageResponse _$TickerPageResponseFromJson(Map<String, dynamic> json) =>
|
||||||
TickerPageResponse(
|
$checkedCreate('TickerPageResponse', json, ($checkedConvert) {
|
||||||
schemaVersion: (json['schemaVersion'] as num?)?.toInt() ?? 1,
|
final val = TickerPageResponse(
|
||||||
slug: json['slug'] as String?,
|
schemaVersion: $checkedConvert(
|
||||||
title: json['title'] as String?,
|
'schemaVersion',
|
||||||
kind: json['kind'] as String? ?? 'CONTENT',
|
(v) => (v as num?)?.toInt() ?? 1,
|
||||||
content: json['content'] as Map<String, dynamic>?,
|
),
|
||||||
externalUrl: json['externalUrl'] as String?,
|
slug: $checkedConvert('slug', (v) => v as String?),
|
||||||
externalUrlNewTab: json['externalUrlNewTab'] as bool?,
|
title: $checkedConvert('title', (v) => v as String?),
|
||||||
fileUrl: json['fileUrl'] as String?,
|
kind: $checkedConvert('kind', (v) => v as String? ?? 'CONTENT'),
|
||||||
contentType: json['contentType'] as String?,
|
content: $checkedConvert('content', (v) => v as Map<String, dynamic>?),
|
||||||
filename: json['filename'] as String?,
|
externalUrl: $checkedConvert('externalUrl', (v) => v as String?),
|
||||||
hash: json['hash'] as String?,
|
externalUrlNewTab: $checkedConvert(
|
||||||
webUrl: json['webUrl'] as String?,
|
'externalUrlNewTab',
|
||||||
publishedAt: json['publishedAt'] as String?,
|
(v) => v as bool?,
|
||||||
fileFetchedAt: json['fileFetchedAt'] as String?,
|
),
|
||||||
|
fileUrl: $checkedConvert('fileUrl', (v) => v as String?),
|
||||||
|
contentType: $checkedConvert('contentType', (v) => v as String?),
|
||||||
|
filename: $checkedConvert('filename', (v) => v as String?),
|
||||||
|
hash: $checkedConvert('hash', (v) => v as String?),
|
||||||
|
webUrl: $checkedConvert('webUrl', (v) => v as String?),
|
||||||
|
publishedAt: $checkedConvert('publishedAt', (v) => v as String?),
|
||||||
|
fileFetchedAt: $checkedConvert('fileFetchedAt', (v) => v as String?),
|
||||||
);
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$TickerPageResponseToJson(TickerPageResponse instance) =>
|
Map<String, dynamic> _$TickerPageResponseToJson(TickerPageResponse instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
|
|
||||||
import '../../marianumconnect_query.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
/// Downloads the raw bytes of a PROXIED_FILE ticker page from
|
/// Downloads the raw bytes of a PROXIED_FILE ticker page from
|
||||||
@@ -15,11 +13,6 @@ class GetTickerPageFile extends MarianumConnectQuery {
|
|||||||
|
|
||||||
GetTickerPageFile(this.slug, {super.dio});
|
GetTickerPageFile(this.slug, {super.dio});
|
||||||
|
|
||||||
Future<Uint8List> run() => guard(() async {
|
Future<Uint8List> run() =>
|
||||||
final response = await dio.get<List<int>>(
|
getBytes('ticker/pages/${Uri.encodeComponent(slug)}/file');
|
||||||
endpoint('ticker/pages/${Uri.encodeComponent(slug)}/file'),
|
|
||||||
options: Options(responseType: ResponseType.bytes),
|
|
||||||
);
|
|
||||||
return Uint8List.fromList(response.data!);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
|
import '../../client_platform.dart';
|
||||||
import 'report_client_error.dart';
|
import 'report_client_error.dart';
|
||||||
|
|
||||||
/// Fire-and-forget bridge from the app's global error handlers to the
|
/// Fire-and-forget bridge from the app's global error handlers to the
|
||||||
@@ -36,6 +36,15 @@ class ClientErrorReporter {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Server entries the decoder could not map (see `decodeListLenient`): the
|
||||||
|
/// app keeps working without them, the report says which field drifted.
|
||||||
|
static void reportDecodeWarnings(String context, Iterable<String> warnings) {
|
||||||
|
for (final warning in warnings) {
|
||||||
|
log('$context: skipped entry: $warning');
|
||||||
|
_report(errorType: 'decode', message: warning, context: context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static void _report({
|
static void _report({
|
||||||
required String errorType,
|
required String errorType,
|
||||||
required String message,
|
required String message,
|
||||||
@@ -61,7 +70,7 @@ class ClientErrorReporter {
|
|||||||
message: message,
|
message: message,
|
||||||
stacktrace: stacktrace,
|
stacktrace: stacktrace,
|
||||||
context: context,
|
context: context,
|
||||||
platform: _platform(),
|
platform: clientPlatform(),
|
||||||
appVersion: _appVersion,
|
appVersion: _appVersion,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -73,10 +82,4 @@ class ClientErrorReporter {
|
|||||||
final normalized = message.toLowerCase().replaceAll(RegExp(r'\d+'), '#');
|
final normalized = message.toLowerCase().replaceAll(RegExp(r'\d+'), '#');
|
||||||
return '$errorType|$normalized|${context ?? ''}';
|
return '$errorType|$normalized|${context ?? ''}';
|
||||||
}
|
}
|
||||||
|
|
||||||
static String? _platform() {
|
|
||||||
if (Platform.isAndroid) return 'android';
|
|
||||||
if (Platform.isIOS) return 'ios';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import 'dart:typed_data';
|
|||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
|
import '../../client_platform.dart';
|
||||||
import '../../marianumconnect_query.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
/// Submits user feedback to MarianumConnect (`POST me/feedback`, bearer-auth).
|
/// Submits user feedback to MarianumConnect (`POST me/feedback`, bearer-auth).
|
||||||
@@ -27,7 +28,7 @@ class SubmitFeedback extends MarianumConnectQuery {
|
|||||||
'message': message,
|
'message': message,
|
||||||
'screenshot': ?screenshotBase64,
|
'screenshot': ?screenshotBase64,
|
||||||
'screenshotContentType': screenshot != null ? screenshotContentType : null,
|
'screenshotContentType': screenshot != null ? screenshotContentType : null,
|
||||||
'platform': _platform(),
|
'platform': clientPlatform(),
|
||||||
'appVersion': package.version,
|
'appVersion': package.version,
|
||||||
'appBuild': int.tryParse(package.buildNumber),
|
'appBuild': int.tryParse(package.buildNumber),
|
||||||
'deviceModel': await _deviceModel(),
|
'deviceModel': await _deviceModel(),
|
||||||
@@ -35,12 +36,6 @@ class SubmitFeedback extends MarianumConnectQuery {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
static String? _platform() {
|
|
||||||
if (Platform.isAndroid) return 'android';
|
|
||||||
if (Platform.isIOS) return 'ios';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<String?> _deviceModel() async {
|
static Future<String?> _deviceModel() async {
|
||||||
try {
|
try {
|
||||||
final info = DeviceInfoPlugin();
|
final info = DeviceInfoPlugin();
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
import '../../../../storage/keychain_write.dart';
|
||||||
|
import '../../../../utils/random_id.dart';
|
||||||
|
|
||||||
/// A stable, anonymous per-install identifier for telemetry. Generated once on
|
/// A stable, anonymous per-install identifier for telemetry. Generated once on
|
||||||
/// first use (128 bits from a cryptographic RNG) and persisted in the secure
|
/// first use (128 bits from a cryptographic RNG) and persisted in the secure
|
||||||
/// keystore, so a device stays a single row across password rotations and FCM
|
/// keystore, so a device stays a single row across password rotations and FCM
|
||||||
@@ -21,15 +22,9 @@ class TelemetryDeviceId {
|
|||||||
_cached = existing;
|
_cached = existing;
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
final generated = _generate();
|
final generated = randomHexId();
|
||||||
await _storage.write(key: _key, value: generated);
|
await _storage.writeRetrying(key: _key, value: generated);
|
||||||
_cached = generated;
|
_cached = generated;
|
||||||
return generated;
|
return generated;
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _generate() {
|
|
||||||
final random = Random.secure();
|
|
||||||
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
|
|
||||||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+12
-8
@@ -2,8 +2,8 @@ import 'dart:developer';
|
|||||||
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../../../../model/account_data.dart';
|
import '../../../../session/session.dart';
|
||||||
import '../../../demo/demo_mode.dart';
|
import '../../../../session/session_manager.dart';
|
||||||
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event.dart';
|
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event.dart';
|
||||||
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart';
|
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart';
|
||||||
import '../../../mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart';
|
import '../../../mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart';
|
||||||
@@ -26,28 +26,32 @@ class CustomEventsMigration {
|
|||||||
const CustomEventsMigration._();
|
const CustomEventsMigration._();
|
||||||
|
|
||||||
static Future<void> runOnce() async {
|
static Future<void> runOnce() async {
|
||||||
if (DemoMode.active) return;
|
// Only password accounts can derive the legacy identity.
|
||||||
|
final session = SessionManager().current;
|
||||||
|
if (session is! CredentialSession || session.isDemo) return;
|
||||||
if (await _isDone()) return;
|
if (await _isDone()) return;
|
||||||
|
|
||||||
final epoch = AccountData().sessionEpoch;
|
final epoch = SessionManager().sessionEpoch;
|
||||||
try {
|
try {
|
||||||
final response = await GetCustomTimetableEvent(
|
final response = await GetCustomTimetableEvent(
|
||||||
GetCustomTimetableEventParams(AccountData().getUserSecret()),
|
GetCustomTimetableEventParams(session.legacyUserSecret),
|
||||||
).run();
|
).run();
|
||||||
|
|
||||||
for (final event in response.events) {
|
for (final event in response.events) {
|
||||||
// The POST authenticates with whoever is signed in now; after a
|
// The POST authenticates with whoever is signed in now; after a
|
||||||
// sign-out the previous account's events would land in the next one.
|
// sign-out the previous account's events would land in the next one.
|
||||||
if (!AccountData().isCurrentSession(epoch)) return;
|
if (!SessionManager().isCurrentSession(epoch)) return;
|
||||||
await TimetableCustomEventsAdd().run(event);
|
await TimetableCustomEventsAdd().run(event);
|
||||||
await RemoveCustomTimetableEvent(
|
await RemoveCustomTimetableEvent(
|
||||||
RemoveCustomTimetableEventParams(event.id),
|
RemoveCustomTimetableEventParams(event.id),
|
||||||
).run();
|
).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!AccountData().isCurrentSession(epoch)) return;
|
if (!SessionManager().isCurrentSession(epoch)) return;
|
||||||
await _markDone();
|
await _markDone();
|
||||||
log('Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.');
|
log(
|
||||||
|
'Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.',
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Leave the flag unset so the next launch retries; the delete-after-post
|
// Leave the flag unset so the next launch retries; the delete-after-post
|
||||||
// above keeps a partial run duplicate-free.
|
// above keeps a partial run duplicate-free.
|
||||||
|
|||||||
+21
-10
@@ -7,11 +7,14 @@ part of 'timetable_get_classes_response.dart';
|
|||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
McTimetableClass _$McTimetableClassFromJson(Map<String, dynamic> json) =>
|
McTimetableClass _$McTimetableClassFromJson(Map<String, dynamic> json) =>
|
||||||
McTimetableClass(
|
$checkedCreate('McTimetableClass', json, ($checkedConvert) {
|
||||||
id: (json['id'] as num).toInt(),
|
final val = McTimetableClass(
|
||||||
shortName: json['shortName'] as String,
|
id: $checkedConvert('id', (v) => (v as num).toInt()),
|
||||||
longName: json['longName'] as String,
|
shortName: $checkedConvert('shortName', (v) => v as String),
|
||||||
|
longName: $checkedConvert('longName', (v) => v as String),
|
||||||
);
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$McTimetableClassToJson(McTimetableClass instance) =>
|
Map<String, dynamic> _$McTimetableClassToJson(McTimetableClass instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
@@ -22,15 +25,23 @@ Map<String, dynamic> _$McTimetableClassToJson(McTimetableClass instance) =>
|
|||||||
|
|
||||||
TimetableGetClassesResponse _$TimetableGetClassesResponseFromJson(
|
TimetableGetClassesResponse _$TimetableGetClassesResponseFromJson(
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
) =>
|
) => $checkedCreate('TimetableGetClassesResponse', json, ($checkedConvert) {
|
||||||
TimetableGetClassesResponse(
|
final val = TimetableGetClassesResponse(
|
||||||
result: (json['result'] as List<dynamic>)
|
result: $checkedConvert(
|
||||||
|
'result',
|
||||||
|
(v) => (v as List<dynamic>)
|
||||||
.map((e) => McTimetableClass.fromJson(e as Map<String, dynamic>))
|
.map((e) => McTimetableClass.fromJson(e as Map<String, dynamic>))
|
||||||
.toList(),
|
.toList(),
|
||||||
)
|
),
|
||||||
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
|
|
||||||
(k, e) => MapEntry(k, e as String),
|
|
||||||
);
|
);
|
||||||
|
$checkedConvert(
|
||||||
|
'headers',
|
||||||
|
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
|
||||||
|
(k, e) => MapEntry(k, e as String),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$TimetableGetClassesResponseToJson(
|
Map<String, dynamic> _$TimetableGetClassesResponseToJson(
|
||||||
TimetableGetClassesResponse instance,
|
TimetableGetClassesResponse instance,
|
||||||
|
|||||||
+14
-6
@@ -8,15 +8,23 @@ part of 'timetable_get_holidays_response.dart';
|
|||||||
|
|
||||||
TimetableGetHolidaysResponse _$TimetableGetHolidaysResponseFromJson(
|
TimetableGetHolidaysResponse _$TimetableGetHolidaysResponseFromJson(
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
) =>
|
) => $checkedCreate('TimetableGetHolidaysResponse', json, ($checkedConvert) {
|
||||||
TimetableGetHolidaysResponse(
|
final val = TimetableGetHolidaysResponse(
|
||||||
result: (json['result'] as List<dynamic>)
|
result: $checkedConvert(
|
||||||
|
'result',
|
||||||
|
(v) => (v as List<dynamic>)
|
||||||
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
|
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
|
||||||
.toList(),
|
.toList(),
|
||||||
)
|
),
|
||||||
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
|
|
||||||
(k, e) => MapEntry(k, e as String),
|
|
||||||
);
|
);
|
||||||
|
$checkedConvert(
|
||||||
|
'headers',
|
||||||
|
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
|
||||||
|
(k, e) => MapEntry(k, e as String),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$TimetableGetHolidaysResponseToJson(
|
Map<String, dynamic> _$TimetableGetHolidaysResponseToJson(
|
||||||
TimetableGetHolidaysResponse instance,
|
TimetableGetHolidaysResponse instance,
|
||||||
|
|||||||
+23
-11
@@ -6,11 +6,15 @@ part of 'timetable_get_rooms_response.dart';
|
|||||||
// JsonSerializableGenerator
|
// JsonSerializableGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
McRoom _$McRoomFromJson(Map<String, dynamic> json) => McRoom(
|
McRoom _$McRoomFromJson(Map<String, dynamic> json) =>
|
||||||
id: (json['id'] as num).toInt(),
|
$checkedCreate('McRoom', json, ($checkedConvert) {
|
||||||
shortName: json['shortName'] as String,
|
final val = McRoom(
|
||||||
longName: json['longName'] as String,
|
id: $checkedConvert('id', (v) => (v as num).toInt()),
|
||||||
);
|
shortName: $checkedConvert('shortName', (v) => v as String),
|
||||||
|
longName: $checkedConvert('longName', (v) => v as String),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$McRoomToJson(McRoom instance) => <String, dynamic>{
|
Map<String, dynamic> _$McRoomToJson(McRoom instance) => <String, dynamic>{
|
||||||
'id': instance.id,
|
'id': instance.id,
|
||||||
@@ -20,15 +24,23 @@ Map<String, dynamic> _$McRoomToJson(McRoom instance) => <String, dynamic>{
|
|||||||
|
|
||||||
TimetableGetRoomsResponse _$TimetableGetRoomsResponseFromJson(
|
TimetableGetRoomsResponse _$TimetableGetRoomsResponseFromJson(
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
) =>
|
) => $checkedCreate('TimetableGetRoomsResponse', json, ($checkedConvert) {
|
||||||
TimetableGetRoomsResponse(
|
final val = TimetableGetRoomsResponse(
|
||||||
result: (json['result'] as List<dynamic>)
|
result: $checkedConvert(
|
||||||
|
'result',
|
||||||
|
(v) => (v as List<dynamic>)
|
||||||
.map((e) => McRoom.fromJson(e as Map<String, dynamic>))
|
.map((e) => McRoom.fromJson(e as Map<String, dynamic>))
|
||||||
.toList(),
|
.toList(),
|
||||||
)
|
),
|
||||||
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
|
|
||||||
(k, e) => MapEntry(k, e as String),
|
|
||||||
);
|
);
|
||||||
|
$checkedConvert(
|
||||||
|
'headers',
|
||||||
|
(v) => val.headers = (v as Map<String, dynamic>?)?.map(
|
||||||
|
(k, e) => MapEntry(k, e as String),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
Map<String, dynamic> _$TimetableGetRoomsResponseToJson(
|
Map<String, dynamic> _$TimetableGetRoomsResponseToJson(
|
||||||
TimetableGetRoomsResponse instance,
|
TimetableGetRoomsResponse instance,
|
||||||
|
|||||||
Loaded 100 of 397 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user