81 Commits

Author SHA1 Message Date
MineTec 778c473631 added telemetry call on app resume with 15 minute cooldown 2026-07-28 12:52:07 +02:00
MineTec 7e9cbcf1e9 fixed widget mounting errors 2026-07-28 12:51:39 +02:00
MineTec 32be67426c Merge remote-tracking branch 'origin/develop' into develop 2026-07-26 13:49:05 +02:00
MineTec b957189fd3 implemented absence report module with form validation, prefill support, and API integration 2026-07-26 11:45:48 +02:00
MineTec 624c5512a6 fixed failing test 2026-07-23 14:22:32 +02:00
MineTec 3493cf8617 fixed error on pdf viewer transition 2026-07-23 14:16:33 +02:00
MineTec 75080a2c49 implemented shared items view for Talk chats with tabbed categories and on-scroll pagination 2026-07-19 10:58:02 +02:00
MineTec f3cd7896d9 implemented backward pagination for chat history with on-scroll prefetching 2026-07-17 20:14:47 +02:00
Marianum 101e7c909c bump version to 1.4.0+59 2026-07-17 15:19:26 +02:00
Marianum 429f1e6f96 fix build against updated dependencies: re-add DioExceptionType.transformTimeout case (restored in dio 5.10.0) and rename SwiftReceiveSharingIntentPlugin to ReceiveSharingIntentPlugin (renamed in receive_sharing_intent 1.9.0); refresh Podfile.lock and SPM Package.resolved 2026-07-17 15:19:10 +02:00
Marianum a0c55a811c completed iOS push notification setup: created the NotificationServiceExtension Xcode target programmatically (via xcodeproj), embedded it in Runner, and added its xcconfig base configs; fixed Automatic Signing by switching the NSE↔Runner shared keychain from an app-group to a team-prefixed access group across entitlements, Dart and Swift; fixed the analyzer break from dio 5.9.2 dropping DioExceptionType.transformTimeout 2026-07-17 15:05:34 +02:00
MineTec e625216a90 improved push notification settings with health indicators and optimized notification dismissal 2026-07-16 22:40:33 +02:00
MineTec 8128cede21 refactored teacher display in lesson details to better handle substitutions and duplicates 2026-07-16 21:44:08 +02:00
MineTec e5f7cf0176 updated the ProseMirror table view to conditionally hide the mode toggle toolbar when content fits within the available width, ensuring the toggle is only visible for overflowing tables. 2026-07-16 21:43:52 +02:00
MineTec e349d667d4 implemented a 1-minute in-memory cache for the EmergencyNoticeClient to prevent redundant network requests on app resume 2026-07-16 10:47:51 +02:00
MineTec 7c1f5c06df implemented a backend-independent emergency notice system 2026-07-15 20:30:20 +02:00
MineTec e8c6ac1c65 further improved accessibility across the application and add tooltips to action buttons 2026-07-15 19:29:40 +02:00
MineTec 478f0ff20b implemented comprehensive accessibility (A11y) support across the app 2026-07-13 23:41:47 +02:00
MineTec 8274dd46cd refactored HTTP error handling and streamlined UI components by centralizing shared logic and removing redundant parameters 2026-07-13 22:58:02 +02:00
MineTec d7536ea5d0 remove dead endpoint staging mode (obsoleted by demo mode) 2026-07-13 22:32:38 +02:00
MineTec 398b147c76 simplify: dedupe boilerplate and remove dead code across all layers 2026-07-13 22:22:39 +02:00
MineTec 15791423ea added fileFetchedAt to TickerPageResponse and updated the Ticker UI to use it as the data currency timestamp for proxied files 2026-07-13 00:32:27 +02:00
MineTec 2f5a6b4ce0 split file_viewer into focused sub-widgets 2026-07-13 00:01:07 +02:00
MineTec 53bc6d5360 share weekKey extension and log splash precache failures 2026-07-12 23:56:47 +02:00
MineTec f50359b4eb use AppProgressIndicator for plain loading spinners 2026-07-12 23:45:55 +02:00
MineTec 9994a1f3fa extract SettingsDropdownTile for settings dropdowns 2026-07-12 23:42:56 +02:00
MineTec 4aa31a2e44 extract SettingsCheckboxTile for settings toggles 2026-07-12 23:41:12 +02:00
MineTec dfce3e7b5c add PromptDialog helper for text input dialogs 2026-07-12 23:36:46 +02:00
MineTec 564a334cdc route avatar crop and profile view through AppRoutes 2026-07-12 23:32:48 +02:00
MineTec db329c7299 adopt MarianumConnectQuery base in remaining queries 2026-07-12 23:28:51 +02:00
MineTec 0a2ff5c3fb add MarianumConnectQuery base and adopt it in list queries 2026-07-12 23:24:21 +02:00
MineTec 9b5198c6db add ticker page bloc and avatar disk cache 2026-07-12 23:18:53 +02:00
MineTec 94794ff092 remove unused dead code files 2026-07-12 23:17:47 +02:00
MineTec fe2b3c43b2 implemented custom subject colors for the timetable and unified the color palette system 2026-07-12 21:30:08 +02:00
MineTec a7111844b1 bugfixes, better background operation robustness and platform-specific error handling 2026-07-12 19:17:49 +02:00
MineTec babc347b18 implemented global user search integration for Talk chats with role-coded badges and direct chat creation logic 2026-07-12 19:05:27 +02:00
MineTec 44e45c9b78 implemented full interactive poll support in Talk, including creation, voting, and closing functionality 2026-07-12 17:54:30 +02:00
MineTec 3f44e9302f implemented dynamic quick reactions by surfacing emojis from message content in the chat options dialog 2026-07-12 16:42:30 +02:00
MineTec 59501d3b45 implemented enlarged emoji rendering for chat messages containing up to three emojis 2026-07-12 16:26:22 +02:00
MineTec 91a6216f66 migrated custom timetable events to Marianum-Connect and refactored push notification handling 2026-07-12 14:31:48 +02:00
MineTec c444ed54a5 migrated Marianum Message module to the Marianum-Connect API and implemented push notification deep linking 2026-07-11 17:37:50 +02:00
MineTec ff23199345 downgrade AGP and Gradle versions, update Ticker icon, and upgrade dependencies 2026-07-11 15:32:09 +02:00
MineTec 9545d2a946 upgraded Android Gradle Plugin to 9.2.1 and Gradle wrapper to 9.4.1 2026-07-10 19:16:07 +02:00
MineTec 37608e59b3 implemented Ticker page selection persistence and enhanced ProseMirror table display modes 2026-07-10 19:15:52 +02:00
MineTec 0d01f6b631 fixed push-registration not activated on app-login 2026-07-10 15:35:56 +02:00
MineTec b0d2e7a34b fixed inconsistent placement of appbar title on iOS 2026-07-09 12:04:20 +02:00
MineTec cedeb06569 implemented the Ticker module with a native ProseMirror document renderer and integrated API support for structured content, navigation trees, and proxied files 2026-07-09 00:51:08 +02:00
MineTec 1114291313 Add Fastlane Play Store integration with automated screenshots
Fastlane supply config (android/fastlane) with de-DE metadata structure,
integration_test-driven screenshot automation (screenshot_test.dart,
test_driver, tool/screenshots.sh) using stable keys on the login form,
refreshed materials/screenshots (phone + 7-inch) and app banner/symbol.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 23:43:10 +02:00
MineTec 9b74c9fd81 implemented a comprehensive client-side demo mode triggered by a "demo@" username prefix, serving curated fixture data for all app modules 2026-07-07 20:24:16 +02:00
MineTec 2668b111f1 updated login branding and the about dialog with updated slogan. 2026-07-07 19:21:11 +02:00
MineTec a19b67eb84 refactored and condensed technical documentation and comments across the codebase to improve readability 2026-07-06 23:17:06 +02:00
MineTec c48f5ef215 migrated feedback submission from the legacy mhsl.eu endpoint to the MarianumConnect API 2026-07-06 22:36:11 +02:00
MineTec ddbb3fbc19 implemented a new telemetry heartbeat and client-side error reporting system for MarianumConnect; replaced legacy mhsl.eu user index updates with a POST me/telemetry heartbeat using stable, securely-stored device identifiers; integrated global Flutter and platform error handlers to report uncaught exceptions with session-based deduplication and rate limiting. 2026-07-06 22:11:23 +02:00
MineTec 2be5051d12 migrated the breaker system to MarianumConnect and refactored the API response to a rule-based model with support for version-specific blocks using maxBuild. 2026-07-06 21:15:39 +02:00
MineTec 38a271929c implemented a guided notification permission flow triggered on the first Talk visit 2026-07-06 20:01:45 +02:00
MineTec 3be0113f93 fixed bug on leaving talk chat, spamming console 2026-07-06 15:55:27 +02:00
MineTec 2b59f11ea5 fixed z-index issue with background-loading indicator being behind talk-messages in chats 2026-07-06 15:51:08 +02:00
MineTec 124b1a5177 added support for simultan downloads in files and talk, support for background downloads, enhanced loading in files with retry 2026-07-06 15:43:17 +02:00
MineTec 614ab159af updated notification subject parsing to support newline separators for sender and message, while maintaining a fallback for legacy colon-delimited formats. 2026-07-05 23:57:48 +02:00
MineTec 5952c6619f fixed a race condition in long-poll initialization by adding a token validation guard to prevent stale chat sessions from hijacking the current state during navigation or app resumes. 2026-07-05 23:55:01 +02:00
MineTec dab208877f improved reliability and error handling for background notification actions by migrating to a top-level entry point for stable AOT callback resolution; implemented failure notifications to preserve undelivered reply text and surface technical errors; resolved a race condition that could resurrect dismissed notifications during silent re-renders; updated the Android manifest with the required broadcast receiver for notification actions; and refactored internal OCS methods to return detailed success/failure records for improved logging and field debugging. 2026-07-05 23:43:58 +02:00
MineTec 483fea62ba implemented dual Nextcloud push registration with separate general and talk apptypes to ensure reliable Talk notification delivery; introduced stacked MessagingStyle notifications for chat threads with support for circular conversation avatars and disk caching 2026-07-05 22:48:04 +02:00
MineTec 35e144799e Bumped Gradle to 8.14 and fixed build warnings 2026-07-05 00:17:20 +02:00
MineTec b14e8c8ea2 improved timetable date range calculation to ensure the current week remains accessible during holiday gaps or when persisted bounds are stale, and updated holiday labels to prioritize long names over short names in the calendar view. 2026-07-04 23:54:50 +02:00
MineTec 74a2ddd17f implemented an E2E-encrypted Nextcloud push-v2 notification system with support for RSA decryption and signature verification; introduced an iOS Notification Service Extension and native AppDelegate handlers for Talk actions (inline reply and mark-as-read); replaced the legacy notification registration with a new lifecycle managing app passwords and secure keypair storage; added background message handling with tray synchronization and a test notification utility in the settings. 2026-07-04 22:50:18 +02:00
MineTec 32f7c311bc fixed pending share race error on warm app start 2026-07-02 15:28:05 +02:00
MineTec 4bc7ffd37a implemented post-login splash screen with Lottie animations and integrated background data prefetching during the login transition 2026-06-29 12:00:26 +02:00
MineTec 13f4f79829 removed broad media access permissions from the manifest to comply with Google Play policies, and bumped the app version to 1.2.2+57. 2026-06-23 18:41:18 +02:00
MineTec a76b09af26 updated pods and SPM dependencies (Firebase 12.15, PhoneNumberKit)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 12:13:00 +02:00
Marianum 4fd09204ec iOS build 2026-06-23 12:06:22 +02:00
MineTec f554c57d8d updated version to 1.2.1+56 2026-06-22 22:44:54 +02:00
MineTec baa26a6e79 implemented a comprehensive Nextcloud file sharing system with support for user, group, and public link shares with gating based on server-side permissions; added sharing management interfaces including a share sheet; updated the file list with visual badges for incoming shares and improved OCS API response handling. 2026-06-02 21:42:08 +02:00
MineTec b6d06dd3b4 implemented foreign timetable support for students, teachers, rooms, and classes, including a searchable element picker with favorites support, introduced a capabilities system for feature gating, refactored the timetable UI into a reusable TimetableCalendarView component, and redesigned the chat input field with a unified emoji picker and integrated attachment actions. 2026-05-31 21:29:16 +02:00
MineTec 6e12da08c0 implemented a customizable chat background system with support for patterns, solid colors, and gallery images; added a dedicated settings page with live preview and adjustable blur/dim effects, updated the image cropper to support flexible aspect ratios for wallpapers, and integrated file cleanup logic during account logout. 2026-05-31 19:20:18 +02:00
MineTec 5ebf5bccdb implemented avatar management for user profiles and chat rooms, including 1:1 cropping, integrated OCS and Spreed avatar APIs, added cache invalidation logic, and updated the account settings view to display user info and profile pictures. 2026-05-31 18:42:30 +02:00
MineTec f966cf302b implemented favorite and leave actions for chat/rooms info view 2026-05-30 14:05:00 +02:00
MineTec 582432dbb9 implemented support for viewing large group profile pictures 2026-05-30 13:57:26 +02:00
MineTec ece0669f7d implemented a central haptic feedback system with configurable levels (off, reduced, full), added a Haptics facade providing semantic feedback methods, integrated haptic cues across navigation, settings toggles, and async action results, and updated version to 1.1.0+54 2026-05-30 13:54:19 +02:00
MineTec 01b4b44010 migrated holidays module to MarianumConnect API, replaced local Holiday model. 2026-05-24 17:49:25 +02:00
MineTec 93b9929f8f migrated timetable integration from WebUntis to the MarianumConnect API, implementing a Dio-based client with bearer token authentication, background session validation, and auto-refresh logic. 2026-05-23 17:32:42 +02:00
MineTec 2858f910c9 implemented DST-safe date arithmetic with new addDays and subtractDays extensions, updated timetable state to reset view and scroll boundaries on initialization to prevent stale views, added hard caps to calendar navigation, and updated version to 1.0.3+52 2026-05-22 15:08:30 +02:00
623 changed files with 34278 additions and 7446 deletions
+12
View File
@@ -350,3 +350,15 @@ hs_err_pid*
*.idea*
**/.DS_store
# Fastlane (Play-Store-Upload)
android/fastlane/report.xml
android/fastlane/Preview.html
android/fastlane/play-service-account.json
android/fastlane/README.md.bak
# lokale Screenshots außerhalb der Fastlane-Metadaten
/screenshots/
# Fastlane-Bild-PNGs sind generierte Kopien; kanonisch/versioniert liegen die
# Screenshots unter materials/screenshots/. Nur die leeren Ordner (.gitkeep)
# bleiben eingecheckt, damit supply die Struktur vorfindet.
android/fastlane/metadata/android/**/images/**/*.png
@@ -4,4 +4,10 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Allow cleartext HTTP in debug builds so developers can point the
Marianum-Connect custom endpoint at a local backend (e.g.
http://10.0.2.2:8080 from the Android emulator). Release builds
keep the default cleartext block. -->
<application android:usesCleartextTraffic="true" />
</manifest>
+46 -1
View File
@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:label="Marianum Fulda"
android:name="${applicationName}"
@@ -47,6 +48,35 @@
android:name="flutterEmbedding"
android:value="2" />
<!-- Fallback small icon + accent for anything FCM ever renders itself
(e.g. plain notification messages). Launcher mipmaps are unusable
as small icons: Android only draws their alpha silhouette, which
shows up as a solid circle in the status bar. -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_stat_notification" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/notification_accent" />
<!-- flutter_local_notifications liefert Notification-Action-Taps (Talk:
Antworten / Gelesen) als expliziten Broadcast an diesen Receiver.
Das Plugin deklariert ihn NICHT selbst — ohne diesen Eintrag kommt
der Tap nirgends an und der Background-Handler startet nie. -->
<receiver
android:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver"
android:exported="false" />
<!-- background_downloader runs large/long downloads in a foreground
service (WorkManager) so they survive the app being backgrounded and
beat the default 9-minute WorkManager timeout. On API 34+ the
dataSync foreground service type must be declared explicitly here;
see the FOREGROUND_SERVICE_DATA_SYNC permission below. -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
<!-- Receiver classes live at the package root (NOT under .widgets) because
the home_widget Flutter plugin resolves them as <app-package>.<name>. -->
<receiver
@@ -103,7 +133,22 @@
</intent>
</queries>
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Android 13+ runtime notification permission. Requested at login via
FirebaseMessaging.requestPermission(); without this declaration the
locally rendered push notifications are silently dropped. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- background_downloader foreground service for large/long downloads that
must keep running while the app is backgrounded. DATA_SYNC is the
required type declaration on Android 14+ (API 34+). -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<!-- Workmanager periodic widget refresh needs to reschedule after device
reboot, otherwise the widget freezes until the user opens the app. -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<!-- Aus open_filex gemergt: die App öffnet nur selbst heruntergeladene Dateien
über dessen FileProvider (content://-URIs) und braucht keinen Zugriff auf
geteilte Medien. Entfernt wegen Google-Play "Photo and Video Permissions". -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" tools:node="remove"/>
</manifest>
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Status-bar small icon: monochrome Marianum M, ported from
marianum_m_watermark.xml (same path + nested transforms). Android renders
only the alpha channel of small icons, so the fill is hard white and never
tinted at runtime. 24dp with the source's non-square viewport — the system
fits it into the icon bounds.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="70.000168"
android:viewportHeight="82.227348">
<group
android:scaleX="0.26458333"
android:scaleY="0.26458334"
android:translateX="107.44411"
android:translateY="-80.482198">
<group
android:translateX="-749.41293"
android:translateY="290.52252">
<group
android:scaleX="0.13333333"
android:scaleY="-0.13333333"
android:translateX="0"
android:translateY="632.14667">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M 3499.67,4594.33 c -16.43,-106.19 -29.71,-199.97 -43.79,-293.49 86.83,-19 138.5,-27.61 223.38,-43.82 63.81,-12.18 175.24,-20.4 179.64,-83.23 6.46,-92.69 -124.69,-55.41 -188.38,-43.81 -84.33,15.36 -159.13,28.84 -232.2,43.81 -13.68,-60.72 -26.83,-118.68 -39.43,-179.61 -36.76,-178.32 -73.67,-368.16 -105.11,-551.97 18.09,25.66 30.84,42.72 43.8,65.7 66.7,118.26 140.39,245.04 227.76,354.83 33.49,42.05 76.86,94.81 118.31,113.91 98.42,45.36 166.68,-22.2 170.87,-118.28 3.68,-85.28 -23.09,-181.17 -35.08,-275.99 -12.4,-98.19 -22.89,-194.93 -35.03,-275.98 72.44,102.69 147.93,269.64 240.95,381.12 27.51,33 73.55,80.61 118.27,87.62 218.76,34.33 126.58,-312.17 127.05,-473.13 0.4,-144.9 44.01,-255.37 175.21,-271.59 43.02,-5.31 105.84,11.16 112.7,-26.34 8.67,-47.38 -78.15,-60.52 -125.84,-61.28 -291.34,-4.51 -322.06,262.33 -311.01,573.88 -19.85,-18.57 -35.71,-47.53 -52.57,-74.47 -97.59,-155.88 -203.95,-327.22 -297.92,-503.79 -25.93,-48.79 -53.68,-114.7 -135.8,-83.23 -17.27,6.63 -48.25,44.39 -52.56,56.96 -19.58,57.19 1.55,137.42 8.76,205.89 21.54,203.72 57.81,389.09 78.87,587.01 -26.3,0.51 -43.93,-30.07 -56.96,-48.2 -46.9,-65.27 -86.02,-140.76 -127.04,-214.64 -52.84,-95.15 -108.23,-192.84 -157.71,-293.52 -75.25,-153.09 -188.6,-501.89 -242.12,-678.81 -8.67,-28.67 -17.7,-58.08 -26.3,-87.64 -7.48,-25.72 -10.39,-57.68 -35.05,-74.46 -100.02,18.93 -89.71,104.89 -70.09,205.9 47.35,243.43 170.89,706.45 211.48,946.04 -72.97,-70.97 -153.99,-207.41 -289.14,-236.55 -136.47,-29.44 -217.95,47.68 -271.6,122.66 -17.14,23.96 -41.43,49.54 -26.29,78.84 83.96,35.51 113.37,-65.2 197.15,-74.47 22.65,-2.5 54.56,2.4 74.46,8.78 132.4,42.34 237.57,218.76 297.87,346.07 74.16,156.45 125.32,330.5 148.95,490.64 -65.71,11.4 -142.96,22.15 -219.25,36.52 -109.8,20.72 -158.81,10.75 -201.29,59.86 9.15,41.95 41.41,60.8 70.1,83.24 126.26,-16.84 252.45,-33.77 372.36,-56.97 20.43,89.25 51.98,218.51 74.45,311.05 40.53,26.02 88.88,-8.43 105.17,-35.06 z" />
</group>
</group>
</group>
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Brand accent for notifications; mirrors LightAppTheme.marianumRed
(Color.fromARGB(255, 153, 51, 51)). Used as the FCM default
notification color and must stay in sync with the Dart constant. -->
<color name="notification_accent">#993333</color>
</resources>
+5
View File
@@ -22,6 +22,11 @@ subprojects { sub ->
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
// Some plugins (e.g. app_settings) hard-pin an older compileSdk
// than their transitive AndroidX deps require, which fails the
// AAR metadata check. Align every plugin with the app's
// compileSdk (flutter.compileSdkVersion) so the build passes.
compileSdkVersion 36
}
}
sub.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
+6
View File
@@ -0,0 +1,6 @@
package_name("eu.mhsl.marianum.mobile.client")
# Service-Account-JSON für die Google Play Developer API. Pfad über die
# Umgebungsvariable SUPPLY_JSON_KEY setzen (die Datei selbst wird per
# .gitignore nicht eingecheckt).
json_key_file(ENV["SUPPLY_JSON_KEY"] || "fastlane/play-service-account.json")
+29
View File
@@ -0,0 +1,29 @@
default_platform(:android)
# Play-Store-Auslieferung über fastlane supply. Die Screenshots erzeugt
# ../tool/screenshots.sh (Demo-Login + integration_test) direkt in
# metadata/android/<locale>/images/. Texte/Changelogs liegen daneben in
# metadata/android/<locale>/.
platform :android do
desc "Nur Screenshots hochladen (verändert keine Texte/Binaries)"
lane :upload_screenshots do
upload_to_play_store(
skip_upload_apk: true,
skip_upload_aab: true,
skip_upload_metadata: true,
skip_upload_changelogs: true,
skip_upload_images: false,
skip_upload_screenshots: false,
metadata_path: "./fastlane/metadata/android",
)
end
desc "Store-Texte, Changelogs und Screenshots hochladen (keine Binaries)"
lane :upload_metadata do
upload_to_play_store(
skip_upload_apk: true,
skip_upload_aab: true,
metadata_path: "./fastlane/metadata/android",
)
end
end
+2
View File
@@ -0,0 +1,2 @@
# fastlane-Plugins (aktuell keine). Datei vorhanden, damit `fastlane` das
# Plugin-Handling initialisiert.
+35
View File
@@ -0,0 +1,35 @@
# Fastlane Play-Store-Screenshots & Upload
Schritt 2 der Play-Store-Automatisierung. Die App muss dafür **nicht** neu
gebaut werden der Demo-Modus (`demo@`-Login) liefert die kompletten Inhalte
aus Fixtures.
## 1. Screenshots aufnehmen
Ein Gerät/Emulator (≥ Android 8) per `adb devices` sichtbar, dann aus dem
**Client**-Wurzelverzeichnis:
```bash
tool/screenshots.sh
```
Fährt Phone (1080×1920), 7″ (1200×1920) und 10″ (1600×2560) nacheinander durch
(setzt die Displaygröße per `adb shell wm size/density`) und legt die PNGs in
`metadata/android/de-DE/images/{phone,sevenInch,tenInch}Screenshots/` ab.
- Nur eine Größe: `PROFILES="phone" tool/screenshots.sh`
- Profile-Build (flüssigere Frames): `BUILD_MODE=profile tool/screenshots.sh`
## 2. Hochladen
Service-Account-JSON der Google Play Developer API besorgen und referenzieren:
```bash
export SUPPLY_JSON_KEY=/pfad/zu/play-service-account.json
cd android
fastlane upload_screenshots # nur Bilder
# oder
fastlane upload_metadata # Bilder + Texte/Changelogs, keine Binaries
```
Der Service-Account-Key wird **nicht** eingecheckt (siehe `.gitignore`).
+4
View File
@@ -4,3 +4,7 @@ android.enableJetifier=true
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=false
android.nonFinalResIds=false
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+40
View File
@@ -0,0 +1,40 @@
---
#
# Hinweis: Die Steuerzeichen ("---") dürfen NICHT entfernt werden!
# Nach dem zweiten Steuerzeichen wird als Markdown interpretiert und es sind keine Kommentare mehr möglich!
# Kommentare sind nur innerhalb des Steuerblocks zugelassen, sowie die Variablen.
#
# Notfall-Nachricht der MarianumMobile-App
#
# Diese Datei wird bei jedem App-Start geladen.
# Solange 'active' nicht true ist, wird NICHTS angezeigt (Normalzustand).
#
# Steuerfelder:
# active: true schaltet die Anzeige ein (Default: false)
# dismissible: true = wegklickbar & gecachte Inhalte der App weiterhin normal sichtbar, false = Vollbild & nicht schließbar (Default: true)
# title: optionale Überschrift
#
# Im Notfall: 'active: false' auf 'active: true' setzen, unten Inhalt anpassen.
# Der Textinhalt wird in Markdown ausgewertet. Siehe https://markdownlivepreview.com/
# VORSICHT: Hashtags (#) sind in Markdown kein Kommentar sondern "Titel"!
# Beispielkonfiguration:
#
# ---
# # Ein Kommentar
# active: true
# dismissible: false
# title: Wichtiger Hinweis
# ---
# Hinweistext in Markdown
#
# ============================================================================
active: false
dismissible: true
title: Hinweis
---
# Serverstörung
Der Zugriff auf einige Funktionen ist derzeit großflächig **eingeschränkt**. Wir arbeiten an einer Lösung.
Bitte prüfe unter folgendem Link auf aktuelle Informationen der Schulleitung.
- Aktuelle Informationen: [www.marianum-fulda.de](https://www.marianum-fulda.de)
+215
View File
@@ -0,0 +1,215 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:marianum_mobile/app.dart';
import 'package:marianum_mobile/main.dart' as app;
import 'package:marianum_mobile/routing/app_routes.dart';
import 'package:marianum_mobile/state/app/modules/app_modules.dart';
import 'package:marianum_mobile/state/app/modules/settings/bloc/settings_cubit.dart';
import 'package:marianum_mobile/view/pages/talk/widgets/chat_tile.dart';
/// Marketing-Screenshot-Lauf für die Stores. Loggt sich über den Demo-Login
/// (`demo@…`, siehe DemoMode) ein und nimmt einen kuratierten Satz Screens auf:
/// Stundenplan (hell + dunkel), Talk-Liste, Talk-Chat, Dateien, „Mehr"-Bereich
/// und Einstellungen. Ausgeführt über `flutter drive` mit
/// test_driver/integration_test.dart, orchestriert von tool/screenshots.sh
/// (Phone / 7″ / 10″).
///
/// Kein Netzwerk: der Demo-Modus beantwortet allen Backend-Verkehr aus
/// Fixtures, daher ist der Lauf deterministisch.
Future<void> main() async {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('Store-Screenshots über den Demo-Login', (tester) async {
// Der integration_test-Harness installiert eigene Error-Handler. app()
// überschreibt sie in main() durch die produktiven (ClientErrorReporter),
// was die End-of-Test-Buchführung des Harness sprengt (_pendingExceptionDetails).
// Daher vorher sichern und nach main() zurücksetzen.
final harnessFlutterOnError = FlutterError.onError;
final harnessDispatcherOnError = PlatformDispatcher.instance.onError;
_log('main() starten');
await app.main();
FlutterError.onError = harnessFlutterOnError;
PlatformDispatcher.instance.onError = harnessDispatcherOnError;
await tester.pump(const Duration(seconds: 1));
_log('Login');
await _login(tester);
_log('warte auf App-Shell');
await _pumpUntil(
tester,
find.byType(App),
timeout: const Duration(seconds: 20),
);
_log('warte auf Post-Login-Splash-Ende');
await _pumpUntilGone(
tester,
find.byKey(const ValueKey('post-login-splash')),
timeout: const Duration(seconds: 12),
);
await tester.pump(const Duration(seconds: 1));
// Screenshots auf Android brauchen eine einmalige Umwandlung der
// Flutter-Surface in eine lesbare Image-Texture. Erst hier nach Login und
// Splash , damit der sichtbare Screen erst kurz vor der Aufnahme „einfriert".
if (Platform.isAndroid) {
_log('convertFlutterSurfaceToImage');
await binding.convertFlutterSurfaceToImage();
}
// 1 + 2: Stundenplan in beiden Themes.
await _goTo(tester, Modules.timetable);
_setTheme(tester, ThemeMode.light);
await _capture(tester, binding, '1_stundenplan_hell');
_setTheme(tester, ThemeMode.dark);
await _capture(tester, binding, '2_stundenplan_dunkel');
// Restlicher Satz einheitlich im hellen Theme.
_setTheme(tester, ThemeMode.light);
// 3: Talk-Liste.
await _goTo(tester, Modules.talk);
await _capture(tester, binding, '3_talk_liste');
// 4: Talk-Chat (ersten Chat der Liste öffnen).
await tester.tap(find.byType(ChatTile).first);
await _capture(tester, binding, '4_talk_chat');
_popPushedPage(tester);
await tester.pump(const Duration(milliseconds: 500));
// 5: Dateien.
await _goTo(tester, Modules.files);
await _capture(tester, binding, '5_dateien');
// 6: „Mehr"-Bereich (letzter Tab, hinter den Modul-Tabs).
_goToMore(tester);
await _capture(tester, binding, '6_mehr');
// 7: Einstellungen (Vollbild-Push).
AppRoutes.openSettings(tester.element(find.byType(App)));
await _capture(tester, binding, '7_einstellungen');
_popPushedPage(tester);
_log('fertig');
});
}
void _log(String message) => debugPrint('SHOTS: $message');
Future<void> _login(WidgetTester tester) async {
final loginVisible = await _pumpUntil(
tester,
find.byKey(const Key('login-username-field')),
);
if (!loginVisible) {
_log('kein Login-Screen sichtbar bereits angemeldet, überspringe Login');
return;
}
await tester.enterText(
find.byKey(const Key('login-username-field')),
'demo@screenshots',
);
await tester.enterText(find.byKey(const Key('login-password-field')), 'demo');
await tester.pump();
await tester.tap(find.byKey(const Key('login-submit-button')));
await tester.pump(const Duration(milliseconds: 500));
}
/// Setzt das App-Theme über den [SettingsCubit] (MaterialApp folgt
/// `settings.appTheme`). `val(write: true)` plant den Emit als Microtask.
void _setTheme(WidgetTester tester, ThemeMode mode) {
final context = tester.element(find.byType(App));
context.read<SettingsCubit>().val(write: true).appTheme = mode;
}
/// 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.
Future<void> _goTo(WidgetTester tester, Modules module) async {
final appFinder = find.byType(App);
if (appFinder.evaluate().isEmpty) {
_log('WARN: App-Shell fehlt, kann $module nicht ansteuern');
return;
}
final context = tester.element(appFinder);
if (!AppRoutes.goToTab(context, module)) {
final resolved = AppModule.modules(context)[module];
if (resolved != null) AppRoutes.openModule(context, resolved);
}
}
/// Springt auf den „Mehr"-Tab. Der liegt hinter den Modul-Tabs, sein Index ist
/// also die Anzahl der Bottom-Bar-Module.
void _goToMore(WidgetTester tester) {
final context = tester.element(find.byType(App));
final moreIndex = AppModule.getBottomBarModules(context).length;
app.Main.bottomNavigator.jumpToTab(moreIndex);
}
/// Schließt einen per [AppRoutes] gepushten Vollbild-Screen wieder.
void _popPushedPage(WidgetTester tester) {
final navigator = AppRoutes.rootNavigatorKey.currentState;
if (navigator != null && navigator.canPop()) navigator.pop();
}
/// Lässt die Oberfläche zur Ruhe kommen und legt den Screenshot ab.
Future<void> _capture(
WidgetTester tester,
IntegrationTestWidgetsFlutterBinding binding,
String name,
) async {
// Feste Setzzeit statt „warte auf Spinner-Ende": die Demo-Fixtures kommen
// synchron (keine künstlichen Delays), aber Module wie Dateien/Chat laden erst
// beim Navigieren nach. Eine globale CircularProgressIndicator-Prüfung taugt
// nicht, weil die PersistentTabView alle Tabs am Leben hält und
// Avatar-Platzhalter dauerhaft einen Spinner zeigen. In 500ms-Schritten
// pumpen, damit Timer/Microtasks (u.a. der Theme-Emit) durchlaufen.
await _settle(tester, const Duration(seconds: 6));
_log('-> $name: takeScreenshot');
await binding.takeScreenshot(name);
}
/// Pumpt [duration] in 500ms-Schritten ab, damit die App async Fixtures laden
/// und ihre Frames rendern kann (Ersatz für pumpAndSettle, das an den
/// Dauer-Animationen hängen bleibt).
Future<void> _settle(WidgetTester tester, Duration duration) async {
final steps = duration.inMilliseconds ~/ 500;
for (var i = 0; i < steps; i++) {
await tester.pump(const Duration(milliseconds: 500));
}
}
/// Pumpt in kurzen Schritten, bis [finder] erscheint oder [timeout] abläuft.
/// Gibt zurück, ob [finder] gefunden wurde.
Future<bool> _pumpUntil(
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 10),
}) async {
final end = DateTime.now().add(timeout);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 200));
if (finder.evaluate().isNotEmpty) return true;
}
return false;
}
/// Gegenstück zu [_pumpUntil]: pumpt, bis [finder] nichts mehr trifft.
Future<bool> _pumpUntilGone(
WidgetTester tester,
Finder finder, {
Duration timeout = const Duration(seconds: 10),
}) async {
final end = DateTime.now().add(timeout);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 200));
if (finder.evaluate().isEmpty) return true;
}
return false;
}
@@ -0,0 +1 @@
#include "Generated.xcconfig"
@@ -0,0 +1 @@
#include "Generated.xcconfig"
@@ -0,0 +1 @@
#include "Generated.xcconfig"
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>NotificationServiceExtension</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,256 @@
import UserNotifications
import Security
/// Notification Service Extension for Nextcloud push-v2.
///
/// Architecture: Nextcloud pushes an E2E-encrypted notification to the
/// MarianumConnect proxy, which forwards it via FCM to this device as an
/// `alert` push with `mutable-content: 1`. That flag makes iOS spin up this
/// extension *before* showing the notification, giving us ~30 s (wall clock,
/// shared with the CPU budget) to decrypt the payload and replace the
/// placeholder alert with the real content.
///
/// The FCM proxy places the two relevant fields at the top level of the APNs
/// payload (mirrored into `request.content.userInfo`):
/// - `subject` base64( RSA-encrypted subject JSON ) encrypted with THIS
/// device's public key, so we decrypt with our private key.
/// - `signature` base64( SHA-512-with-RSA over the *encrypted* subject bytes )
/// signed by the per-user server key we stored at registration.
///
/// Key material lives in the shared (App Group) keychain, written by the Dart
/// side via flutter_secure_storage with
/// `IOSOptions(groupId: kPushKeychainGroup, accessibility: first_unlock)`:
/// - `push_device_private_key_pem` crypton PKCS#8-in-"RSA PRIVATE KEY" PEM
/// - `push_server_public_key_pem` SPKI "PUBLIC KEY" PEM (from NC)
///
/// Robustness contract: on ANY failure (missing keys, bad signature, decrypt
/// failure, malformed JSON, timeout) we deliver the untouched placeholder
/// content. We never crash and never deliver empty content.
class NotificationService: UNNotificationServiceExtension {
/// Must exactly match `kPushKeychainGroup` in lib/push/push_secure_storage.dart
/// and the `keychain-access-groups` entitlement of BOTH the Runner and this
/// extension. Wrong value here => keychain reads return nil => placeholder.
private static let keychainAccessGroup = "MY55VF3KPG.eu.mhsl.marianum.mobile.client.push"
private static let devicePrivateKeyAccount = "push_device_private_key_pem"
private static let serverPublicKeyAccount = "push_server_public_key_pem"
private static let talkCategoryId = "TALK_MESSAGE"
private var contentHandler: ((UNNotificationContent) -> Void)?
private var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
self.bestAttemptContent =
request.content.mutableCopy() as? UNMutableNotificationContent
guard let content = bestAttemptContent else {
contentHandler(request.content)
return
}
// Any early return delivers the placeholder unchanged that is the
// intended fallback, never an error path that hides the notification.
let userInfo = content.userInfo
guard
let subjectB64 = userInfo["subject"] as? String,
let signatureB64 = userInfo["signature"] as? String,
let encrypted = Data(base64Encoded: subjectB64),
let signature = Data(base64Encoded: signatureB64)
else {
deliver(content)
return
}
guard let privateKey = loadPrivateKey() else {
NSLog("[NSE] no device private key in keychain, delivering placeholder")
deliver(content)
return
}
// Signature verification is defence-in-depth (the proxy already verified
// it). Skip only if we somehow have no server key; never deliver on a
// *failed* verification.
if let serverKey = loadServerPublicKey() {
if !verifySignature(signature, over: encrypted, with: serverKey) {
NSLog("[NSE] signature verification failed, delivering placeholder")
deliver(content)
return
}
}
guard
let plaintext = decrypt(encrypted, with: privateKey),
let subject = try? JSONSerialization.jsonObject(with: plaintext) as? [String: Any]
else {
NSLog("[NSE] could not decrypt/parse subject, delivering placeholder")
deliver(content)
return
}
apply(subject: subject, to: content)
deliver(content)
}
override func serviceExtensionTimeWillExpire() {
// Ran out of time hand back whatever we have (at worst the placeholder).
if let handler = contentHandler, let content = bestAttemptContent {
handler(content)
}
}
private func deliver(_ content: UNNotificationContent) {
contentHandler?(content)
}
// MARK: - Content shaping
private func apply(subject: [String: Any], to content: UNMutableNotificationContent) {
// Delete pushes normally arrive as silent `background` pushes that never
// invoke this extension. If one reaches us anyway, an NSE cannot suppress
// the banner (iOS always shows *something*), so we leave the placeholder.
// Actual delete cleanup is done by the Flutter app on next open. See
// ios/PUSH_NSE_SETUP.md "Delete-Handling".
let isDelete = (subject["delete"] as? Bool == true)
|| (subject["delete-multiple"] as? Bool == true)
|| (subject["delete-all"] as? Bool == true)
if isDelete {
NSLog("[NSE] delete push reached NSE — cannot suppress, leaving placeholder")
return
}
let app = subject["app"] as? String
let text = subject["subject"] as? String ?? "Neue Benachrichtigung"
let objectId = stringValue(subject["id"])
let nid = subject["nid"]
if app == "spreed" {
// Talk: "Sender: message" -> title = Sender, body = message.
let (sender, message) = splitSender(text)
content.title = sender
content.body = message
content.categoryIdentifier = NotificationService.talkCategoryId
if let token = objectId, !token.isEmpty {
content.threadIdentifier = "talk_\(token)"
var info = content.userInfo
info["chatToken"] = token
if let nid = nid { info["nid"] = nid }
content.userInfo = info
}
} else {
content.title = text
content.body = ""
}
// Badge intentionally left untouched (the app recomputes it from the
// delivered notifications).
}
/// Splits a Talk subject into header and message. nextcloud/spreed's
/// Notifier separates them with a NEWLINE `"{user}\n{message}"` for 1:1
/// chats, `"{user} in {call}\n{message}"` for groups (verified against
/// spreed; mirrors parseTalkSubject in lib/push/push_subject.dart). The
/// legacy `": "` form is kept as fallback for older payloads.
private func splitSender(_ subject: String) -> (String, String) {
if let range = subject.range(of: "\n") {
let sender = String(subject[..<range.lowerBound])
.trimmingCharacters(in: .whitespaces)
let message = String(subject[range.upperBound...])
if !sender.isEmpty && !message.isEmpty {
return (sender, message)
}
}
if let range = subject.range(of: ": ") {
let sender = String(subject[..<range.lowerBound])
let message = String(subject[range.upperBound...])
if !sender.isEmpty && !message.isEmpty {
return (sender, message)
}
}
return ("Talk", subject)
}
private func stringValue(_ value: Any?) -> String? {
if let s = value as? String { return s }
if let n = value as? NSNumber { return n.stringValue }
return nil
}
// MARK: - Crypto
private func verifySignature(_ signature: Data, over message: Data, with key: SecKey) -> Bool {
var error: Unmanaged<CFError>?
let ok = SecKeyVerifySignature(
key,
.rsaSignatureMessagePKCS1v15SHA512,
message as CFData,
signature as CFData,
&error
)
return ok
}
private func decrypt(_ data: Data, with key: SecKey) -> Data? {
// NC 32 defaults to OAEP(SHA-1); older instances use PKCS#1 v1.5. Try
// OAEP first, fall back to PKCS#1 mirrors the Dart PushDecryptor.
for algorithm in [SecKeyAlgorithm.rsaEncryptionOAEPSHA1,
SecKeyAlgorithm.rsaEncryptionPKCS1] {
var error: Unmanaged<CFError>?
if let plain = SecKeyCreateDecryptedData(key, algorithm, data as CFData, &error) as Data? {
return plain
}
}
return nil
}
// MARK: - Key loading
private func loadPrivateKey() -> SecKey? {
guard let pem = keychainString(NotificationService.devicePrivateKeyAccount),
let der = PEM.der(fromPem: pem),
// crypton labels it "RSA PRIVATE KEY" but the body is PKCS#8.
let pkcs1 = PEM.pkcs1PrivateKey(fromPkcs8: der)
else { return nil }
return PEM.rsaKey(pkcs1: pkcs1, isPrivate: true)
}
private func loadServerPublicKey() -> SecKey? {
guard let pem = keychainString(NotificationService.serverPublicKeyAccount),
let der = PEM.der(fromPem: pem),
let pkcs1 = PEM.pkcs1PublicKey(fromSpki: der)
else { return nil }
return PEM.rsaKey(pkcs1: pkcs1, isPrivate: false)
}
// MARK: - Keychain
/// Reads a flutter_secure_storage entry from the shared keychain. Must match
/// exactly how flutter_secure_storage_darwin 0.3.2 stores items:
/// kSecClass = kSecClassGenericPassword
/// kSecAttrAccount = the Dart key, verbatim
/// kSecAttrService = (unset the Dart IOSOptions set no accountName)
/// kSecAttrAccessGroup = the team-prefixed shared keychain group
/// value = raw UTF-8 bytes of the string
private func keychainString(_ account: String) -> String? {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: account,
kSecAttrAccessGroup: NotificationService.keychainAccessGroup,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne,
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
if status != errSecItemNotFound {
NSLog("[NSE] keychain read '\(account)' failed: \(status)")
}
return nil
}
return String(data: data, encoding: .utf8)
}
}
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push</string>
</array>
</dict>
</plist>
+105
View File
@@ -0,0 +1,105 @@
import Foundation
import Security
/// Minimal PEM/DER helpers to turn the crypton-produced PEM strings into
/// `SecKey`s. iOS's `SecKeyCreateWithData` only accepts *raw PKCS#1* DER for
/// RSA, so we must strip the two wrapper formats crypton/Nextcloud use:
///
/// - Private key: crypton's `toPEM()` emits the `RSA PRIVATE KEY` label but the
/// DER body is actually **PKCS#8** `PrivateKeyInfo`
/// (SEQUENCE { INTEGER version, SEQUENCE algId, OCTET STRING pkcs1 }).
/// We extract the OCTET STRING content = the PKCS#1 `RSAPrivateKey`.
///
/// - Public key: `PUBLIC KEY` label, **SPKI** `SubjectPublicKeyInfo`
/// (SEQUENCE { SEQUENCE algId, BIT STRING spki }). We extract the BIT STRING
/// content and drop its leading 0x00 "unused bits" byte = the PKCS#1
/// `RSAPublicKey`.
///
/// Only DER lengths up to 4 bytes are handled more than enough for RSA-2048.
enum PEM {
/// Strips the PEM armor and returns the base64-decoded DER.
static func der(fromPem pem: String) -> Data? {
let body = pem
.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
.filter { !$0.hasPrefix("-----") }
.joined()
return Data(base64Encoded: body)
}
/// PKCS#8 `PrivateKeyInfo` -> inner PKCS#1 `RSAPrivateKey`.
static func pkcs1PrivateKey(fromPkcs8 der: Data) -> Data? {
var reader = DERReader(der)
guard let outer = reader.readTLV(), outer.tag == 0x30 else { return nil }
var inner = DERReader(Data(outer.value))
guard let version = inner.readTLV(), version.tag == 0x02 else { return nil } // INTEGER version
guard let algId = inner.readTLV(), algId.tag == 0x30 else { return nil } // SEQUENCE algId
guard let octet = inner.readTLV(), octet.tag == 0x04 else { return nil } // OCTET STRING
return Data(octet.value)
}
/// SPKI `SubjectPublicKeyInfo` -> inner PKCS#1 `RSAPublicKey`.
static func pkcs1PublicKey(fromSpki der: Data) -> Data? {
var reader = DERReader(der)
guard let outer = reader.readTLV(), outer.tag == 0x30 else { return nil }
var inner = DERReader(Data(outer.value))
guard let algId = inner.readTLV(), algId.tag == 0x30 else { return nil } // SEQUENCE algId
guard let bitString = inner.readTLV(), bitString.tag == 0x03 else { return nil } // BIT STRING
var bytes = Array(bitString.value)
guard let first = bytes.first, first == 0x00 else { return nil } // unused-bits count
bytes.removeFirst()
return Data(bytes)
}
/// Builds an RSA `SecKey` from raw PKCS#1 DER.
static func rsaKey(pkcs1 der: Data, isPrivate: Bool) -> SecKey? {
let attributes: [CFString: Any] = [
kSecAttrKeyType: kSecAttrKeyTypeRSA,
kSecAttrKeyClass: isPrivate ? kSecAttrKeyClassPrivate : kSecAttrKeyClassPublic,
kSecAttrKeySizeInBits: 2048,
]
var error: Unmanaged<CFError>?
let key = SecKeyCreateWithData(der as CFData, attributes as CFDictionary, &error)
if key == nil {
NSLog("[NSE] SecKeyCreateWithData failed: \(String(describing: error?.takeRetainedValue()))")
}
return key
}
}
/// Tiny non-recursive DER TLV reader over a byte buffer.
private struct DERReader {
private let bytes: [UInt8]
private var pos = 0
init(_ data: Data) { bytes = [UInt8](data) }
/// Reads one tag-length-value triple, advancing past the value. Returns the
/// tag byte and the value bytes, or nil on a malformed/truncated buffer.
mutating func readTLV() -> (tag: UInt8, value: ArraySlice<UInt8>)? {
guard pos < bytes.count else { return nil }
let tag = bytes[pos]
pos += 1
guard pos < bytes.count else { return nil }
var length = Int(bytes[pos])
pos += 1
if length & 0x80 != 0 {
let numLengthBytes = length & 0x7F
guard numLengthBytes > 0, numLengthBytes <= 4, pos + numLengthBytes <= bytes.count else {
return nil
}
length = 0
for _ in 0..<numLengthBytes {
length = (length << 8) | Int(bytes[pos])
pos += 1
}
}
guard pos + length <= bytes.count else { return nil }
let value = bytes[pos..<pos + length]
pos += length
return (tag, value)
}
}
+312
View File
@@ -0,0 +1,312 @@
# iOS Notification Service Extension (NSE) — Setup & Übergabe
> **Für wen:** Eine spätere (kontextlose) Claude-Session **und** den Menschen, der
> auf einem Mac mit Xcode weiterbaut. Dieses Dokument ist die einzige Quelle der
> Wahrheit für die iOS-Push-Fertigstellung. Die Swift-/Dart-Dateien existieren
> bereits (auf Linux textuell erstellt, **nie kompiliert**). Was fehlt, ist die
> reine Xcode-Verdrahtung (Target anlegen, Capabilities, Signing) — das geht
> **nur** auf dem Mac.
---
## 1. Architektur-Kurzfassung
```
Nextcloud (cloud.marianum-fulda.de)
│ push-v2: E2E-verschlüsselte Notification (mit Device-Public-Key verschlüsselt)
MarianumConnect Push-Proxy (connect.marianum-fulda.de/push-proxy/notifications)
│ verifiziert Signatur, leitet per FCM HTTP v1 weiter
FCM → APNs → iOS-Gerät
│ alert-Push mit `mutable-content: 1`, `subject` + `signature` top-level im Payload
NotificationServiceExtension (DIESES Target)
│ liest Device-Private-Key + Server-Public-Key aus dem geteilten Keychain
│ verifiziert Signatur → entschlüsselt subject → setzt Titel/Body/Category
iOS zeigt die fertige Notification
```
- **Verschlüsselung:** OAEP-SHA1 (NC-32-Default) mit Fallback PKCS#1 v1.5.
- **Signatur:** `SHA512withRSA` über die **base64-dekodierten (verschlüsselten)**
subject-Bytes, geprüft mit dem per-User-Server-Public-Key aus der Registrierung.
- **Dart-Referenz:** `lib/push/` — insb. `push_decryptor.dart` (Krypto-Spiegel),
`push_secure_storage.dart` (Keychain-Optionen), `push_keypair.dart` (Keyformat),
`push_registration_store.dart` (was group-scoped gespeichert wird),
`push_registration.dart` (`_persistNativeAuthContext`).
- **Backend-Referenz:** MarianumConnect-Service `marMobileApi`, Endpunkte
`push-proxy/notifications` (NC→Proxy) und `PUT/DELETE me/push-device`.
- **Talk-Actions (Antworten / Als gelesen markieren):** werden **nativ** im
`AppDelegate` per `URLSession` an die Talk-OCS-API geschickt, weil bei einer
Notification-Action nicht garantiert ist, dass die Flutter-Engine läuft.
---
## 2. Dateiinventar
| Datei | Status | Anmerkung |
|-------|--------|-----------|
| `ios/NotificationServiceExtension/NotificationService.swift` | **existiert** | NSE-Hauptklasse (entschlüsseln + Content setzen) |
| `ios/NotificationServiceExtension/PEM.swift` | **existiert** | PEM/DER→`SecKey` (PKCS#8/SPKI-Stripping) |
| `ios/NotificationServiceExtension/Info.plist` | **existiert** | `NSExtensionPointIdentifier = com.apple.usernotifications.service` |
| `ios/NotificationServiceExtension/NotificationServiceExtension.entitlements` | **existiert** | App Group + keychain-access-groups |
| `ios/Runner/Runner.entitlements` | **geändert** | `keychain-access-groups` (widget-Group) ergänzt |
| `ios/Runner/AppDelegate.swift` | **geändert** | TALK_MESSAGE-Category + native Action-Behandlung |
| `lib/push/push_registration_store.dart` | **geändert** | schreibt `nextcloud_username` + `nextcloud_base_url` group-scoped |
| `lib/push/push_registration.dart` | **geändert** | `_persistNativeAuthContext()` bei `register()` |
| **Xcode-Target „NotificationServiceExtension"** | **existiert** | programmatisch via `xcodeproj`-Gem angelegt (2026-07-07), gespiegelt an der Share-Extension |
| `ios/Runner.xcodeproj/project.pbxproj` | **geändert** | NSE-Target, Dependency + „Embed Foundation Extensions" ergänzt |
| `ios/Flutter/NotificationServiceExtension-{Debug,Release,Profile}.xcconfig` | **neu** | Base-Configs, inkludieren `Generated.xcconfig` (Flutter-Versionsvariablen) |
> **Wichtig:** Die vier Dateien unter `ios/NotificationServiceExtension/` liegen
> schon auf der Platte. Beim Anlegen des Targets erzeugt Xcode eigene
> Platzhalter-Dateien — **diese löschen und die vorhandenen Dateien zuordnen**
> (siehe 3.2), sonst überschreibst du die fertige Implementierung.
---
## 3. Xcode-Checkliste (auf dem Mac)
> **Stand 2026-07-07:** Abschnitte 3.13.2 (Target anlegen, Dateien zuordnen) sind
> bereits **programmatisch** erledigt (via `xcodeproj`-Gem). Der unsignierte Build
> aller Targets läuft durch (`flutter build ios --no-codesign`), die
> `NotificationServiceExtension.appex` wird korrekt in `Runner.app/PlugIns/`
> eingebettet. **Offen bleiben nur noch Signing/Capabilities (3.33.4, 3.6)** —
> die brauchen den Apple-Developer-Account und einen signierten Build/Archive.
> Die 3.1/3.2-Anleitung unten bleibt als Referenz stehen (falls das Target mal neu
> aufgesetzt werden muss).
### 3.1 Target anlegen
1. `ios/Runner.xcworkspace` in Xcode öffnen (nicht `.xcodeproj`).
2. **File → New → Target… → iOS → Notification Service Extension**.
3. **Product Name exakt: `NotificationServiceExtension`** (der
`NSExtensionPrincipalClass` in der Info.plist ist
`$(PRODUCT_MODULE_NAME).NotificationService` — bei abweichendem Namen zerbricht
das). Language: Swift. „Embed in Application": Runner.
4. Beim Dialog „Activate scheme?" → Activate.
5. **Bundle Identifier:** `eu.mhsl.marianum.mobile.client.NotificationServiceExtension`
(Muster wie bei den bestehenden Extensions).
6. **Deployment Target = 15.0** (identisch zum Runner; im `post_install` des
Podfiles wird ohnehin alles auf 15.0 gezwungen).
### 3.2 Generierte Dateien durch die vorhandenen ersetzen
- Xcode legt eine eigene `NotificationService.swift` und `Info.plist` im
Target-Ordner an. **Beide aus dem Projekt entfernen** („Move to Trash" für die
frisch generierten) und stattdessen die bereits vorhandenen Dateien hinzufügen:
- `NotificationService.swift`, `PEM.swift`**Target Membership: nur NSE**.
- `Info.plist` → in den **Build Settings** des NSE-Targets als
`INFOPLIST_FILE = NotificationServiceExtension/Info.plist` setzen (bzw. die
vorhandene Datei als Info.plist des Targets referenzieren).
- `NotificationServiceExtension.entitlements` → in Build Settings
`CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements`.
### 3.3 Capabilities (in **beiden** Targets: Runner **und** NSE)
- **App Groups:** `group.eu.mhsl.marianum.mobile.client.widget` aktivieren.
(Beim Runner ist die Group bereits vorhanden; beim NSE neu hinzufügen.)
- **Keychain Sharing:** Keychain-Group `group.eu.mhsl.marianum.mobile.client.widget`
hinzufügen. Die Entitlement-Dateien enthalten das schon als
`keychain-access-groups`; über die Capabilities-UI stellst du sicher, dass das
Provisioning-Profil es abdeckt.
- Prüfen, dass die Capabilities-UI **keine** doppelten/abweichenden Einträge
erzeugt hat (Xcode schreibt gern in die `.entitlements`; danach Abschnitt 4
gegenchecken).
### 3.4 Signing
- Beide Targets: **Team** = dasselbe wie Runner. Automatic Signing an, oder die
passenden Profile wählen. Der App-Identifier-Prefix (Team-ID) muss identisch
sein, sonst greift die Keychain-Group nicht.
### 3.5 Pods / Podfile
- Die NSE ist **komplett selbst-enthalten** (`Foundation`, `Security`,
`UserNotifications` — alles System-Frameworks). **Kein** Pod-Target nötig.
- Prüfen, ob irgendein Flutter-Plugin explizit ein NSE-Target im Podfile verlangt
(aktuell **nein**`firebase_messaging` braucht keins). Falls doch mal nötig:
im Podfile analog zur `Share Extension` einen `target
'NotificationServiceExtension' do inherit! :search_paths end`-Block ergänzen,
danach `pod install`.
- Nach dem Anlegen einmal `cd ios && pod install` laufen lassen (aktualisiert die
Workspace-Referenzen).
### 3.6 aps-environment (Production!)
- `ios/Runner/Runner.entitlements` hat aktuell `aps-environment = development`.
**Für TestFlight/Release muss `production` aktiv sein.** Das wurde hier bewusst
**nicht** hart umgestellt, um den Debug-Flow nicht zu brechen.
- Empfehlung: über die **Capabilities → Push Notifications** und die
Build-Konfiguration steuern (Xcode setzt beim Archive automatisch `production`,
wenn das Profil ein Distribution-Profil ist). Beim Archive-Export kontrollieren,
dass in der finalen `.ipa` `aps-environment = production` steht (siehe 5.2).
---
## 4. Ermittelte Keychain-Details (verbindlich)
Die Dart-Seite schreibt mit
`IOSOptions(groupId: 'MY55VF3KPG.eu.mhsl.marianum.mobile.client.push', accessibility: first_unlock)`.
> **Wichtig (Stand 2026-07-07):** Als Keychain-Access-Group wird **nicht** mehr die
> App-Group (`group.*`) genutzt, sondern eine **team-prefixed** Group
> (`$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push`). Grund: Die
> Xcode-verwalteten Provisioning-Profile gewähren als `keychain-access-groups`
> nur `<TeamID>.*` — eine `group.*`-App-Group fällt da **nicht** drunter, was
> Automatic Signing mit „doesn't match the entitlements file's value for the
> keychain-access-groups entitlement" abbricht. Runner und NSE teilen die Group,
> weil sie mit demselben Team (`MY55VF3KPG`) signieren. Der `MY55VF3KPG.`-Prefix
> ist der stabile AppIdentifierPrefix und in Dart/Swift hart hinterlegt.
Aus dem Quellcode von **`flutter_secure_storage_darwin` 0.3.2** (gepinnt in
`pubspec.lock`) ergibt sich die exakte Ablage im Keychain:
| Keychain-Attribut | Wert |
|-------------------|------|
| `kSecClass` | `kSecClassGenericPassword` |
| `kSecAttrAccount` | der Dart-**Key**, **wortwörtlich** (kein Hash, kein Prefix) |
| `kSecAttrService` | **nicht gesetzt** (die `IOSOptions` setzen kein `accountName`) |
| `kSecAttrAccessGroup` | `MY55VF3KPG.eu.mhsl.marianum.mobile.client.push` (team-prefixed) |
| `kSecAttrAccessible` | `kSecAttrAccessibleAfterFirstUnlock` (aus `first_unlock`) |
| Wert (`kSecValueData`) | **rohe UTF-8-Bytes** des Strings (PEM/Passwort im Klartext) |
**Deshalb** fragt die Swift-Seite (`keychainString(...)` in NSE **und**
AppDelegate) exakt so ab: `GenericPassword` + `kSecAttrAccount = <key>` +
`kSecAttrAccessGroup` + `kSecMatchLimit=One` + `kSecReturnData=true`, **ohne**
`kSecAttrService`. (Ein gesetztes `kSecAttrService` würde nicht matchen.)
### Konkrete Einträge (Account-Namen)
| Account (`kSecAttrAccount`) | Inhalt | Geschrieben von | Genutzt von |
|-----------------------------|--------|-----------------|-------------|
| `push_device_private_key_pem` | Device-**Private**-Key, PEM | `push_keypair.dart` | **NSE** (entschlüsseln) |
| `push_device_public_key_pem` | Device-Public-Key, SPKI-PEM | `push_keypair.dart` | — (nur Registrierung) |
| `push_server_public_key_pem` | **Server**-Public-Key (per User), SPKI-PEM | `push_registration_store.dart` | **NSE** (Signatur prüfen) |
| `push_device_identifier` | NC Device-Identifier | `push_registration_store.dart` | Dart |
| `push_registered_fcm_token` | FCM-Token der Registrierung | `push_registration_store.dart` | Dart |
| `nextcloud_app_password` | NC App-Password | `account_data.dart` | **AppDelegate** (Basic-Auth) |
| `nextcloud_username` | NC Username | `push_registration_store.dart` (**neu**) | **AppDelegate** (Basic-Auth) |
| `nextcloud_base_url` | z.B. `https://cloud.marianum-fulda.de` | `push_registration_store.dart` (**neu**) | **AppDelegate** (OCS-URL) |
> `username` und `password` (Realpasswort) liegen in AccountDatas **Default**-Storage
> **ohne** `groupId` → nur im primären Access-Group der App, **für die NSE
> unsichtbar**. Deshalb werden `nextcloud_username` + `nextcloud_base_url` bei
> jeder `register()`-Runde zusätzlich **group-scoped** geschrieben. Das
> App-Password lag schon immer group-scoped.
### Schlüsselformate (kritisch für das PEM-Parsing)
`crypton` (2.2.1) erzeugt die Keys:
- **Private Key:** Label `-----BEGIN RSA PRIVATE KEY-----`, der DER-Body ist aber
**PKCS#8** `PrivateKeyInfo` (SEQ{ INTEGER version, SEQ algId, OCTET STRING
pkcs1 }) — **nicht** rohes PKCS#1! `SecKeyCreateWithData` will bei RSA aber
**rohes PKCS#1**. `PEM.pkcs1PrivateKey(fromPkcs8:)` strippt den PKCS#8-Wrapper
(extrahiert den OCTET-STRING-Inhalt).
- **Public Key** (Device **und** der von NC gelieferte Server-Key): Label
`-----BEGIN PUBLIC KEY-----`, DER = **SPKI** `SubjectPublicKeyInfo`.
`PEM.pkcs1PublicKey(fromSpki:)` extrahiert den BIT-STRING-Inhalt und wirft das
führende `0x00`-Byte (unused bits) weg → PKCS#1 `RSAPublicKey`.
---
## 5. Verifikation auf dem Mac
### 5.1 Build
- `flutter clean && flutter pub get`
- `cd ios && pod install`
- In Xcode: **beide** Targets bauen. NSE-Target-Scheme separat bauen, um Swift-
Fehler früh zu sehen.
- Auf echtem Gerät installieren (Push funktioniert **nicht** im Simulator).
### 5.2 aps-environment im Archive prüfen
- Product → Archive → Distribute (oder `.ipa` exportieren) → `.ipa` entpacken →
`codesign -d --entitlements :- Payload/Runner.app` → muss
`aps-environment = production` zeigen.
### 5.3 Testbenachrichtigung (schneller Smoke-Test, ohne NC-Krypto)
- In den App-Einstellungen → Benachrichtigungen → **Testbenachrichtigung**
(Backend `POST me/push-device/test`). Erwartung: Alert „Testbenachrichtigung /
Push-Benachrichtigungen funktionieren! 🎉". Testet Registrierung + FCM +
Rendering — die NSE ist hier **nicht** beteiligt (Connect-Push, kein
`mutable-content`).
### 5.4 Echte Talk-Nachricht (End-to-End inkl. NSE)
- Gerät sperren. Von einem Zweitaccount eine Talk-Nachricht schicken.
- **Erwartung:** Banner mit **echtem Absender + Nachrichtentext** (nicht der
Platzhalter „Neue Benachrichtigung / Tippen zum Öffnen"). Long-press →
Actions „Antworten" + „Als gelesen markieren".
- „Antworten" → Text senden → Nachricht erscheint im Chat (nativ via URLSession),
Chat wird als gelesen markiert.
- Alternativ auf der NC-Instanz: `occ notification:test-push --talk <user> -v`.
### 5.5 Woran erkenne ich, ob die NSE lief?
- **NSE lief & OK:** echter Absender/Text im Banner.
- **NSE lief nicht / Fehler:** Platzhalter-Text bleibt stehen. Das ist der
bewusste Fallback (nie leer, nie Crash).
- **Logs:** Über die macOS-**Console.app** das Gerät wählen und nach `[NSE]` bzw.
`[Talk action]` filtern (`NSLog`-Ausgaben). Oder in Xcode: Debug → Attach to
Process → `NotificationServiceExtension`.
---
## 6. Bekannte Fehlerbilder + Fixes
| Symptom | Wahrscheinliche Ursache | Fix |
|---------|-------------------------|-----|
| NSE greift gar nicht (immer Platzhalter, auch bei Talk) | `mutable-content` fehlt / Signing des NSE-Targets kaputt / Bundle-ID falsch | Backend-`FcmMessageFactory` setzt `mutable-content:1` (prüfen). NSE-Target-Signing + Provisioning prüfen. Bundle-ID = `…client.NotificationServiceExtension`. |
| Banner zeigt Platzhalter trotz Talk | PEM-Parsing/Padding schlägt fehl | Console.app nach `[NSE] SecKeyCreateWithData failed` / `could not decrypt`. Prüfen, dass Private-Key wirklich als PKCS#8-in-„RSA PRIVATE KEY" ankommt (crypton). OAEP↔PKCS1-Fallback ist schon drin. |
| `[NSE] no device private key in keychain` | Keychain-Group greift nicht | In **beiden** Targets Keychain Sharing + App Group aktiv? Team-ID identisch? Access-Group-String exakt `group.eu.mhsl.marianum.mobile.client.widget`? |
| Signatur schlägt immer fehl | Falscher/kein Server-Public-Key | `push_server_public_key_pem` vorhanden? (wird bei `register()` gesetzt). Notfalls: ohne Server-Key überspringt die NSE die Prüfung — bleibt sie hängen, ist der Key da aber falsch geparst. |
| Reply-Action tut nichts | Credentials fehlen / Delegate-Konflikt (s.u.) | Console.app nach `[Talk action]`. `nextcloud_username`/`_app_password`/`_base_url` im Keychain? App einmal neu einloggen (schreibt sie via `register()`). |
| Reply wird **doppelt** gesendet | AppDelegate **und** ein Plugin behandeln dieselbe Action | Der AppDelegate returnt bei TALK_REPLY/TALK_MARK_READ **ohne** `super`-Aufruf, das Plugin sieht die Action also nicht. Falls doch doppelt: prüfen, ob `flutter_local_notifications` auf iOS separat als Delegate registriert ist. |
### ⚠️ Delegate-Ownership (wichtigster Verifikationspunkt)
`FlutterAppDelegate` konformiert (über `FlutterAppLifeCycleProvider`) zu
`UNUserNotificationCenterDelegate` und **leitet** `userNotificationCenter(...)` an
die registrierten Plugins weiter (firebase_messaging,
flutter_local_notifications). Der `AppDelegate` setzt sich in
`didFinishLaunching` als `UNUserNotificationCenter.current().delegate = self`,
**überschreibt** die beiden Delegate-Methoden und ruft für alles **außer** den
zwei Talk-Actions `super` auf — dadurch bleibt das Plugin-Forwarding intakt. Das
ist der fragilste Teil und **muss auf dem Gerät verifiziert werden**:
- Normale Push-Taps müssen weiterhin ins richtige Chat/Ziel navigieren
(läuft über `super` → Plugins).
- Foreground-Darstellung von FCM darf nicht kaputtgehen (`willPresent` wird hier
bewusst **nicht** überschrieben, läuft komplett über `super`/Plugins).
- Sollte ein Plugin den Delegate **nach** `didFinishLaunching` erneut übernehmen
(und damit unsere Talk-Actions abfangen), das Setzen von
`UNUserNotificationCenter.current().delegate = self` an einen späteren Punkt
verschieben (z. B. nach dem ersten Dart-Frame). Ohne Gerät nicht abschließend
testbar.
> **Compile-Hinweis:** `override` an den beiden `userNotificationCenter`-Methoden
> ist korrekt, weil die Methoden über das von `FlutterAppDelegate` adoptierte
> Protokoll `FlutterAppLifeCycleProvider : UNUserNotificationCenterDelegate`
> sichtbar sind (verifiziert im Flutter-3.44-Engine-Header). `super.userNotification…`
> ist daher aufrufbar.
---
## 7. Offene TODOs / bewusste Einschränkungen
1. **Delete-Handling auf iOS (best effort).** Delete-Pushes kommen als silent
`background`-Pushes (`content-available`, **kein** `mutable-content`) → die NSE
läuft dafür **nicht**. Das Wegräumen erledigt der Flutter-Background-Handler
(`PushMessageHandler`) bzw. ein Sweep beim App-Öffnen. Erreicht doch mal ein
Delete-Subject die NSE, **kann** sie die Notification **nicht** unterdrücken
(eine NSE muss immer *irgendeinen* Content liefern) — sie lässt den Platzhalter
stehen und loggt. Kein Fix möglich, dokumentierte iOS-Limitierung.
2. **Rich-Detail via OCS (spätere Erweiterung).** v1 lädt **nichts** nach. Optional
könnte die NSE bei Talk `GET …/ocs/v2.php/apps/notifications/api/v2/notifications/{nid}`
mit dem App-Password abrufen, um Avatar/Rich-Subject anzuzeigen (Timeout < 25 s,
innerhalb des NSE-Budgets). Nicht implementiert.
3. **`aps-environment = production`** ist noch nicht hart gesetzt (Abschnitt 3.6) —
vor dem Release erledigen und im Archive gegenchecken (5.2).
4. **Keychain-Access-Group-Schreibweise (gelöst 2026-07-07).** Die Entitlements
listen `$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push` als
`keychain-access-groups` (team-prefixed, **keine** App-Group). Damit greift das
`<TeamID>.*` der Xcode-Profile und Automatic Signing läuft ohne Portal-Änderung
durch (verifiziert: `flutter build ios --release` signiert Runner **und** NSE
mit `MY55VF3KPG.eu.mhsl.marianum.mobile.client.push`). Der frühere App-Group-
Ansatz (`group.*`) scheiterte an genau diesem Profil-Matching. Falls der
Keychain-Zugriff zur Laufzeit doch scheitert (Status `-34018` /
`errSecMissingEntitlement`), prüfen, dass Dart (`push_secure_storage.dart`) und
Swift (`AppDelegate.swift`, `NotificationService.swift`) **exakt denselben**
vollqualifizierten Group-String verwenden.
+3
View File
@@ -46,5 +46,8 @@ end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
end
end
end
+2 -261
View File
@@ -1,311 +1,52 @@
PODS:
- connectivity_plus (0.0.1):
- Flutter
- device_info_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.9):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
- DKImagePickerController/ImageDataManager (4.3.9)
- DKImagePickerController/PhotoGallery (4.3.9):
- DKImagePickerController/Core
- DKPhotoGallery
- DKImagePickerController/Resource (4.3.9)
- DKPhotoGallery (0.0.19):
- DKPhotoGallery/Core (= 0.0.19)
- DKPhotoGallery/Model (= 0.0.19)
- DKPhotoGallery/Preview (= 0.0.19)
- DKPhotoGallery/Resource (= 0.0.19)
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Core (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Preview
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Model (0.0.19):
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Preview (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Resource
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Resource (0.0.19):
- SDWebImage
- SwiftyGif
- emoji_picker_flutter (0.0.1):
- Flutter
- eraser (0.0.1):
- Flutter
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- Firebase/CoreOnly (12.12.0):
- FirebaseCore (~> 12.12.0)
- Firebase/Messaging (12.12.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 12.12.0)
- firebase_core (4.7.0):
- Firebase/CoreOnly (= 12.12.0)
- Flutter
- firebase_messaging (16.2.0):
- Firebase/Messaging (= 12.12.0)
- firebase_core
- Flutter
- FirebaseCore (12.12.1):
- FirebaseCoreInternal (~> 12.12.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1)
- FirebaseCoreInternal (12.12.0):
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- FirebaseInstallations (12.12.0):
- FirebaseCore (~> 12.12.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- PromisesObjC (~> 2.4)
- FirebaseMessaging (12.12.0):
- FirebaseCore (~> 12.12.0)
- FirebaseInstallations (~> 12.12.0)
- GoogleDataTransport (~> 10.1)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Reachability (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- nanopb (~> 3.30910.0)
- Flutter (1.0.0)
- flutter_app_badge (2.0.0):
- Flutter
- flutter_local_notifications (0.0.1):
- Flutter
- flutter_native_splash (2.4.3):
- Flutter
- flutter_secure_storage_darwin (10.0.0):
- Flutter
- FlutterMacOS
- GoogleDataTransport (10.1.0):
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- GoogleUtilities/AppDelegateSwizzler (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
- GoogleUtilities/Network
- GoogleUtilities/Privacy
- GoogleUtilities/Environment (8.1.0):
- GoogleUtilities/Privacy
- GoogleUtilities/Logger (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/Network (8.1.0):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Privacy
- GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (8.1.0)":
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (8.1.0)
- GoogleUtilities/Reachability (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilities/UserDefaults (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- home_widget (0.0.1):
- Flutter
- image_picker_ios (0.0.1):
- Flutter
- in_app_review (2.0.0):
- Flutter
- nanopb (3.30910.0):
- nanopb/decode (= 3.30910.0)
- nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- open_filex (0.0.2):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- PhoneNumberKit (3.7.11):
- PhoneNumberKit/PhoneNumberKitCore (= 3.7.11)
- PhoneNumberKit/UIKit (= 3.7.11)
- PhoneNumberKit/PhoneNumberKitCore (3.7.11)
- PhoneNumberKit/UIKit (3.7.11):
- PhoneNumberKit/PhoneNumberKitCore
- PromisesObjC (2.4.0)
- receive_sharing_intent (1.8.1):
- Flutter
- screen_brightness_ios (2.1.3):
- Flutter
- SDWebImage (5.21.7):
- SDWebImage/Core (= 5.21.7)
- SDWebImage/Core (5.21.7)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- SwiftyGif (5.4.5)
- syncfusion_flutter_pdfviewer (0.0.1):
- Flutter
- url_launcher_ios (0.0.1):
- Flutter
- video_player_avfoundation (0.0.1):
- Flutter
- FlutterMacOS
- wakelock_plus (0.0.1):
- Flutter
- workmanager_apple (0.0.1):
- Flutter
DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- emoji_picker_flutter (from `.symlinks/plugins/emoji_picker_flutter/ios`)
- eraser (from `.symlinks/plugins/eraser/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
- Flutter (from `Flutter`)
- flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
- home_widget (from `.symlinks/plugins/home_widget/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
- open_filex (from `.symlinks/plugins/open_filex/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- PhoneNumberKit (~> 3.7.6)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- syncfusion_flutter_pdfviewer (from `.symlinks/plugins/syncfusion_flutter_pdfviewer/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
SPEC REPOS:
trunk:
- DKImagePickerController
- DKPhotoGallery
- Firebase
- FirebaseCore
- FirebaseCoreInternal
- FirebaseInstallations
- FirebaseMessaging
- GoogleDataTransport
- GoogleUtilities
- nanopb
- PhoneNumberKit
- PromisesObjC
- SDWebImage
- SwiftyGif
EXTERNAL SOURCES:
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
emoji_picker_flutter:
:path: ".symlinks/plugins/emoji_picker_flutter/ios"
eraser:
:path: ".symlinks/plugins/eraser/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
firebase_core:
:path: ".symlinks/plugins/firebase_core/ios"
firebase_messaging:
:path: ".symlinks/plugins/firebase_messaging/ios"
Flutter:
:path: Flutter
flutter_app_badge:
:path: ".symlinks/plugins/flutter_app_badge/ios"
flutter_local_notifications:
:path: ".symlinks/plugins/flutter_local_notifications/ios"
flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_secure_storage_darwin:
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
home_widget:
:path: ".symlinks/plugins/home_widget/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
in_app_review:
:path: ".symlinks/plugins/in_app_review/ios"
open_filex:
:path: ".symlinks/plugins/open_filex/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
receive_sharing_intent:
:path: ".symlinks/plugins/receive_sharing_intent/ios"
screen_brightness_ios:
:path: ".symlinks/plugins/screen_brightness_ios/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
syncfusion_flutter_pdfviewer:
:path: ".symlinks/plugins/syncfusion_flutter_pdfviewer/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
video_player_avfoundation:
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
workmanager_apple:
:path: ".symlinks/plugins/workmanager_apple/ios"
SPEC CHECKSUMS:
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
emoji_picker_flutter: ece213fc274bdddefb77d502d33080dc54e616cc
eraser: 83a4b06985f3702aa3d8dec816f9693266012937
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
Firebase: aa154fee4e9b8eac17aa42344988865b3e857d33
firebase_core: 9156a152117c843440b0b990c785aa0259bc5447
firebase_messaging: 0d962ab44ff24ed36deb8fa2ee043c4671858269
FirebaseCore: 86241206e656f5c80c995e370e6c975913b9b284
FirebaseCoreInternal: 7c12fc3011d889085e765e317d7b9fd1cef97af9
FirebaseInstallations: 4e6e162aa4abaaeeeb01dd00179dfc5ad9c2194e
FirebaseMessaging: 341004946fa7ffc741344b20f1b667514fc93e31
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf
flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
PhoneNumberKit: ced55861269312a5e3bc2ef82a58d6255b1c976a
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
screen_brightness_ios: 212d950bb99c915eee971c884f4a6c87c92cd13d
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
syncfusion_flutter_pdfviewer: 90dc48305d2e33d4aa20681d1e98ddeda891bc14
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
PODFILE CHECKSUM: 424a9b4c0fe81d8ebeaa9cb0dfedb60a68b19a0d
PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6
COCOAPODS: 1.16.2
+201 -18
View File
@@ -12,13 +12,18 @@
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3321F8052FB1C00C0011C712 /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
725388B5C3A724B19BD6FD06 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2960F029246C39E2A03F6D87 /* NotificationService.swift */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7832A860F2264966809A9402 /* NotificationServiceExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
97D9AFE39CF2369A97F04721 /* PEM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 509DCCD474353408FE5806C5 /* PEM.swift */; };
AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0101020000000011111111 /* TimetableWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
AA0102010000000022222222 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0102020000000022222222 /* SceneDelegate.swift */; };
B8263932DB64B022CCEE7A53 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90960A132A5F91779B3FBE28 /* Pods_Runner.framework */; };
C40CF71846788CD98CB99E2B /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F5B421EAF56B77B775E58E92 /* Foundation.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -36,6 +41,13 @@
remoteGlobalIDString = AA0101010000000011111111;
remoteInfo = TimetableWidgetExtension;
};
AABEF26FF24F76E01DA5ADEA /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = AEDC710FEBFF2CC736D88AB2;
remoteInfo = NotificationServiceExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -47,6 +59,7 @@
files = (
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */,
AA0101070000000011111111 /* TimetableWidgetExtension.appex in Embed Foundation Extensions */,
7832A860F2264966809A9402 /* NotificationServiceExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
@@ -64,17 +77,24 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
12B96C78930441C73F123636 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = Info.plist; path = Info.plist; sourceTree = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Release.xcconfig"; path = "Flutter/NotificationServiceExtension-Release.xcconfig"; sourceTree = "<group>"; };
2960F029246C39E2A03F6D87 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NotificationService.swift; path = NotificationService.swift; sourceTree = "<group>"; };
3321F8052FB1C00C0011C712 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Debug.xcconfig"; path = "Flutter/NotificationServiceExtension-Debug.xcconfig"; sourceTree = "<group>"; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
4509EC31CB08BA9BF367AF6C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Share_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
509DCCD474353408FE5806C5 /* PEM.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PEM.swift; path = PEM.swift; sourceTree = "<group>"; };
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; name = NotificationServiceExtension.entitlements; path = NotificationServiceExtension.entitlements; sourceTree = "<group>"; };
60E1803A3FB28FCC6F435E99 /* Pods-Share Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.release.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"; sourceTree = "<group>"; };
64801C012A9112D500E8B558 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
90960A132A5F91779B3FBE28 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
@@ -93,9 +113,12 @@
BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Debug.xcconfig"; path = "Flutter/TimetableWidget-Debug.xcconfig"; sourceTree = "<group>"; };
BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Release.xcconfig"; path = "Flutter/TimetableWidget-Release.xcconfig"; sourceTree = "<group>"; };
BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "TimetableWidget-Profile.xcconfig"; path = "Flutter/TimetableWidget-Profile.xcconfig"; sourceTree = "<group>"; };
C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
C7E1879BE78835C7E3256316 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
DD904D7C0FC0AD11449CEB80 /* Pods-Share Extension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.debug.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.debug.xcconfig"; sourceTree = "<group>"; };
EF5279D9BF8FCBB117AF998E /* Pods-Share Extension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.profile.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.profile.xcconfig"; sourceTree = "<group>"; };
F5B421EAF56B77B775E58E92 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
F758339399E7B5FD1A88FC92 /* NotificationServiceExtension-Profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Profile.xcconfig"; path = "Flutter/NotificationServiceExtension-Profile.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -155,6 +178,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
B8263932DB64B022CCEE7A53 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -166,6 +190,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
D2447D92E9CCD96B3292B17B /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
C40CF71846788CD98CB99E2B /* Foundation.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -182,18 +214,40 @@
path = Pods;
sourceTree = "<group>";
};
553E8F3190182FD2E527FEB5 /* NotificationServiceExtension */ = {
isa = PBXGroup;
children = (
2960F029246C39E2A03F6D87 /* NotificationService.swift */,
509DCCD474353408FE5806C5 /* PEM.swift */,
12B96C78930441C73F123636 /* Info.plist */,
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */,
);
name = NotificationServiceExtension;
path = NotificationServiceExtension;
sourceTree = SOURCE_ROOT;
};
731388A08E3B330B216381D0 /* Frameworks */ = {
isa = PBXGroup;
children = (
90960A132A5F91779B3FBE28 /* Pods_Runner.framework */,
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */,
80D9B9919D3D7CCA2A80C8C5 /* iOS */,
);
name = Frameworks;
sourceTree = "<group>";
};
80D9B9919D3D7CCA2A80C8C5 /* iOS */ = {
isa = PBXGroup;
children = (
F5B421EAF56B77B775E58E92 /* Foundation.framework */,
);
name = iOS;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@@ -204,6 +258,9 @@
BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */,
BB0001050000000011111111 /* TimetableWidget-Release.xcconfig */,
BB0001060000000011111111 /* TimetableWidget-Profile.xcconfig */,
36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */,
17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */,
F758339399E7B5FD1A88FC92 /* NotificationServiceExtension-Profile.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
@@ -218,6 +275,7 @@
97C146EF1CF9000F007C117D /* Products */,
345F4BD4143471FDA71626DE /* Pods */,
731388A08E3B330B216381D0 /* Frameworks */,
553E8F3190182FD2E527FEB5 /* NotificationServiceExtension */,
);
sourceTree = "<group>";
};
@@ -227,6 +285,7 @@
97C146EE1CF9000F007C117D /* Runner.app */,
3321F8052FB1C00C0011C712 /* Share Extension.appex */,
AA0101020000000011111111 /* TimetableWidgetExtension.appex */,
C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -286,15 +345,18 @@
3321F8102FB1C00C0011C712 /* Embed Foundation Extensions */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
174B54D80220E5F588BD9737 /* [CP] Embed Pods Frameworks */,
859FAB4E05FAC31B7B1A62D7 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
dependencies = (
3321F80E2FB1C00C0011C712 /* PBXTargetDependency */,
AA0101090000000011111111 /* PBXTargetDependency */,
43763BD5552A36CA28890DFA /* PBXTargetDependency */,
);
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
@@ -319,6 +381,23 @@
productReference = AA0101020000000011111111 /* TimetableWidgetExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = C3A6F3FA3E3FD220B736D6E5 /* Build configuration list for PBXNativeTarget "NotificationServiceExtension" */;
buildPhases = (
DF8C1170E7DF96711030EAC0 /* Sources */,
D2447D92E9CCD96B3292B17B /* Frameworks */,
239068341DC6E6B36193EC96 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = NotificationServiceExtension;
productName = NotificationServiceExtension;
productReference = C3B91710361EFED8934F0FDC /* NotificationServiceExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -351,6 +430,9 @@
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
@@ -358,11 +440,19 @@
97C146ED1CF9000F007C117D /* Runner */,
3321F8042FB1C00C0011C712 /* Share Extension */,
AA0101010000000011111111 /* TimetableWidgetExtension */,
AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
239068341DC6E6B36193EC96 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
3321F8032FB1C00C0011C712 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -425,23 +515,6 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
859FAB4E05FAC31B7B1A62D7 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -528,6 +601,15 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
DF8C1170E7DF96711030EAC0 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
725388B5C3A724B19BD6FD06 /* NotificationService.swift in Sources */,
97D9AFE39CF2369A97F04721 /* PEM.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@@ -536,6 +618,12 @@
target = 3321F8042FB1C00C0011C712 /* Share Extension */;
targetProxy = 3321F80D2FB1C00C0011C712 /* PBXContainerItemProxy */;
};
43763BD5552A36CA28890DFA /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = NotificationServiceExtension;
target = AEDC710FEBFF2CC736D88AB2 /* NotificationServiceExtension */;
targetProxy = AABEF26FF24F76E01DA5ADEA /* PBXContainerItemProxy */;
};
AA0101090000000011111111 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = AA0101010000000011111111 /* TimetableWidgetExtension */;
@@ -937,6 +1025,29 @@
};
name = Release;
};
A2B046F78AD55232F08583F3 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */;
buildSettings = {
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = MY55VF3KPG;
INFOPLIST_FILE = NotificationServiceExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client.NotificationServiceExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
AA01010A0000000011111111 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = BB0001040000000011111111 /* TimetableWidget-Debug.xcconfig */;
@@ -1059,6 +1170,54 @@
};
name = Profile;
};
E5EE58583E83B7694F6C3C5E /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */;
buildSettings = {
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = MY55VF3KPG;
INFOPLIST_FILE = NotificationServiceExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client.NotificationServiceExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
F418DF43742DC2E30B161652 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = F758339399E7B5FD1A88FC92 /* NotificationServiceExtension-Profile.xcconfig */;
buildSettings = {
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = NotificationServiceExtension/NotificationServiceExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = MY55VF3KPG;
INFOPLIST_FILE = NotificationServiceExtension/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = eu.mhsl.marianum.mobile.client.NotificationServiceExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -1102,7 +1261,31 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
C3A6F3FA3E3FD220B736D6E5 /* Build configuration list for PBXNativeTarget "NotificationServiceExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
E5EE58583E83B7694F6C3C5E /* Release */,
A2B046F78AD55232F08583F3 /* Debug */,
F418DF43742DC2E30B161652 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
@@ -0,0 +1,176 @@
{
"pins" : [
{
"identity" : "abseil-cpp-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/abseil-cpp-binary.git",
"state" : {
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
"version" : "1.2024072200.0"
}
},
{
"identity" : "app-check",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git",
"state" : {
"revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902",
"version" : "11.3.1"
}
},
{
"identity" : "dkcamera",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKCamera",
"state" : {
"branch" : "master",
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
}
},
{
"identity" : "dkimagepickercontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKImagePickerController",
"state" : {
"branch" : "4.3.9",
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
}
},
{
"identity" : "dkphotogallery",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
"state" : {
"branch" : "master",
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
}
},
{
"identity" : "firebase-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : {
"revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270",
"version" : "12.15.0"
}
},
{
"identity" : "google-ads-on-device-conversion-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : {
"revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55",
"version" : "3.6.1"
}
},
{
"identity" : "googleappmeasurement",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : {
"revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b",
"version" : "12.15.0"
}
},
{
"identity" : "googledatatransport",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git",
"state" : {
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
"version" : "10.1.0"
}
},
{
"identity" : "googleutilities",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "9f183ae842be978784f2963a343682e0c46d8fb3",
"version" : "8.1.2"
}
},
{
"identity" : "grpc-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/grpc-binary.git",
"state" : {
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
"version" : "1.69.1"
}
},
{
"identity" : "gtm-session-fetcher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : {
"revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a",
"version" : "5.3.0"
}
},
{
"identity" : "interop-ios-for-google-sdks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
"state" : {
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
"version" : "101.0.0"
}
},
{
"identity" : "leveldb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/leveldb.git",
"state" : {
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
"version" : "1.22.5"
}
},
{
"identity" : "nanopb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git",
"state" : {
"revision" : "3851d94a41890dea16dc3db34caf60e585cb4163",
"version" : "2.30910.1"
}
},
{
"identity" : "promises",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
"version" : "2.4.1"
}
},
{
"identity" : "sdwebimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImage",
"state" : {
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
"version" : "5.21.7"
}
},
{
"identity" : "swiftygif",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kirualex/SwiftyGif.git",
"state" : {
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
"version" : "5.4.5"
}
},
{
"identity" : "tocropviewcontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TimOliver/TOCropViewController",
"state" : {
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
"version" : "2.8.0"
}
}
],
"version" : 2
}
@@ -5,6 +5,24 @@
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
@@ -5,8 +5,44 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/abseil-cpp-binary.git",
"state" : {
"revision" : "bfc0b6f81adc06ce5121eb23f628473638d67c5c",
"version" : "1.2022062300.0"
"revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5",
"version" : "1.2024072200.0"
}
},
{
"identity" : "app-check",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git",
"state" : {
"revision" : "bb4002485ff867768dec13bf904a2ddb050bd1b1",
"version" : "11.3.0"
}
},
{
"identity" : "dkcamera",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKCamera",
"state" : {
"branch" : "master",
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
}
},
{
"identity" : "dkimagepickercontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKImagePickerController",
"state" : {
"branch" : "4.3.9",
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
}
},
{
"identity" : "dkphotogallery",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
"state" : {
"branch" : "master",
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
}
},
{
@@ -14,8 +50,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : {
"revision" : "df2171b0c6afb9e9d4f7e07669d558c510b9f6be",
"version" : "10.13.0"
"revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270",
"version" : "12.15.0"
}
},
{
"identity" : "google-ads-on-device-conversion-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : {
"revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64",
"version" : "3.6.0"
}
},
{
@@ -23,8 +68,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : {
"revision" : "03b9beee1a61f62d32c521e172e192a1663a5e8b",
"version" : "10.13.0"
"revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b",
"version" : "12.15.0"
}
},
{
@@ -32,8 +77,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git",
"state" : {
"revision" : "aae45a320fd0d11811820335b1eabc8753902a40",
"version" : "9.2.5"
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
"version" : "10.1.0"
}
},
{
@@ -41,8 +86,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "c38ce365d77b04a9a300c31061c5227589e5597b",
"version" : "7.11.5"
"revision" : "c46e5f8b7c23265f17c24ca7f9fa1b13ded7a822",
"version" : "8.1.1"
}
},
{
@@ -50,8 +95,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/grpc-binary.git",
"state" : {
"revision" : "f1b366129d1125be7db83247e003fc333104b569",
"version" : "1.50.2"
"revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
"version" : "1.69.1"
}
},
{
@@ -59,8 +104,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : {
"revision" : "d415594121c9e8a4f9d79cecee0965cf35e74dbd",
"version" : "3.1.1"
"revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a",
"version" : "5.3.0"
}
},
{
"identity" : "interop-ios-for-google-sdks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
"state" : {
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
"version" : "101.0.0"
}
},
{
@@ -68,8 +122,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/leveldb.git",
"state" : {
"revision" : "0706abcc6b0bd9cedfbb015ba840e4a780b5159b",
"version" : "1.22.2"
"revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
"version" : "1.22.5"
}
},
{
@@ -77,8 +131,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git",
"state" : {
"revision" : "819d0a2173aff699fb8c364b6fb906f7cdb1a692",
"version" : "2.30909.0"
"revision" : "3851d94a41890dea16dc3db34caf60e585cb4163",
"version" : "2.30910.1"
}
},
{
@@ -86,17 +140,35 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "e70e889c0196c76d22759eb50d6a0270ca9f1d9e",
"version" : "2.3.1"
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
"version" : "2.4.1"
}
},
{
"identity" : "swift-protobuf",
"identity" : "sdwebimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-protobuf.git",
"location" : "https://github.com/SDWebImage/SDWebImage",
"state" : {
"revision" : "ce20dc083ee485524b802669890291c0d8090170",
"version" : "1.22.1"
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
"version" : "5.21.7"
}
},
{
"identity" : "swiftygif",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kirualex/SwiftyGif.git",
"state" : {
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
"version" : "5.4.5"
}
},
{
"identity" : "tocropviewcontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TimOliver/TOCropViewController",
"state" : {
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
"version" : "2.8.0"
}
}
],
+188 -1
View File
@@ -1,16 +1,203 @@
import Flutter
import UIKit
import UserNotifications
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
// Kept in sync with lib/push/push_actions.dart (kTalkReplyActionId /
// kTalkMarkReadActionId) and lib/push/push_renderer.dart (iosTalkCategory).
private let talkCategoryId = "TALK_MESSAGE"
private let replyActionId = "TALK_REPLY"
private let markReadActionId = "TALK_MARK_READ"
// Shared (App Group) keychain same group as the NSE and the Dart side.
private let keychainAccessGroup = "MY55VF3KPG.eu.mhsl.marianum.mobile.client.push"
private let usernameAccount = "nextcloud_username"
private let appPasswordAccount = "nextcloud_app_password"
private let baseUrlAccount = "nextcloud_base_url"
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
let result = super.application(application, didFinishLaunchingWithOptions: launchOptions)
registerTalkCategory()
// FlutterAppDelegate conforms to UNUserNotificationCenterDelegate and
// forwards these callbacks to the plugins (firebase_messaging,
// flutter_local_notifications). We route Talk actions natively here the
// Flutter engine is not guaranteed to run for a background action and
// forward everything else to `super` so plugin behaviour is preserved.
UNUserNotificationCenter.current().delegate = self
return result
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
private func registerTalkCategory() {
let reply = UNTextInputNotificationAction(
identifier: replyActionId,
title: "Antworten",
options: [],
textInputButtonTitle: "Senden",
textInputPlaceholder: "Nachricht"
)
let markRead = UNNotificationAction(
identifier: markReadActionId,
title: "Als gelesen markieren",
options: []
)
let category = UNNotificationCategory(
identifier: talkCategoryId,
actions: [reply, markRead],
intentIdentifiers: [],
options: []
)
UNUserNotificationCenter.current().setNotificationCategories([category])
}
// MARK: - UNUserNotificationCenterDelegate
override func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let actionId = response.actionIdentifier
if actionId == replyActionId || actionId == markReadActionId {
// Handled natively; deliberately NOT forwarded to super so the plugins
// don't also process it (which would double-send the reply).
handleTalkAction(response, completion: completionHandler)
return
}
super.userNotificationCenter(
center, didReceive: response, withCompletionHandler: completionHandler)
}
// MARK: - Native Talk action handling
private func handleTalkAction(
_ response: UNNotificationResponse,
completion: @escaping () -> Void
) {
guard
let token = chatToken(from: response),
let credentials = loadCredentials()
else {
completion()
return
}
let group = DispatchGroup()
if response.actionIdentifier == replyActionId,
let text = (response as? UNTextInputNotificationResponse)?.userText
.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty {
group.enter()
ocsPost(
credentials: credentials,
path: "apps/spreed/api/v1/chat/\(token)",
form: ["message": text]
) { group.leave() }
}
// Both reply and mark-read clear the unread marker on the chat.
group.enter()
ocsPost(
credentials: credentials,
path: "apps/spreed/api/v1/chat/\(token)/read",
form: nil
) { group.leave() }
group.notify(queue: .main) { completion() }
}
private func chatToken(from response: UNNotificationResponse) -> String? {
let userInfo = response.notification.request.content.userInfo
if let token = userInfo["chatToken"] as? String, !token.isEmpty {
return token
}
let thread = response.notification.request.content.threadIdentifier
if thread.hasPrefix("talk_") {
let token = String(thread.dropFirst("talk_".count))
return token.isEmpty ? nil : token
}
return nil
}
private struct Credentials {
let baseUrl: String
let authorization: String
}
private func loadCredentials() -> Credentials? {
guard
let username = keychainString(usernameAccount),
let appPassword = keychainString(appPasswordAccount),
let baseUrl = keychainString(baseUrlAccount),
let token = "\(username):\(appPassword)".data(using: .utf8)
else { return nil }
let authorization = "Basic \(token.base64EncodedString())"
let trimmed = baseUrl.hasSuffix("/") ? String(baseUrl.dropLast()) : baseUrl
return Credentials(baseUrl: trimmed, authorization: authorization)
}
private func ocsPost(
credentials: Credentials,
path: String,
form: [String: String]?,
completion: @escaping () -> Void
) {
guard let url = URL(string: "\(credentials.baseUrl)/ocs/v2.php/\(path)") else {
completion()
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue(credentials.authorization, forHTTPHeaderField: "Authorization")
request.setValue("true", forHTTPHeaderField: "OCS-APIRequest")
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let form = form {
request.setValue(
"application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.httpBody = form
.map { "\($0.key)=\(formEncode($0.value))" }
.joined(separator: "&")
.data(using: .utf8)
}
URLSession.shared.dataTask(with: request) { _, response, error in
if let error = error {
NSLog("[Talk action] \(path) failed: \(error.localizedDescription)")
} else if let http = response as? HTTPURLResponse,
!(200..<300).contains(http.statusCode) {
NSLog("[Talk action] \(path) -> HTTP \(http.statusCode)")
}
completion()
}.resume()
}
private func formEncode(_ value: String) -> String {
var allowed = CharacterSet.alphanumerics
allowed.insert(charactersIn: "-._~")
return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
}
// MARK: - Keychain (shared App Group)
private func keychainString(_ account: String) -> String? {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: account,
kSecAttrAccessGroup: keychainAccessGroup,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne,
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
}
+4
View File
@@ -9,5 +9,9 @@
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
<string>group.eu.mhsl.marianum.mobile.client.share</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)eu.mhsl.marianum.mobile.client.push</string>
</array>
</dict>
</plist>
+2 -2
View File
@@ -14,7 +14,7 @@ class SceneDelegate: FlutterSceneDelegate {
) {
super.scene(scene, willConnectTo: session, options: connectionOptions)
for context in connectionOptions.urlContexts {
_ = SwiftReceiveSharingIntentPlugin.instance.application(
_ = ReceiveSharingIntentPlugin.instance.application(
UIApplication.shared,
didFinishLaunchingWithOptions: [UIApplication.LaunchOptionsKey.url: context.url]
)
@@ -23,7 +23,7 @@ class SceneDelegate: FlutterSceneDelegate {
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
for context in URLContexts {
_ = SwiftReceiveSharingIntentPlugin.instance.application(
_ = ReceiveSharingIntentPlugin.instance.application(
UIApplication.shared,
open: context.url,
options: [:]
@@ -3,7 +3,7 @@ import UniformTypeIdentifiers
import AVFoundation
// Datenmodell muss byte-für-byte zu dem passen, was
// SwiftReceiveSharingIntentPlugin auf der Host-App-Seite decodiert.
// ReceiveSharingIntentPlugin auf der Host-App-Seite decodiert.
private enum SharedMediaType: String, Codable {
case image, video, text, file, url
}
-1
View File
@@ -1 +0,0 @@
class ApiRequest {}
+17
View File
@@ -0,0 +1,17 @@
import '../../marianumconnect/queries/absence/absence_prefill_response.dart';
/// Demo fixtures for the absence-report form: the class dropdown and the
/// identity/phone prefill. Typed (compile-checked) so a field rename can't
/// silently drift the demo shape away from what the queries parse.
class DemoAbsence {
const DemoAbsence._();
static List<String> classes() => const ['5a', '6b', '7c', '9d', '10a', 'Q1'];
static AbsencePrefillResponse prefill() => AbsencePrefillResponse(
firstName: 'Max',
lastName: 'Mustermann',
className: '10a',
phone: '0123 456789',
);
}
+9
View File
@@ -0,0 +1,9 @@
import '../../marianumconnect/queries/get_breakers/get_breakers_response.dart';
/// Demo fixtures for the app-maintenance breaker.
class DemoBreaker {
const DemoBreaker._();
/// No active rules — the demo app is never blocked.
static GetBreakersResponse none() => GetBreakersResponse([]);
}
+33
View File
@@ -0,0 +1,33 @@
import '../../../state/app/modules/capabilities/bloc/capabilities_state.dart';
import '../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_state.dart';
/// Demo fixtures for the mobile capability flags — everything granted so the
/// demo persona sees every feature (incl. push) as available.
class DemoCapabilities {
const DemoCapabilities._();
static CapabilitiesState state() => const CapabilitiesState(
viewForeignTimetables: true,
pushNotifications: true,
loaded: true,
);
}
/// Demo fixtures for the Nextcloud `files_sharing` capabilities — a permissive
/// "everything enabled, nothing enforced" server profile.
class DemoNextcloudCapabilities {
const DemoNextcloudCapabilities._();
static NextcloudCapabilitiesState state() => const NextcloudCapabilitiesState(
apiEnabled: true,
publicEnabled: true,
publicMultipleLinks: true,
publicUploadEnabled: true,
publicPasswordEnforced: false,
publicExpireEnabled: false,
publicExpireEnforced: false,
groupEnabled: true,
resharing: true,
loaded: true,
);
}
+155
View File
@@ -0,0 +1,155 @@
import '../../marianumcloud/webdav/queries/list_files/cacheable_file.dart';
import '../../marianumcloud/webdav/queries/list_files/list_files_response.dart';
/// Curated WebDAV listings for the demo mode. Everything is built on
/// [CacheableFile] (the app-level model the file UI renders) so no `nextcloud`
/// DAV objects need to be faked and no network is ever touched.
///
/// `hasPreview`/`fileId` are left null on purpose: without them the file list
/// won't try to fetch server-side thumbnails for the fixtures.
class DemoFiles {
const DemoFiles._();
/// Returns the listing for [path] as passed to `FilesDataProvider.listFiles`
/// (`/` or empty for the root, otherwise the joined folder display names).
/// Unknown folders resolve to an empty listing rather than an error.
static ListFilesResponse listing(String path) {
final key = path.replaceAll('/', '').trim();
switch (key) {
case '':
return _root();
case 'Hausaufgaben':
return _hausaufgaben();
case 'Arbeitsblätter':
return _arbeitsblaetter();
case 'Klausuren':
return _klausuren();
case 'Schuljahr 2025-26':
return _schuljahr();
default:
return ListFilesResponse({});
}
}
static ListFilesResponse _root() => ListFilesResponse({
_folder('Hausaufgaben', daysAgo: 1),
_folder('Arbeitsblätter', daysAgo: 3),
_folder('Klausuren', daysAgo: 12),
_folder('Schuljahr 2025-26', daysAgo: 30),
_file('Deutsch Lektüre.pdf', size: 2_480_000, daysAgo: 2),
_file('Mathe Formelsammlung.pdf', size: 890_000, daysAgo: 9),
_file('Bio Referat.pptx', size: 5_120_000, daysAgo: 5),
_file('Stundenplan.pdf', size: 145_000, daysAgo: 14),
});
static ListFilesResponse _hausaufgaben() => ListFilesResponse({
_file(
'Deutsch Aufsatz.docx',
parent: 'Hausaufgaben',
size: 42_000,
daysAgo: 1,
),
_file(
'Mathe Übungen KW28.pdf',
parent: 'Hausaufgaben',
size: 310_000,
daysAgo: 2,
),
_file(
'Englisch Vokabeln.pdf',
parent: 'Hausaufgaben',
size: 96_000,
daysAgo: 4,
),
});
static ListFilesResponse _arbeitsblaetter() => ListFilesResponse({
_file(
'Physik Optik.pdf',
parent: 'Arbeitsblätter',
size: 720_000,
daysAgo: 6,
),
_file(
'Geschichte Weimar.pdf',
parent: 'Arbeitsblätter',
size: 540_000,
daysAgo: 8,
),
});
static ListFilesResponse _klausuren() => ListFilesResponse({
_file(
'Mathe Klausur 1.pdf',
parent: 'Klausuren',
size: 630_000,
daysAgo: 20,
),
_file(
'Deutsch Klausur 1.pdf',
parent: 'Klausuren',
size: 580_000,
daysAgo: 34,
),
});
static ListFilesResponse _schuljahr() => ListFilesResponse({
_folder('Halbjahr 1', parent: 'Schuljahr 2025-26', daysAgo: 40),
_file(
'Klassenliste 10b.pdf',
parent: 'Schuljahr 2025-26',
size: 78_000,
daysAgo: 60,
),
});
static CacheableFile _folder(
String name, {
String? parent,
required int daysAgo,
}) {
final base = parent == null ? '' : '/$parent';
return CacheableFile(
path: '$base/$name/',
isDirectory: true,
name: name,
modifiedAt: _relative(daysAgo),
createdAt: _relative(daysAgo + 30),
);
}
static CacheableFile _file(
String name, {
String? parent,
required int size,
required int daysAgo,
}) {
final base = parent == null ? '' : '/$parent';
return CacheableFile(
path: '$base/$name',
isDirectory: false,
name: name,
mimeType: _mimeFor(name),
size: size,
modifiedAt: _relative(daysAgo),
createdAt: _relative(daysAgo + 30),
);
}
static DateTime _relative(int daysAgo) =>
DateTime.now().subtract(Duration(days: daysAgo, hours: 3));
static String _mimeFor(String name) {
final ext = name.split('.').last.toLowerCase();
switch (ext) {
case 'pdf':
return 'application/pdf';
case 'pptx':
return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
case 'docx':
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
default:
return 'application/octet-stream';
}
}
}
+34
View File
@@ -0,0 +1,34 @@
import '../../marianumconnect/models/mc_holiday.dart';
/// Demo fixtures for the holiday calendar — a couple of upcoming breaks,
/// positioned relative to "now" so they always read as still ahead.
class DemoHolidays {
const DemoHolidays._();
static List<McHoliday> upcoming() {
final today = DateTime.now();
DateTime day(int offset) =>
DateTime(today.year, today.month, today.day).add(Duration(days: offset));
return [
McHoliday(
shortName: 'Herbst',
longName: 'Herbstferien',
startDate: day(28),
endDate: day(42),
),
McHoliday(
shortName: 'Weihnachten',
longName: 'Weihnachtsferien',
startDate: day(90),
endDate: day(104),
),
McHoliday(
shortName: 'Ostern',
longName: 'Osterferien',
startDate: day(180),
endDate: day(194),
),
];
}
}
@@ -0,0 +1,54 @@
import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart';
import '../demo_persona.dart';
/// Demo fixtures for the school calendar — a handful of upcoming events built
/// relative to "now" and consistent with the demo persona (class 10b).
class DemoMarianumDates {
const DemoMarianumDates._();
static List<MarianumDate> events() {
final today = DateTime.now();
DateTime at(int offset, [int hour = 0, int minute = 0]) => DateTime(
today.year,
today.month,
today.day,
hour,
minute,
).add(Duration(days: offset));
return [
MarianumDate(
uid: 'demo-klausur-mathe',
title: 'Klausur Mathematik ${DemoPersona.className}',
description: 'Erste Klausur bei Herr Müller. Themen: Analysis.',
start: at(4, 9, 45),
end: at(4, 11, 15),
isAllDay: false,
),
MarianumDate(
uid: 'demo-wandertag',
title: 'Wandertag',
description: 'Gemeinsamer Ausflug der Jahrgangsstufe 10.',
start: at(11),
end: at(12),
isAllDay: true,
),
MarianumDate(
uid: 'demo-elternsprechtag',
title: 'Elternsprechtag',
description: 'Anmeldung zu den Gesprächen über das Sekretariat.',
start: at(18, 14, 0),
end: at(18, 19, 0),
isAllDay: false,
),
MarianumDate(
uid: 'demo-zeugniskonferenz',
title: 'Zeugniskonferenz',
description: 'Unterrichtsfrei ab der 4. Stunde.',
start: at(32),
end: at(33),
isAllDay: true,
),
];
}
}
@@ -0,0 +1,26 @@
import 'package:intl/intl.dart';
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
/// Demo fixtures for the info messages — a single friendly welcome entry so the
/// section is populated rather than empty.
class DemoMarianumMessage {
const DemoMarianumMessage._();
static MarianumMessageList list() {
final date = DateFormat.yMMMM('de').format(DateTime.now());
return MarianumMessageList(
base: 'https://marianum-fulda.de',
messages: [
MarianumMessage(
id: 'demo',
name: 'Willkommen in der Marianum-App',
date: date,
description: 'Eine kurze Begrüßung zum Ausprobieren.',
url: 'https://marianum-fulda.de',
),
],
);
}
}
+429
View File
@@ -0,0 +1,429 @@
import '../../../model/account_data.dart';
import '../../marianumcloud/talk/chat/get_chat_response.dart';
import '../../marianumcloud/talk/room/get_room_response.dart';
import '../demo_persona.dart';
/// Fixtures for the Talk module while [DemoMode] is active: a curated set of
/// rooms plus a believable German conversation per room. Room tokens are the
/// join between [rooms] and [chat] — they must stay in sync.
class DemoTalk {
const DemoTalk._();
static const String tokenClass = 'demo-klasse-10b';
static const String tokenMathLk = 'demo-mathe-lk';
static const String tokenWeber = 'demo-herr-weber';
static const String tokenWeimar = 'demo-weimar';
static const String tokenSv = 'demo-sv';
static GetRoomResponse rooms() {
return GetRoomResponse({
_room(
id: 100,
token: tokenClass,
type: GetRoomResponseObjectConversationType.group,
name: 'Klasse 10b',
displayName: 'Klasse ${DemoPersona.className}',
lastMessage: _lastOf(tokenClass),
unreadMessages: 2,
isFavorite: true,
),
_room(
id: 200,
token: tokenMathLk,
type: GetRoomResponseObjectConversationType.group,
name: 'Mathe-LK',
displayName: 'Mathe-LK · Herr Müller',
lastMessage: _lastOf(tokenMathLk),
),
_room(
id: 300,
token: tokenWeber,
type: GetRoomResponseObjectConversationType.oneToOne,
name: 'weber',
displayName: 'Herr Weber',
lastMessage: _lastOf(tokenWeber),
unreadMessages: 1,
),
_room(
id: 400,
token: tokenWeimar,
type: GetRoomResponseObjectConversationType.group,
name: 'Fahrt nach Weimar',
displayName: 'Fahrt nach Weimar 🚌',
lastMessage: _lastOf(tokenWeimar),
),
_room(
id: 500,
token: tokenSv,
type: GetRoomResponseObjectConversationType.group,
name: 'SV',
displayName: 'SV Schülervertretung',
lastMessage: _lastOf(tokenSv),
),
});
}
static GetChatResponse chat(String token) =>
GetChatResponse(_messages(token).toSet());
static GetChatResponseObject _lastOf(String token) => _messages(token).last;
static List<GetChatResponseObject> _messages(String token) {
switch (token) {
case tokenClass:
return _classChat();
case tokenMathLk:
return _mathLkChat();
case tokenWeber:
return _weberChat();
case tokenWeimar:
return _weimarChat();
case tokenSv:
return _svChat();
default:
return _genericChat(token);
}
}
static List<GetChatResponseObject> _classChat() {
const token = tokenClass;
var base = 10000;
return [
_msg(
id: base + 1,
token: token,
actor: 'sophie',
display: 'Sophie',
ago: const Duration(days: 1, hours: 4),
message: 'Weiß jemand, was wir in Bio auf haben?',
),
_msg(
id: base + 2,
token: token,
actor: 'max',
display: 'Max',
ago: const Duration(days: 1, hours: 3, minutes: 55),
message: 'S. 42 Nr. 3 und 4, glaube ich',
),
_selfMsg(
id: base + 3,
token: token,
ago: const Duration(days: 1, hours: 3, minutes: 50),
message: 'Ja genau, und das Arbeitsblatt zur Zellteilung 🙌',
reactions: {'👍': 3},
),
_msg(
id: base + 4,
token: token,
actor: 'jonas',
display: 'Jonas',
ago: const Duration(days: 1, hours: 3, minutes: 40),
message: 'Danke euch! 🙏',
),
_msg(
id: base + 5,
token: token,
actor: 'emma',
display: 'Emma',
ago: const Duration(hours: 20),
message: 'Ist die Mathe-Arbeit jetzt am Donnerstag oder Freitag?',
),
_msg(
id: base + 6,
token: token,
actor: 'mueller',
display: 'Herr Müller',
ago: const Duration(hours: 19, minutes: 30),
message: 'Die Arbeit findet am Freitag in der 3. Stunde statt.',
),
_msg(
id: base + 7,
token: token,
actor: 'emma',
display: 'Emma',
ago: const Duration(hours: 19, minutes: 20),
message: 'Danke, Herr Müller!',
),
_msg(
id: base + 8,
token: token,
actor: 'paul',
display: 'Paul',
ago: const Duration(hours: 6),
message: 'Wer kommt morgen mit in die Mensa?',
),
_msg(
id: base + 9,
token: token,
actor: 'mia',
display: 'Mia',
ago: const Duration(hours: 5, minutes: 50),
message: 'Ich bin dabei 🍕',
),
_selfMsg(
id: base + 10,
token: token,
ago: const Duration(hours: 5, minutes: 45),
message: 'Ich auch!',
),
_msg(
id: base + 11,
token: token,
actor: 'sophie',
display: 'Sophie',
ago: const Duration(hours: 3),
message:
'Vergesst nicht, die Einverständniserklärung für Weimar abzugeben 🚌',
reactions: {'👍': 5, '🚌': 2},
),
_msg(
id: base + 12,
token: token,
actor: 'ben',
display: 'Ben',
ago: const Duration(hours: 2, minutes: 10),
message: 'Oh stimmt, hab ich fast vergessen 😅',
),
];
}
static List<GetChatResponseObject> _mathLkChat() {
const token = tokenMathLk;
var base = 20000;
return [
_msg(
id: base + 1,
token: token,
actor: 'mueller',
display: 'Herr Müller',
ago: const Duration(days: 2, hours: 2),
message:
'Zur Vorbereitung auf die Arbeit findet ihr die Übungsblätter in den Dateien.',
),
_selfMsg(
id: base + 2,
token: token,
ago: const Duration(days: 1, hours: 6),
message: 'Danke! Können wir Aufgabe 5 nächste Stunde nochmal besprechen?',
),
_msg(
id: base + 3,
token: token,
actor: 'mueller',
display: 'Herr Müller',
ago: const Duration(days: 1, hours: 5, minutes: 30),
message: 'Klar, wir schauen uns die Kurvendiskussion gemeinsam an.',
reactions: {'👍': 4},
),
_msg(
id: base + 4,
token: token,
actor: 'jonas',
display: 'Jonas',
ago: const Duration(hours: 8),
message: 'Super, danke!',
),
];
}
static List<GetChatResponseObject> _weberChat() {
const token = tokenWeber;
var base = 30000;
return [
_selfMsg(
id: base + 1,
token: token,
ago: const Duration(days: 1, hours: 2),
message:
'Guten Tag Herr Weber, ich war letzte Woche krank. Was muss ich in Englisch nachholen?',
),
_msg(
id: base + 2,
token: token,
actor: 'weber',
display: 'Herr Weber',
ago: const Duration(hours: 4),
message:
'Hallo Lena, gute Besserung! Lies bitte Unit 4 und bereite die Vokabeln vor.',
),
_msg(
id: base + 3,
token: token,
actor: 'weber',
display: 'Herr Weber',
ago: const Duration(hours: 3, minutes: 58),
message: 'Die Präsentation kannst du nächste Woche nachholen.',
),
];
}
static List<GetChatResponseObject> _weimarChat() {
const token = tokenWeimar;
var base = 40000;
return [
_msg(
id: base + 1,
token: token,
actor: 'wagner',
display: 'Frau Wagner',
ago: const Duration(days: 3),
message:
'Wir treffen uns am Montag um 7:45 Uhr am Haupteingang. Bitte pünktlich sein!',
reactions: {'👍': 8},
),
_msg(
id: base + 2,
token: token,
actor: 'paul',
display: 'Paul',
ago: const Duration(days: 2, hours: 20),
message: 'Wie lange sind wir ungefähr unterwegs?',
),
_msg(
id: base + 3,
token: token,
actor: 'wagner',
display: 'Frau Wagner',
ago: const Duration(days: 2, hours: 19),
message: 'Etwa zwei Stunden mit dem Bus. Denkt an Verpflegung!',
),
_selfMsg(
id: base + 4,
token: token,
ago: const Duration(hours: 12),
message: 'Freue mich schon! 🎉',
reactions: {'🎉': 6},
),
];
}
static List<GetChatResponseObject> _svChat() {
const token = tokenSv;
var base = 50000;
return [
_msg(
id: base + 1,
token: token,
actor: 'ben',
display: 'Ben',
ago: const Duration(days: 2, hours: 5),
message: 'Nächste SV-Sitzung ist am Mittwoch in der großen Pause.',
),
_msg(
id: base + 2,
token: token,
actor: 'mia',
display: 'Mia',
ago: const Duration(days: 1, hours: 8),
message: 'Können wir über den Ablauf vom Sommerfest sprechen?',
),
_selfMsg(
id: base + 3,
token: token,
ago: const Duration(hours: 9),
message: 'Gute Idee, ich sammle schon mal Vorschläge.',
reactions: {'👍': 3},
),
];
}
static List<GetChatResponseObject> _genericChat(String token) {
return [
_msg(
id: 90001,
token: token,
actor: 'info',
display: 'MarianumConnect',
ago: const Duration(hours: 1),
message: 'Willkommen im Demo-Chat 👋',
),
];
}
static GetChatResponseObject _selfMsg({
required int id,
required String token,
required Duration ago,
required String message,
Map<String, int>? reactions,
}) => _msg(
id: id,
token: token,
actor: AccountData().getUsername(),
display: DemoPersona.studentName,
ago: ago,
message: message,
reactions: reactions,
);
static GetChatResponseObject _msg({
required int id,
required String token,
required String actor,
required String display,
required Duration ago,
required String message,
Map<String, int>? reactions,
}) => GetChatResponseObject(
id,
token,
GetRoomResponseObjectMessageActorType.user,
actor,
display,
_epoch(ago),
'',
GetRoomResponseObjectMessageType.comment,
true,
'demo-ref-$id',
message,
null,
reactions,
null,
null,
);
static GetRoomResponseObject _room({
required int id,
required String token,
required GetRoomResponseObjectConversationType type,
required String name,
required String displayName,
required GetChatResponseObject lastMessage,
int unreadMessages = 0,
bool isFavorite = false,
}) => GetRoomResponseObject(
id,
token,
type,
name,
displayName,
'',
3,
0,
0,
0,
0,
'demo-session-$id',
false,
false,
0,
false,
false,
true,
lastMessage.timestamp,
isFavorite,
GetRoomResponseObjectParticipantNotificationLevel.defaultLevel,
unreadMessages,
false,
false,
lastMessage.id - unreadMessages,
lastMessage.id,
lastMessage,
null,
null,
null,
);
static int _epoch(Duration ago) =>
DateTime.now().subtract(ago).millisecondsSinceEpoch ~/ 1000;
}
+112
View File
@@ -0,0 +1,112 @@
import '../../marianumconnect/queries/get_ticker/get_ticker_response.dart';
import '../../marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart';
import '../../marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
/// Demo fixtures for the ticker module — a small, self-contained example doc so
/// the "Aktuelles" surface and the page tree are populated without any network.
class DemoTicker {
const DemoTicker._();
static Map<String, dynamic> _welcomeDoc() => {
'type': 'doc',
'content': [
{
'type': 'heading',
'attrs': {'level': 1},
'content': [
{'type': 'text', 'text': 'Willkommen beim Ticker'},
],
},
{
'type': 'paragraph',
'content': [
{'type': 'text', 'text': 'Hier erscheinen aktuelle Informationen '},
{
'type': 'text',
'text': 'der Schule',
'marks': [
{'type': 'bold'},
],
},
{'type': 'text', 'text': '.'},
],
},
{
'type': 'callout',
'attrs': {'variant': 'info'},
'content': [
{
'type': 'paragraph',
'content': [
{'type': 'text', 'text': 'Dies ist eine Beispiel-Meldung.'},
],
},
],
},
],
};
static Map<String, dynamic> _pageDoc() => {
'type': 'doc',
'content': [
{
'type': 'heading',
'attrs': {'level': 2},
'content': [
{'type': 'text', 'text': 'Über diese Seite'},
],
},
{
'type': 'paragraph',
'content': [
{
'type': 'text',
'text': 'Eine Beispielseite mit nativ gerendertem Inhalt.',
},
],
},
],
};
static TickerResponse ticker() => TickerResponse(
schemaVersion: 1,
available: true,
hash: 'demo-ticker',
publishedAt: DateTime.now().toIso8601String(),
webUrl: '/ticker',
content: _welcomeDoc(),
);
static TickerNavResponse nav() => TickerNavResponse(
schemaVersion: 1,
navHash: 'demo-nav',
sections: [
TickerNavSection(
title: 'Informationen',
pages: [
TickerNavPage(
title: 'Über die App',
slug: 'ueber-die-app',
kind: TickerPageKind.content,
hash: 'demo-page',
),
TickerNavPage(
title: 'Schulwebseite',
slug: 'schulwebseite',
kind: TickerPageKind.redirect,
externalUrl: 'https://marianum-fulda.de',
),
],
),
],
);
static TickerPageResponse page(String slug) => TickerPageResponse(
schemaVersion: 1,
slug: slug,
title: 'Über die App',
kind: TickerPageKind.content,
content: _pageDoc(),
webUrl: '/ticker/p/$slug',
);
}
+202
View File
@@ -0,0 +1,202 @@
import '../../marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
import '../../marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart';
import '../../marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear_response.dart';
import '../../marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
import '../../marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
import '../../marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
import '../demo_persona.dart';
/// Curated timetable fixtures for the demo mode. Everything is derived from
/// [DemoPersona] so subjects, teachers and rooms line up with the rest of the
/// demo app, and every week is generated relative to the requested range so
/// navigating forward/backward always lands on a populated 10b schedule.
class DemoTimetable {
const DemoTimetable._();
/// Period → (startHour, startMinute, endHour, endMinute).
static const Map<int, (int, int, int, int)> _periods = {
1: (7, 55, 8, 40),
2: (8, 40, 9, 25),
3: (9, 45, 10, 30),
4: (10, 30, 11, 15),
5: (11, 35, 12, 20),
6: (12, 20, 13, 5),
7: (13, 50, 14, 35),
8: (14, 35, 15, 20),
};
/// Weekday (DateTime.monday..friday) → period → subject short code.
static const Map<int, Map<int, String>> _plan = {
DateTime.monday: {1: 'M', 2: 'M', 3: 'D', 4: 'E', 5: 'BIO', 6: 'GE', 7: 'SP', 8: 'SP'},
DateTime.tuesday: {1: 'E', 2: 'E', 3: 'PH', 4: 'M', 5: 'D', 6: 'REL', 7: 'GE'},
DateTime.wednesday: {1: 'BIO', 2: 'BIO', 3: 'M', 4: 'D', 5: 'E', 6: 'PH'},
DateTime.thursday: {1: 'D', 2: 'GE', 3: 'M', 4: 'M', 5: 'REL', 6: 'BIO', 7: 'SP', 8: 'SP'},
DateTime.friday: {1: 'PH', 2: 'PH', 3: 'E', 4: 'M', 5: 'D', 6: 'GE'},
};
static const Map<String, String> _roomBySubject = {
'M': 'A101',
'D': 'A101',
'E': 'A102',
'BIO': 'B201',
'PH': 'B203',
'GE': 'A101',
'SP': 'Turnhalle 1',
'REL': 'C305',
};
static TimetableGetWeekResponse week(DateTime from, DateTime until) {
final monday = _mondayOf(from);
final entries = <McTimetableEntry>[];
var id = 1;
for (final weekday in _plan.keys) {
final date = monday.add(Duration(days: weekday - DateTime.monday));
final day = _plan[weekday]!;
for (final entry in day.entries) {
entries.add(_entry(id++, date, weekday, entry.key, entry.value));
}
}
return TimetableGetWeekResponse(from: from, until: until, entries: entries);
}
static McTimetableEntry _entry(
int id,
DateTime date,
int weekday,
int period,
String subjectShort,
) {
final times = _periods[period]!;
final teacher = _teacherFor(subjectShort);
// A couple of recurring irregularities so the demo shows off the cancelled
// and substitution rendering.
final cancelled = weekday == DateTime.monday && period == 4;
final substituted = weekday == DateTime.tuesday && period == 3;
return McTimetableEntry(
id: id,
date: date,
startTime: DateTime(1970, 1, 1, times.$1, times.$2),
endTime: DateTime(1970, 1, 1, times.$3, times.$4),
subjects: [subjectShort],
teachers: [
if (substituted)
McTimetableTeacher(
shortName: 'MÜL',
displayName: 'Herr Müller',
originalShortName: teacher.short,
originalDisplayName: teacher.name,
)
else
McTimetableTeacher(shortName: teacher.short, displayName: teacher.name),
],
rooms: [_roomBySubject[subjectShort] ?? DemoPersona.rooms.first],
classNames: [DemoPersona.className],
lessonType: 'LESSON',
status: cancelled
? 'CANCELLED'
: substituted
? 'IRREGULAR'
: 'REGULAR',
substitutionText: substituted ? 'Vertretung für ${teacher.name}' : null,
lessonText: null,
infoText: cancelled ? 'Fällt aus' : null,
);
}
static TimetableGetRoomsResponse rooms() => TimetableGetRoomsResponse(
result: [
for (var i = 0; i < DemoPersona.rooms.length; i++)
McRoom(id: i + 1, shortName: DemoPersona.rooms[i], longName: DemoPersona.rooms[i]),
],
);
static TimetableGetSubjectsResponse subjects() => TimetableGetSubjectsResponse(
result: [
for (var i = 0; i < DemoPersona.subjects.length; i++)
McSubject(
id: i + 1,
shortName: DemoPersona.subjects[i].short,
longName: DemoPersona.subjects[i].name,
),
],
);
static TimetableGetTimegridResponse timegrid() {
final units = <McTimegridUnit>[];
for (final day in _weekdays) {
for (final period in _periods.entries) {
final t = period.value;
units.add(
McTimegridUnit(
dayOfWeek: day,
label: '${period.key}',
startTime: DateTime(1970, 1, 1, t.$1, t.$2),
endTime: DateTime(1970, 1, 1, t.$3, t.$4),
),
);
}
}
return TimetableGetTimegridResponse(result: units);
}
static TimetableGetSchoolyearResponse schoolyear() {
final startYear = _schoolyearStart(DateTime.now());
return TimetableGetSchoolyearResponse(
id: 1,
name: '$startYear/${startYear + 1}',
startDate: DateTime(startYear, 8, 1),
endDate: DateTime(startYear + 1, 7, 31),
);
}
static TimetableGetHolidaysResponse holidays() {
final startYear = _schoolyearStart(DateTime.now());
return TimetableGetHolidaysResponse(
result: [
McHoliday(
shortName: 'Herbst',
longName: 'Herbstferien',
startDate: DateTime(startYear, 10, 20),
endDate: DateTime(startYear, 10, 31),
),
McHoliday(
shortName: 'Weihnachten',
longName: 'Weihnachtsferien',
startDate: DateTime(startYear, 12, 22),
endDate: DateTime(startYear + 1, 1, 6),
),
],
);
}
static GetCustomTimetableEventResponse customEvents() =>
GetCustomTimetableEventResponse([]);
static const List<McDayOfWeek> _weekdays = [
McDayOfWeek.monday,
McDayOfWeek.tuesday,
McDayOfWeek.wednesday,
McDayOfWeek.thursday,
McDayOfWeek.friday,
];
static DemoTeacher _teacherFor(String subjectShort) {
final subjectName = DemoPersona.subjects
.firstWhere((s) => s.short == subjectShort)
.name;
return DemoPersona.teachers.firstWhere((t) => t.subject == subjectName);
}
static DateTime _mondayOf(DateTime date) {
final day = DateTime(date.year, date.month, date.day);
return day.subtract(Duration(days: day.weekday - DateTime.monday));
}
static int _schoolyearStart(DateTime now) =>
now.month >= 8 ? now.year : now.year - 1;
}
+50
View File
@@ -0,0 +1,50 @@
import '../../marianumconnect/queries/user_search/user_search_response.dart';
/// Demo fixtures for the Talk user search — a small mixed set of teachers and
/// students, filtered client-side so the demo search feels responsive.
class DemoUsers {
const DemoUsers._();
static final List<McUserSearchResult> _all = [
McUserSearchResult(
username: 'm.muster',
firstName: 'Maria',
lastName: 'Mustermann',
userType: 'TEACHER',
),
McUserSearchResult(
username: 'j.beispiel',
firstName: 'Jonas',
lastName: 'Beispiel',
userType: 'TEACHER',
),
McUserSearchResult(
username: 'l.schueler',
firstName: 'Lena',
lastName: 'Schüler',
userType: 'STUDENT',
className: '9c',
),
McUserSearchResult(
username: 'p.probe',
firstName: 'Paul',
lastName: 'Probe',
userType: 'STUDENT',
className: 'Q2',
),
];
static List<McUserSearchResult> search(String query) {
final q = query.trim().toLowerCase();
if (q.length < 2) return const [];
return _all
.where(
(u) =>
u.firstName.toLowerCase().contains(q) ||
u.lastName.toLowerCase().contains(q) ||
u.username.toLowerCase().contains(q) ||
'${u.firstName} ${u.lastName}'.toLowerCase().contains(q),
)
.toList();
}
}
+64
View File
@@ -0,0 +1,64 @@
import 'data/demo_absence.dart';
import 'data/demo_breaker.dart';
import 'data/demo_holidays.dart';
import 'data/demo_timetable.dart';
import 'data/demo_users.dart';
/// Single source of truth for the MarianumConnect demo responses. The demo
/// interceptor asks this for the body of any MC request. Read endpoints reuse
/// the typed fixtures and serialize them to the exact shape the matching query
/// parses (a bare list vs. an object); write/side-effect endpoints (telemetry,
/// client-errors, auth, push, feedback) have no consumed body and resolve to an
/// empty success.
class DemoMarianumConnect {
const DemoMarianumConnect._();
/// Body for [path] (relative to `/api/mobile/v1/`), using [query] for the
/// timetable date window.
static Object responseBody(String path, Map<String, dynamic> query) {
switch (path) {
case 'timetable/me':
return DemoTimetable.week(
_date(query['from']),
_date(query['until']),
).toJson();
case 'timetable/schoolyear':
return DemoTimetable.schoolyear().toJson();
case 'timetable/rooms':
return DemoTimetable.rooms().result.map((e) => e.toJson()).toList();
case 'timetable/subjects':
return DemoTimetable.subjects().result.map((e) => e.toJson()).toList();
case 'timetable/timegrid':
return DemoTimetable.timegrid().result.map((e) => e.toJson()).toList();
case 'timetable/holidays':
return DemoTimetable.holidays().result.map((e) => e.toJson()).toList();
case 'holidays':
return DemoHolidays.upcoming().map((e) => e.toJson()).toList();
case 'users/search':
return DemoUsers.search(query['q']?.toString() ?? '')
.map((e) => e.toJson())
.toList();
case 'breaker':
return DemoBreaker.none().toJson();
case 'absence/classes':
return DemoAbsence.classes();
case 'absence/prefill':
return DemoAbsence.prefill().toJson();
case 'timetable/elements/teachers':
case 'timetable/elements/students':
case 'timetable/elements/classes':
return const <dynamic>[];
}
// A foreign element's week (timetable/<type>/<id>) shows the same demo week.
if (path.startsWith('timetable/')) {
return DemoTimetable.week(
_date(query['from']),
_date(query['until']),
).toJson();
}
return const <String, dynamic>{};
}
static DateTime _date(Object? raw) =>
DateTime.tryParse(raw?.toString() ?? '') ?? DateTime.now();
}
@@ -0,0 +1,35 @@
import 'package:dio/dio.dart';
import 'demo_marianumconnect.dart';
import 'demo_mode.dart';
/// Answers every MarianumConnect request from fixtures while a demo session is
/// active, so the app makes no MC network calls. Installed first on the MC dio
/// so it resolves before the auth interceptor tries to attach a (non-existent)
/// bearer token.
class DemoMarianumConnectInterceptor extends Interceptor {
static const _apiMarker = '/api/mobile/v1/';
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
if (!DemoMode.active) {
handler.next(options);
return;
}
handler.resolve(
Response<dynamic>(
requestOptions: options,
statusCode: 200,
data: DemoMarianumConnect.responseBody(
_relativePath(options.uri),
options.queryParameters,
),
),
);
}
static String _relativePath(Uri uri) {
final i = uri.path.indexOf(_apiMarker);
return i >= 0 ? uri.path.substring(i + _apiMarker.length) : uri.path;
}
}
+27
View File
@@ -0,0 +1,27 @@
import '../../model/account_data.dart';
/// Central switch for the client-side demo mode.
///
/// A login whose username starts with [usernamePrefix] (the password is
/// ignored) enters demo mode: instead of talking to the real backends, the app
/// serves curated fixtures from `DemoData` for every feature. This lets Google
/// Play reviewers and automated screenshot runs see a fully populated app
/// without a real account and without any network dependency.
///
/// The flag is persisted through [AccountData.isDemo], so a demo session
/// survives cold starts exactly like a normal login. It works in release builds
/// too — reviewers run the shipped release build.
class DemoMode {
const DemoMode._();
/// Usernames starting with this prefix trigger demo mode. Whatever the
/// reviewer types as the password is accepted.
static const String usernamePrefix = 'demo@';
/// Whether [username], as typed on the login screen, should enter demo mode.
static bool matches(String username) =>
username.trim().toLowerCase().startsWith(usernamePrefix);
/// True while the active session is a demo session.
static bool get active => AccountData().isDemo;
}
+58
View File
@@ -0,0 +1,58 @@
/// Shared identity for the demo mode so fixtures across every feature describe
/// the same fictional student, class, teachers and rooms — the timetable, chats
/// and grades all line up. See DemoMode.
class DemoPersona {
const DemoPersona._();
static const String studentFirstName = 'Lena';
static const String studentName = 'Lena Hoffmann';
static const String className = '10b';
static const List<DemoTeacher> teachers = [
DemoTeacher('Herr Müller', 'MÜL', 'Mathematik'),
DemoTeacher('Frau Schmidt', 'SMT', 'Deutsch'),
DemoTeacher('Herr Weber', 'WEB', 'Englisch'),
DemoTeacher('Frau Becker', 'BEC', 'Biologie'),
DemoTeacher('Herr Hofmann', 'HOF', 'Physik'),
DemoTeacher('Frau Wagner', 'WAG', 'Geschichte'),
DemoTeacher('Herr Klein', 'KLN', 'Sport'),
DemoTeacher('Frau Richter', 'RIC', 'Religion'),
];
static const List<DemoSubject> subjects = [
DemoSubject('Mathematik', 'M', 0xFF4E79A7),
DemoSubject('Deutsch', 'D', 0xFFE15759),
DemoSubject('Englisch', 'E', 0xFFF28E2B),
DemoSubject('Biologie', 'BIO', 0xFF59A14F),
DemoSubject('Physik', 'PH', 0xFF76B7B2),
DemoSubject('Geschichte', 'GE', 0xFFB07AA1),
DemoSubject('Sport', 'SP', 0xFFEDC948),
DemoSubject('Religion', 'REL', 0xFF9C755F),
];
static const List<String> rooms = [
'A101',
'A102',
'B201',
'B203',
'C305',
'Turnhalle 1',
'Aula',
];
}
class DemoTeacher {
final String name;
final String short;
final String subject;
const DemoTeacher(this.name, this.short, this.subject);
}
class DemoSubject {
final String name;
final String short;
/// ARGB color used where the UI colors a lesson/subject.
final int color;
const DemoSubject(this.name, this.short, this.color);
}
+90
View File
@@ -0,0 +1,90 @@
/// A backend-independent emergency notice loaded from a foreign server URL —
/// frontmatter (control fields) plus a free Markdown body, so it stays
/// hand-writable in an outage. See [parse] for the format.
class EmergencyNotice {
/// `false` renders full-screen and blocks back/barrier taps.
final bool dismissible;
final String? title;
final String body;
const EmergencyNotice({
required this.dismissible,
required this.title,
required this.body,
});
/// Parses the raw file, or returns `null` when there is nothing to show.
/// Never throws — malformed input yields `null` so a broken file can't break
/// the app.
///
/// ```
/// ---
/// active: true # required truthy, else null; # lines are comments
/// dismissible: true # default true
/// title: Störung # optional
/// ---
/// Free **markdown** body (everything after the closing ---).
/// ```
static EmergencyNotice? parse(String raw) {
final lines = raw
.replaceAll('\r\n', '\n')
.replaceAll('\r', '\n')
.split('\n');
var i = 0;
while (i < lines.length && lines[i].trim().isEmpty) {
i++;
}
if (i >= lines.length || lines[i].trim() != '---') return null;
final openIndex = i;
var closeIndex = -1;
for (var j = openIndex + 1; j < lines.length; j++) {
if (lines[j].trim() == '---') {
closeIndex = j;
break;
}
}
if (closeIndex == -1) return null;
final meta = <String, String>{};
for (var j = openIndex + 1; j < closeIndex; j++) {
final line = lines[j].trim();
if (line.isEmpty || line.startsWith('#')) continue;
final sep = line.indexOf(':');
if (sep <= 0) continue;
final key = line.substring(0, sep).trim().toLowerCase();
final value = line.substring(sep + 1).trim();
meta[key] = value;
}
if (_parseBool(meta['active']) != true) return null;
final body = lines.sublist(closeIndex + 1).join('\n').trim();
if (body.isEmpty) return null;
final title = meta['title'];
return EmergencyNotice(
dismissible: _parseBool(meta['dismissible']) ?? true,
title: (title == null || title.isEmpty) ? null : title,
body: body,
);
}
static bool? _parseBool(String? value) {
switch (value?.trim().toLowerCase()) {
case 'true':
case 'yes':
case '1':
case 'on':
return true;
case 'false':
case 'no':
case '0':
case 'off':
return false;
default:
return null;
}
}
}
@@ -0,0 +1,51 @@
import 'package:dio/dio.dart';
import 'emergency_notice.dart';
/// Loads the emergency notice from a foreign URL over a standalone [Dio] — no
/// MarianumConnect interceptors/base URL/auth, so it survives a backend outage.
/// Never throws: any failure yields `null` (nothing shown).
///
/// A [cacheTtl] in-memory throttle keeps rapid resumes from hammering the host;
/// it lives only for the process, so a cold start always fetches fresh.
class EmergencyNoticeClient {
EmergencyNoticeClient();
static const Duration cacheTtl = Duration(minutes: 1);
EmergencyNotice? _cached;
String? _cachedUrl;
DateTime? _cachedAt;
Future<EmergencyNotice?> fetch(String url) async {
final cachedAt = _cachedAt;
if (cachedAt != null &&
_cachedUrl == url &&
DateTime.now().difference(cachedAt) < cacheTtl) {
return _cached;
}
EmergencyNotice? result;
try {
final dio = Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
sendTimeout: const Duration(seconds: 5),
responseType: ResponseType.plain,
),
);
final response = await dio.get<String>(url);
final raw = response.data;
result = (raw == null || raw.isEmpty) ? null : EmergencyNotice.parse(raw);
} catch (_) {
result = null;
}
// Cache failures too, so a down server isn't retried on every resume.
_cached = result;
_cachedUrl = url;
_cachedAt = DateTime.now();
return result;
}
}
+42 -4
View File
@@ -3,16 +3,18 @@ import 'dart:io';
import 'package:dio/dio.dart';
import 'package:http/http.dart' as http;
import 'package:nextcloud/nextcloud.dart';
import '../api_error.dart';
import '../http_errors.dart';
import '../marianumcloud/talk/talk_error.dart';
import '../webuntis/webuntis_error.dart';
import 'app_exception.dart';
import 'auth_exception.dart';
import 'network_exception.dart';
import 'not_found_exception.dart';
import 'parse_exception.dart';
import 'server_exception.dart';
import 'talk_exception.dart';
import 'webuntis_exception.dart';
const String _defaultFallback =
'Etwas ist schiefgelaufen. Bitte versuche es erneut.';
@@ -25,6 +27,7 @@ AppException? _dioToAppException(DioException error) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
case DioExceptionType.transformTimeout:
return NetworkException.timeout(technicalDetails: error.message);
case DioExceptionType.connectionError:
return NetworkException(technicalDetails: error.message);
@@ -52,18 +55,47 @@ AppException? _dioToAppException(DioException error) {
}
}
/// The nextcloud/dynamite clients throw [DynamiteApiException] on non-2xx.
/// 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).
AppException _dynamiteToAppException(DynamiteApiException error) {
final status = error.statusCode;
final preview = previewBody(error.body);
final detail = preview.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview';
switch (status) {
case 401:
return AuthException.unauthorized(technicalDetails: detail);
case 403:
return AuthException.forbidden(technicalDetails: detail);
case 404:
return NotFoundException(technicalDetails: detail);
case 429:
return ServerException(
statusCode: status,
userMessage:
'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut versuchst.',
technicalDetails: detail,
);
default:
return ServerException(statusCode: status, technicalDetails: detail);
}
}
String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
if (error == null) return fallback;
if (error is AppException) return error.userMessage;
if (error is TalkError) return TalkException(error).userMessage;
if (error is WebuntisError) return WebuntisException(error).userMessage;
if (error is DioException) {
final mapped = _dioToAppException(error);
if (mapped != null) return mapped.userMessage;
}
if (error is DynamiteApiException) {
return _dynamiteToAppException(error).userMessage;
}
if (error is SocketException) {
return const NetworkException().userMessage;
}
@@ -90,11 +122,14 @@ String? errorToTechnicalDetails(Object? error) {
if (error == null) return null;
if (error is AppException) return error.technicalDetails ?? error.toString();
if (error is TalkError) return TalkException(error).technicalDetails;
if (error is WebuntisError) return WebuntisException(error).technicalDetails;
if (error is DioException) {
final mapped = _dioToAppException(error);
if (mapped != null) return mapped.technicalDetails ?? mapped.toString();
}
if (error is DynamiteApiException) {
final mapped = _dynamiteToAppException(error);
return mapped.technicalDetails ?? mapped.toString();
}
return error.toString();
}
@@ -105,6 +140,9 @@ bool errorAllowsRetry(Object? error) {
final mapped = _dioToAppException(error);
if (mapped != null) return mapped.allowRetry;
}
if (error is DynamiteApiException) {
return _dynamiteToAppException(error).allowRetry;
}
return true;
}
+4 -1
View File
@@ -8,9 +8,12 @@ class ServerException extends AppException {
String? userMessage,
super.technicalDetails,
}) : super(
// Status code intentionally not in the user message — it lives in
// technicalDetails behind "Details anzeigen".
userMessage:
userMessage ??
'Der Server hat gerade Probleme (Status $statusCode). Bitte später erneut versuchen.',
'Der Server konnte die Anfrage gerade nicht verarbeiten. '
'Bitte versuche es in einem Moment erneut.',
allowRetry: true,
);
}
+2 -1
View File
@@ -26,7 +26,8 @@ class TalkException extends AppException {
return 'Zu viele Anfragen. Bitte kurz warten und erneut versuchen.';
default:
if (e.code >= 500) {
return 'Talk-Server hat gerade Probleme (${e.code}).';
return 'Der Chat-Server konnte die Anfrage gerade nicht verarbeiten. '
'Bitte versuche es in einem Moment erneut.';
}
return e.message.isNotEmpty
? e.message
@@ -0,0 +1,19 @@
import 'app_exception.dart';
/// Raised when a CONTENT ticker page has no native ProseMirror payload yet
/// (404 `CONTENT_UNAVAILABLE`). Carries the site-relative [webUrl] so the UI
/// can offer to open the page in the browser instead. Not retryable — the
/// content only appears after an admin re-saves the page.
class TickerContentUnavailableException extends AppException {
final String? webUrl;
const TickerContentUnavailableException({
this.webUrl,
super.technicalDetails,
}) : super(
userMessage:
'Diese Seite ist in der App noch nicht verfügbar. '
'Du kannst sie im Browser öffnen.',
allowRetry: false,
);
}
-31
View File
@@ -1,31 +0,0 @@
import '../webuntis/webuntis_error.dart';
import 'app_exception.dart';
class WebuntisException extends AppException {
final WebuntisError source;
WebuntisException(this.source)
: super(
userMessage: _mapMessage(source),
technicalDetails: 'WebUntis (${source.code}): ${source.message}',
allowRetry: true,
);
static String _mapMessage(WebuntisError e) {
switch (e.code) {
case -8504:
case -8502:
return 'WebUntis-Anmeldung abgelaufen. Bitte erneut anmelden.';
case -8520:
return 'Bitte melde dich erneut an.';
case -7004:
return 'Für diesen Zeitraum sind keine Stundenplandaten verfügbar.';
case -32601:
return 'WebUntis kennt diese Anfrage nicht. Bitte App aktualisieren.';
default:
return e.message.isNotEmpty
? 'WebUntis: ${e.message}'
: 'WebUntis konnte die Anfrage nicht bearbeiten (Code ${e.code}).';
}
}
}
+53
View File
@@ -0,0 +1,53 @@
import 'dart:async';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'errors/auth_exception.dart';
import 'errors/network_exception.dart';
import 'errors/not_found_exception.dart';
import 'errors/server_exception.dart';
/// Runs [send] and converts transport-level failures (socket/timeout/client
/// errors) into a [NetworkException] tagged with [label] (e.g. `Talk <uri>`).
/// Passes through whatever [send] produces, including `null` for the base-class
/// request hooks that may skip the call.
Future<http.Response?> sendGuarded(
String label,
Future<http.Response>? Function() send,
) async {
try {
return await send();
} on SocketException catch (e) {
throw NetworkException(technicalDetails: '$label: ${e.message}');
} on TimeoutException catch (e) {
throw NetworkException.timeout(technicalDetails: '$label: $e');
} on http.ClientException catch (e) {
throw NetworkException(technicalDetails: '$label: ${e.message}');
}
}
/// Collapses whitespace and caps an HTTP error body at 500 chars so it can be
/// embedded in an [AppException]'s technical details without dumping headers.
String previewBody(String body) {
final collapsed = body.replaceAll(RegExp(r'\s+'), ' ').trim();
return collapsed.length > 500 ? '${collapsed.substring(0, 500)}' : collapsed;
}
/// Builds a `<label> -> HTTP <status>[ body=<preview>]` technical detail line.
String httpErrorDetail(String label, String body, int status) {
final preview = previewBody(body);
return preview.isEmpty
? '$label -> HTTP $status'
: '$label -> HTTP $status body=$preview';
}
/// Throws the [AppException] matching a non-2xx HTTP [status], carrying
/// [detail] as technical details: 401/403 map to auth errors, 404 to
/// not-found, everything else to a generic server error.
Never throwForStatus(int status, String detail) {
if (status == 401) throw AuthException.unauthorized(technicalDetails: detail);
if (status == 403) throw AuthException.forbidden(technicalDetails: detail);
if (status == 404) throw NotFoundException(technicalDetails: detail);
throw ServerException(statusCode: status, technicalDetails: detail);
}
@@ -0,0 +1,25 @@
import 'package:http/http.dart' as http;
import '../nextcloud_ocs.dart';
/// Revokes an app password server-side via
/// `DELETE /ocs/v2.php/core/apppassword`. The endpoint revokes the credential
/// the request authenticates WITH — by default the shared OCS headers (general
/// app password); pass [authorizationHeader] to revoke another one (the Talk
/// app password). Best-effort: the result is ignored — logout clears local
/// state regardless.
class DeleteAppPassword {
final http.Client _client;
DeleteAppPassword({http.Client? client}) : _client = client ?? http.Client();
Future<void> run({String? authorizationHeader}) async {
await _client.delete(
NextcloudOcs.uri('core/apppassword'),
headers: {
...NextcloudOcs.headers(),
'Authorization': ?authorizationHeader,
},
);
}
}
@@ -0,0 +1,45 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../../model/account_data.dart';
import '../nextcloud_ocs.dart';
/// Exchanges the user's real Nextcloud password for a scoped app password via
/// `GET /ocs/v2.php/core/getapppassword`. All subsequent Nextcloud calls then
/// authenticate with the app password (see [AccountData.getBasicAuthHeader]),
/// which is what the push-v2 registration binds to.
///
/// Must authenticate with the *real* password — an app password cannot mint
/// another one.
class GetAppPassword {
final http.Client _client;
GetAppPassword({http.Client? client}) : _client = client ?? http.Client();
/// Returns the freshly minted app password. Throws on any transport or
/// protocol error — callers treat push registration as best-effort and swallow
/// failures.
Future<String> run() async {
final response = await _client.get(
NextcloudOcs.uri('core/getapppassword'),
headers: {
...NextcloudOcs.headers(),
// Deliberately NOT the shared Authorization value: that one prefers
// the app password, but an app password cannot mint another one —
// this endpoint requires the real password.
'Authorization': AccountData().getRealPasswordBasicAuthHeader(),
},
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('getapppassword HTTP ${response.statusCode}');
}
final json = jsonDecode(utf8.decode(response.bodyBytes));
final data = (json as Map)['ocs']?['data'];
final appPassword = data is Map ? data['apppassword'] as String? : null;
if (appPassword == null || appPassword.isEmpty) {
throw Exception('getapppassword: no apppassword in response');
}
return appPassword;
}
}
@@ -1,32 +1,42 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../../../model/endpoint_data.dart';
import '../../errors/server_exception.dart';
import '../nextcloud_ocs.dart';
import 'autocomplete_response.dart';
class AutocompleteApi {
Future<AutocompleteResponse> find(String query) async {
final endpoint = NextcloudOcs.uri(
'core/autocomplete/get',
queryParameters: {
/// Searches sharees (users by default). Pass [shareTypes] to widen the search
/// — e.g. `[0, 1]` for both users and groups (0 = user, 1 = group).
Future<AutocompleteResponse> find(
String query, {
List<int> shareTypes = const [0],
}) async {
// NextcloudOcs.uri serialises every query value via `toString()`, which
// would turn the `shareTypes[]` list into `"[0, 1]"`. Build the Uri here so
// Dart encodes the list as repeated `shareTypes[]=0&shareTypes[]=1` params.
final endpoint = EndpointData().nextcloud();
final uri = Uri.https(
endpoint.domain,
'${endpoint.path}/ocs/v2.php/core/autocomplete/get',
{
'format': 'json',
'search': query,
'itemType': ' ',
'itemId': ' ',
'shareTypes[]': ['0'],
'shareTypes[]': shareTypes.map((t) => t.toString()).toList(),
'limit': '10',
},
);
final response = await http.get(endpoint, headers: NextcloudOcs.headers());
final response = await http.get(uri, headers: NextcloudOcs.headers());
if (response.statusCode != HttpStatus.ok) {
throw Exception(
'Api call failed with ${response.statusCode}: ${response.body}',
throw ServerException(
statusCode: response.statusCode,
technicalDetails: 'core/autocomplete/get: ${response.body}',
);
}
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
return AutocompleteResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
return AutocompleteResponse.fromJson(NextcloudOcs.decode(response.body));
}
}
@@ -1,7 +1,17 @@
import 'package:json_annotation/json_annotation.dart';
import '../files_sharing/queries/share/share.dart';
part 'autocomplete_response.g.dart';
/// Maps an autocomplete result's `source` to the matching Nextcloud share type.
/// Groups become [kShareTypeGroup]; everything else (users, and any unknown
/// source) defaults to [kShareTypeUser].
int shareTypeFromSource(String? source) {
if (source != null && source.startsWith('groups')) return kShareTypeGroup;
return kShareTypeUser;
}
@JsonSerializable(explicitToJson: true)
class AutocompleteResponse {
List<AutocompleteResponseObject> data;
@@ -0,0 +1,46 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../../errors/server_exception.dart';
import '../nextcloud_ocs.dart';
import 'nextcloud_sharing_capabilities.dart';
/// Fetches the current user's Nextcloud capabilities via OCS
/// `GET cloud/capabilities` and extracts the `files_sharing` block. This is the
/// per-user, group-aware source of truth the sharing UI gates on — no custom
/// backend involved.
class GetNextcloudCapabilities {
Future<NextcloudSharingCapabilities> run() async {
final endpoint = NextcloudOcs.uri(
'cloud/capabilities',
queryParameters: {'format': 'json'},
);
final response = await http.get(endpoint, headers: NextcloudOcs.headers());
if (response.statusCode != HttpStatus.ok) {
throw ServerException(
statusCode: response.statusCode,
technicalDetails: 'cloud/capabilities: ${response.body}',
);
}
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
final data =
(decoded['ocs'] as Map<String, dynamic>?)?['data']
as Map<String, dynamic>?;
final capabilities = data?['capabilities'] as Map<String, dynamic>?;
final filesSharing = capabilities?['files_sharing'];
if (filesSharing is! Map<String, dynamic>) {
// Server doesn't advertise files_sharing (app disabled) — treat as no
// sharing capability rather than failing the whole load.
return const NextcloudSharingCapabilities();
}
final passwordPolicy = capabilities?['password_policy'];
return NextcloudSharingCapabilities.fromFilesSharing(
filesSharing,
passwordPolicy: passwordPolicy is Map<String, dynamic>
? passwordPolicy
: null,
);
}
}
@@ -0,0 +1,131 @@
/// Subset of Nextcloud's `files_sharing` capabilities block that the mobile
/// sharing UI gates on. Nextcloud reports these per authenticated user, so a
/// group that an admin excluded from creating public links sees
/// `public.enabled == false` here — exactly how the web UI hides those buttons.
///
/// The block is deeply nested and varies between server versions, so this is
/// parsed by hand from the raw OCS map with safe fallbacks rather than via
/// code generation. Missing fields default to the most restrictive value so a
/// newer/older server never accidentally unlocks a capability.
class NextcloudSharingCapabilities {
/// `files_sharing.api_enabled` — master switch. When false the user may not
/// create any share (user, group or link).
final bool apiEnabled;
/// `files_sharing.public.enabled` — public link shares allowed.
final bool publicEnabled;
/// `files_sharing.public.multiple_links` — more than one link per file.
final bool publicMultipleLinks;
/// `files_sharing.public.upload` — public upload / file-drop folders.
final bool publicUploadEnabled;
/// `files_sharing.public.password.enforced` — a password is mandatory on
/// public links, so the create flow must collect one upfront.
final bool publicPasswordEnforced;
/// `files_sharing.public.expire_date.enabled`.
final bool publicExpireEnabled;
/// `files_sharing.public.expire_date.days` — default/maximum lifetime.
final int? publicExpireDays;
/// `files_sharing.public.expire_date.enforced` — expiry cannot be removed.
final bool publicExpireEnforced;
/// `files_sharing.group.enabled` (falls back to the older `group_sharing`).
final bool groupEnabled;
/// `files_sharing.resharing` — recipients may reshare.
final bool resharing;
// --- password_policy (a sibling capability of files_sharing) ---
// These let the link-password UI state the rules up front instead of only
// surfacing them after the server rejects a weak password. The
// "non-common password" (breach) check can only be enforced server-side.
/// `password_policy.minLength`.
final int? passwordMinLength;
/// `password_policy.enforceUpperLowerCase`.
final bool passwordEnforceUpperLower;
/// `password_policy.enforceNumericCharacters`.
final bool passwordEnforceNumeric;
/// `password_policy.enforceSpecialCharacters`.
final bool passwordEnforceSpecial;
const NextcloudSharingCapabilities({
this.apiEnabled = false,
this.publicEnabled = false,
this.publicMultipleLinks = false,
this.publicUploadEnabled = false,
this.publicPasswordEnforced = false,
this.publicExpireEnabled = false,
this.publicExpireDays,
this.publicExpireEnforced = false,
this.groupEnabled = false,
this.resharing = false,
this.passwordMinLength,
this.passwordEnforceUpperLower = false,
this.passwordEnforceNumeric = false,
this.passwordEnforceSpecial = false,
});
/// Parses the `files_sharing` sub-map of an OCS `cloud/capabilities`
/// response, plus the optional sibling `password_policy` map. Tolerates
/// missing intermediate maps and type drift.
factory NextcloudSharingCapabilities.fromFilesSharing(
Map<String, dynamic> filesSharing, {
Map<String, dynamic>? passwordPolicy,
}) {
Map<String, dynamic>? sub(Map<String, dynamic>? m, String key) {
final value = m?[key];
return value is Map<String, dynamic> ? value : null;
}
bool boolAt(Map<String, dynamic>? m, String key) => m?[key] == true;
int? intAt(Map<String, dynamic>? m, String key) {
final v = m?[key];
if (v is int) return v;
if (v is String) return int.tryParse(v);
return null;
}
final public = sub(filesSharing, 'public');
final password = sub(public, 'password');
final expire = sub(public, 'expire_date');
final group = sub(filesSharing, 'group');
return NextcloudSharingCapabilities(
apiEnabled: boolAt(filesSharing, 'api_enabled'),
publicEnabled: boolAt(public, 'enabled'),
publicMultipleLinks: boolAt(public, 'multiple_links'),
publicUploadEnabled: boolAt(public, 'upload'),
publicPasswordEnforced: boolAt(password, 'enforced'),
publicExpireEnabled: boolAt(expire, 'enabled'),
publicExpireDays: intAt(expire, 'days'),
publicExpireEnforced: boolAt(expire, 'enforced'),
// Newer servers nest it under `group.enabled`; older ones expose a flat
// `group_sharing` boolean.
groupEnabled:
boolAt(group, 'enabled') || boolAt(filesSharing, 'group_sharing'),
resharing: boolAt(filesSharing, 'resharing'),
passwordMinLength: intAt(passwordPolicy, 'minLength'),
passwordEnforceUpperLower: boolAt(
passwordPolicy,
'enforceUpperLowerCase',
),
passwordEnforceNumeric: boolAt(
passwordPolicy,
'enforceNumericCharacters',
),
passwordEnforceSpecial: boolAt(
passwordPolicy,
'enforceSpecialCharacters',
),
);
}
}
@@ -0,0 +1,113 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import '../../../model/account_data.dart';
import '../../../model/endpoint_data.dart';
import '../../errors/parse_exception.dart';
import '../../http_errors.dart';
import '../nextcloud_ocs.dart';
/// Mix of two Nextcloud surfaces:
/// - User info comes from the OCS provisioning API
/// (`/ocs/v2.php/cloud/users/{userId}`).
/// - The own-avatar upload/delete uses the *core* AvatarController at
/// `/avatar/` — the OCS provisioning route has no POST (it answers 405).
/// This is the same controller the read path (`/avatar/{id}/{size}` in
/// [UserAvatar]) already talks to. CSRF is bypassed because we use Basic
/// Auth without a session cookie.
/// Core AvatarController endpoint for the logged-in user (POST sets, DELETE
/// removes). Built against the bare Nextcloud base (domain + optional path),
/// not the OCS wrapper.
Uri _coreAvatarUri() {
final endpoint = EndpointData().nextcloud();
return Uri.https(endpoint.domain, '${endpoint.path}/avatar/');
}
Uri _userInfoUri() =>
NextcloudOcs.uri('cloud/users/${AccountData().getUsername()}');
Future<http.Response> _send(
Future<http.Response> Function(Uri uri, Map<String, String> headers)
perform,
Uri uri,
) async {
final headers = NextcloudOcs.headers();
final response = (await sendGuarded(
'Cloud $uri',
() => perform(uri, headers),
))!;
final status = response.statusCode;
if (status >= 200 && status < 300) return response;
final detail = httpErrorDetail('Cloud $uri', response.body, status);
log(detail);
throwForStatus(status, detail);
}
class SetUserAvatar {
final Uint8List bytes;
final String filename;
SetUserAvatar(this.bytes, {this.filename = 'avatar.jpg'});
Future<void> run() async {
await _send((uri, headers) async {
// Core AvatarController reads $_FILES['files']['error'][0] — the field
// must be `files[]` so PHP exposes it as an array, matching the web UI.
final req = http.MultipartRequest('POST', uri)
..headers.addAll(headers)
..files.add(
http.MultipartFile.fromBytes('files[]', bytes, filename: filename),
);
final streamed = await req.send();
return http.Response.fromStream(streamed);
}, _coreAvatarUri());
}
}
class DeleteUserAvatar {
Future<void> run() async {
await _send(
(uri, headers) => http.delete(uri, headers: headers),
_coreAvatarUri(),
);
}
}
class CloudUserInfo {
final String userId;
final String displayName;
const CloudUserInfo({required this.userId, required this.displayName});
}
/// Reads the current user's provisioning record. The OCS wrapper looks like:
/// `{ "ocs": { "meta": {...}, "data": { "id": "...", "displayname": "...", ... } } }`.
/// We only need displayname; everything else is discarded.
class GetUserInfo {
Future<CloudUserInfo> run() async {
final uri = _userInfoUri();
final response = await _send(
(u, headers) => http.get(u, headers: headers),
uri,
);
try {
final root = jsonDecode(response.body) as Map<String, dynamic>;
final data =
(root['ocs'] as Map<String, dynamic>)['data']
as Map<String, dynamic>;
return CloudUserInfo(
userId: data['id'] as String,
displayName: (data['displayname'] as String?) ?? '',
);
} catch (e) {
throw ParseException(
technicalDetails: 'Cloud $uri user info parse: $e',
);
}
}
}
@@ -1,21 +1,144 @@
import 'dart:io';
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../errors/server_exception.dart';
import '../nextcloud_ocs.dart';
import 'file_sharing_api_params.dart';
import 'queries/share/share.dart';
import 'queries/share/share_update_params.dart';
/// OCS `files_sharing` API (Nextcloud Sharing API v1). Per the official docs:
/// list/delete pass parameters via the URL (query / path), create and update
/// pass them in the request **body** (form-urlencoded), and every call adds
/// `format=json` so the server replies with JSON instead of XML.
///
/// All calls surface failures as [ServerException] so they map to friendly
/// messages via `errorToUserMessage`.
class FileSharingApi {
Future<void> share(FileSharingApiParams query) async {
static const String _base = 'apps/files_sharing/api/v1/shares';
/// Creates a share. Returns the created [Share] (callers that don't need it —
/// e.g. Talk file sharing — can ignore the result).
Future<Share> share(FileSharingApiParams query) async {
final endpoint = NextcloudOcs.uri(
'apps/files_sharing/api/v1/shares',
queryParameters: query.toJson(),
_base,
queryParameters: {'format': 'json'},
);
final response = await http.post(endpoint, headers: NextcloudOcs.headers());
if (response.statusCode != HttpStatus.ok) {
throw Exception(
'Api call failed with ${response.statusCode}: ${response.body}',
final response = await http.post(
endpoint,
headers: NextcloudOcs.headers(),
body: _stringForm(query.toJson()),
);
return _decodeShare(response, action: 'Freigabe erstellen');
}
/// Lists shares for the given OCS [path] (see `ocs_path.dart`). [reshares]
/// includes shares the current user received and re-shared.
Future<List<Share>> listForPath(String path, {bool reshares = false}) async {
final endpoint = NextcloudOcs.uri(
_base,
queryParameters: {
'format': 'json',
'path': path,
'reshares': reshares.toString(),
'subfiles': 'false',
},
);
final response = await http.get(endpoint, headers: NextcloudOcs.headers());
final data = _decodeData(response, action: 'Freigaben laden');
if (data is! List) return const [];
return data
.whereType<Map<String, dynamic>>()
.map(Share.fromJson)
.toList(growable: false);
}
/// Updates an existing share. Returns the updated [Share].
Future<Share> update(int shareId, ShareUpdateParams params) async {
final endpoint = NextcloudOcs.uri(
'$_base/$shareId',
queryParameters: {'format': 'json'},
);
final response = await http.put(
endpoint,
headers: NextcloudOcs.headers(),
body: params.toQuery(),
);
return _decodeShare(response, action: 'Freigabe ändern');
}
/// Deletes (revokes) a share.
Future<void> remove(int shareId) async {
final endpoint = NextcloudOcs.uri(
'$_base/$shareId',
queryParameters: {'format': 'json'},
);
final response = await http.delete(
endpoint,
headers: NextcloudOcs.headers(),
);
_decodeData(response, action: 'Freigabe löschen');
}
/// Stringifies a json map into form fields (the OCS body is
/// `application/x-www-form-urlencoded`). Null values are already dropped by
/// the params' `includeIfNull: false`.
Map<String, String> _stringForm(Map<String, dynamic> json) =>
json.map((key, value) => MapEntry(key, value.toString()));
/// Decodes a single-share response (create/update). Throws if the payload is
/// not a share object.
Share _decodeShare(http.Response response, {required String action}) {
final data = _decodeData(response, action: action);
if (data is! Map<String, dynamic>) {
throw ServerException(
statusCode: response.statusCode,
technicalDetails: 'Unerwartete Antwort für "$action": ${response.body}',
);
}
return Share.fromJson(data);
}
/// Validates the HTTP/OCS envelope and returns the `ocs.data` payload, or
/// throws a [ServerException] carrying the server's OCS message when present.
///
/// Every access is type-checked rather than cast: PHP's `json_encode` turns
/// an empty associative array into a JSON array (`[]`), and `statuscode` can
/// arrive as a string — a hard `as` cast on either would throw a raw
/// TypeError that surfaces as the generic "something went wrong" message.
Object? _decodeData(http.Response response, {required String action}) {
dynamic decoded;
try {
decoded = jsonDecode(response.body);
} catch (_) {
decoded = null;
}
final ocs = decoded is Map<String, dynamic> ? decoded['ocs'] : null;
final ocsMap = ocs is Map<String, dynamic> ? ocs : null;
final meta = ocsMap?['meta'];
final metaMap = meta is Map<String, dynamic> ? meta : null;
final rawStatus = metaMap?['statuscode'];
final ocsStatus = rawStatus is int
? rawStatus
: (rawStatus is String ? int.tryParse(rawStatus) : null);
final rawMessage = metaMap?['message'];
final ocsMessage = rawMessage is String ? rawMessage : null;
// OCS v2 mirrors the HTTP status; accept any 2xx. Success OCS statuscodes
// are 100 (v1 carry-over) or 200.
final httpOk = response.statusCode >= 200 && response.statusCode < 300;
final ocsOk = ocsStatus == null || ocsStatus == 100 || ocsStatus == 200;
if (!httpOk || !ocsOk) {
throw ServerException(
statusCode: response.statusCode,
userMessage: ocsMessage != null && ocsMessage.isNotEmpty
? '$action fehlgeschlagen: $ocsMessage'
: null,
technicalDetails: '$action: ${response.statusCode} ${response.body}',
);
}
return ocsMap?['data'];
}
}
@@ -2,7 +2,10 @@ import 'package:json_annotation/json_annotation.dart';
part 'file_sharing_api_params.g.dart';
@JsonSerializable()
// includeIfNull:false so the optional sharing fields below are only sent when
// set — otherwise the OCS query would carry `permissions=null` etc. and be
// rejected.
@JsonSerializable(includeIfNull: false)
class FileSharingApiParams {
int shareType;
String shareWith;
@@ -10,12 +13,24 @@ class FileSharingApiParams {
String? referenceId;
String? talkMetaData;
/// Permission bitmask (see `share_permissions.dart`).
int? permissions;
/// Public link password.
String? password;
/// Expiry as `YYYY-MM-DD`.
String? expireDate;
FileSharingApiParams({
required this.shareType,
required this.shareWith,
required this.path,
this.referenceId,
this.talkMetaData,
this.permissions,
this.password,
this.expireDate,
});
factory FileSharingApiParams.fromJson(Map<String, dynamic> json) =>
@@ -14,6 +14,9 @@ FileSharingApiParams _$FileSharingApiParamsFromJson(
path: json['path'] as String,
referenceId: json['referenceId'] as String?,
talkMetaData: json['talkMetaData'] as String?,
permissions: (json['permissions'] as num?)?.toInt(),
password: json['password'] as String?,
expireDate: json['expireDate'] as String?,
);
Map<String, dynamic> _$FileSharingApiParamsToJson(
@@ -22,6 +25,9 @@ Map<String, dynamic> _$FileSharingApiParamsToJson(
'shareType': instance.shareType,
'shareWith': instance.shareWith,
'path': instance.path,
'referenceId': instance.referenceId,
'talkMetaData': instance.talkMetaData,
'referenceId': ?instance.referenceId,
'talkMetaData': ?instance.talkMetaData,
'permissions': ?instance.permissions,
'password': ?instance.password,
'expireDate': ?instance.expireDate,
};
@@ -0,0 +1,18 @@
import '../webdav/queries/list_files/cacheable_file.dart';
/// Converts a [CacheableFile.path] (relative to the WebDAV files root, folders
/// ending in `/`) into the path the OCS `files_sharing` API expects: rooted
/// with a single leading slash and without a trailing slash on folders.
///
/// Examples:
/// '' -> '/' (files root)
/// 'Documents/x.pdf' -> '/Documents/x.pdf'
/// 'Documents/' -> '/Documents'
/// '/Shared/a/' -> '/Shared/a'
String ocsPathFor(String webdavPath) {
final trimmed = webdavPath.replaceAll(RegExp(r'^/+|/+$'), '');
return trimmed.isEmpty ? '/' : '/$trimmed';
}
/// Convenience wrapper for a [CacheableFile].
String ocsPathOf(CacheableFile file) => ocsPathFor(file.path);
@@ -0,0 +1,108 @@
/// Nextcloud share types (subset the app uses).
const int kShareTypeUser = 0;
const int kShareTypeGroup = 1;
const int kShareTypePublicLink = 3;
const int kShareTypeEmail = 4;
/// A Talk conversation ("room") the file is linked into.
const int kShareTypeRoom = 10;
/// A single share as returned by the OCS `files_sharing` API.
///
/// Parsed by hand rather than via code generation: OCS is inconsistent about
/// types across versions (e.g. `id`/`share_type` may arrive as either strings
/// or numbers) and omits optional fields entirely, so defensive parsing is
/// safer than generated `as int` casts.
class Share {
final int id;
final int shareType;
final int permissions;
/// Server path of the shared item (e.g. `/Documents/x.pdf`).
final String? path;
/// `'file'` or `'folder'`.
final String? itemType;
/// Recipient id (user/group id); empty for public links.
final String? shareWith;
final String? shareWithDisplayname;
/// Public link URL (only set for [kShareTypePublicLink]).
final String? url;
/// Raw expiration as `"YYYY-MM-DD HH:MM:SS"` (or null when none).
final String? expiration;
final String? label;
/// Redacted password marker: the server returns `null` when no password is
/// set and a placeholder (`"redacted"`) when one is — never the real value.
final String? password;
const Share({
required this.id,
required this.shareType,
required this.permissions,
this.path,
this.itemType,
this.shareWith,
this.shareWithDisplayname,
this.url,
this.expiration,
this.label,
this.password,
});
bool get isPublicLink => shareType == kShareTypePublicLink;
bool get isGroup => shareType == kShareTypeGroup;
bool get isEmail => shareType == kShareTypeEmail;
bool get isRoom => shareType == kShareTypeRoom;
bool get isFolder => itemType == 'folder';
/// Whether a (link) password is currently set. See [password].
bool get hasPassword => password != null && password!.isNotEmpty;
/// Best display title for the share row.
String get displayTitle {
if (isPublicLink) return label?.isNotEmpty == true ? label! : 'Link';
final name = shareWithDisplayname;
if (name != null && name.isNotEmpty) return name;
return shareWith ?? 'Unbekannt';
}
/// Human label for the kind of share (for subtitles/headers).
String get kindLabel {
if (isPublicLink) return 'Öffentlicher Link';
if (isGroup) return 'Gruppe';
if (isRoom) return 'Talk-Chat';
if (isEmail) return 'E-Mail';
if (shareType == kShareTypeUser) return 'Person';
return 'Freigabe';
}
static int _asInt(Object? value, {int fallback = 0}) {
if (value is int) return value;
if (value is String) return int.tryParse(value) ?? fallback;
return fallback;
}
static String? _asString(Object? value) {
if (value == null) return null;
final s = value.toString();
return s.isEmpty ? null : s;
}
factory Share.fromJson(Map<String, dynamic> json) => Share(
id: _asInt(json['id']),
shareType: _asInt(json['share_type']),
permissions: _asInt(json['permissions']),
path: _asString(json['path']),
itemType: _asString(json['item_type']),
shareWith: _asString(json['share_with']),
shareWithDisplayname: _asString(json['share_with_displayname']),
url: _asString(json['url']),
expiration: _asString(json['expiration']),
label: _asString(json['label']),
password: _asString(json['password']),
);
}
@@ -0,0 +1,20 @@
/// Parameters for updating an existing share via OCS `PUT shares/{id}`.
/// Only the fields that are explicitly set are sent — every field is optional
/// and a null is omitted from the request (sending `permissions=null` would be
/// rejected by the server).
///
/// Use an empty string for [expireDate]/[password] to explicitly clear the
/// value server-side (Nextcloud treats `expireDate=` as "remove expiry").
class ShareUpdateParams {
final int? permissions;
final String? password;
final String? expireDate;
const ShareUpdateParams({this.permissions, this.password, this.expireDate});
Map<String, String> toQuery() => {
if (permissions != null) 'permissions': permissions.toString(),
'password': ?password,
'expireDate': ?expireDate,
};
}
@@ -0,0 +1,82 @@
/// Nextcloud share permission bitmask helpers. These mirror the constants the
/// OCS `files_sharing` API expects in the `permissions` field. Kept as pure
/// functions (no Flutter/IO) so they are unit-testable.
library;
/// Individual permission bits (Nextcloud `OCS\Constants`).
const int kPermissionRead = 1;
const int kPermissionUpdate = 2;
const int kPermissionCreate = 4;
const int kPermissionDelete = 8;
const int kPermissionShare = 16;
/// User-facing presets that map onto a bitmask.
enum SharePreset {
/// Recipient can only view/download.
readOnly,
/// Recipient can view, edit, add and remove (full editing).
edit,
/// Upload-only "file request" — recipient can add files to a folder but not
/// see existing contents. Only meaningful for folders.
fileDrop,
}
extension SharePresetLabel on SharePreset {
String get label {
switch (this) {
case SharePreset.readOnly:
return 'Nur Lesen';
case SharePreset.edit:
return 'Bearbeiten';
case SharePreset.fileDrop:
return 'Datei-Anfrage';
}
}
}
/// Returns true if [mask] contains the given [flag].
bool hasPermission(int mask, int flag) => mask & flag == flag;
/// Builds the permission bitmask for a [preset].
///
/// [isFolder] matters for the `edit` preset: a file can only carry
/// read+update, while a folder additionally supports create+delete. Nextcloud
/// rejects create/delete on a file ("Failed to update share"), so they must be
/// omitted there. When [allowReshare] is true the reshare bit is added to the
/// editing presets — mirroring how the Nextcloud clients respect the
/// `resharing` capability.
int permissionsFor(
SharePreset preset, {
bool allowReshare = false,
bool isFolder = false,
}) {
switch (preset) {
case SharePreset.readOnly:
return kPermissionRead;
case SharePreset.edit:
var base = kPermissionRead | kPermissionUpdate;
if (isFolder) base |= kPermissionCreate | kPermissionDelete;
return allowReshare ? base | kPermissionShare : base;
case SharePreset.fileDrop:
return kPermissionCreate;
}
}
/// Classifies an arbitrary permission bitmask into the closest preset, or null
/// if it doesn't match any (e.g. a custom combination). The reshare bit is
/// ignored for matching so an "edit" share stays "edit" regardless of reshare.
SharePreset? presetFromBitmask(int mask) {
final normalized = mask & ~kPermissionShare;
if (normalized == kPermissionCreate) return SharePreset.fileDrop;
if (normalized == kPermissionRead) return SharePreset.readOnly;
// Any read share that also carries a write bit (update/create/delete) is
// surfaced as "edit".
const writeBits = kPermissionUpdate | kPermissionCreate | kPermissionDelete;
if (hasPermission(normalized, kPermissionRead) &&
normalized & writeBits != 0) {
return SharePreset.edit;
}
return null;
}
+7
View File
@@ -1,3 +1,5 @@
import 'dart:convert';
import '../../model/account_data.dart';
import '../../model/endpoint_data.dart';
@@ -6,6 +8,11 @@ import '../../model/endpoint_data.dart';
class NextcloudOcs {
NextcloudOcs._();
/// Decodes an OCS v2 JSON envelope and returns its `ocs` object (the wrapper
/// every response nests its `meta`/`data` under).
static Map<String, dynamic> decode(String raw) =>
(jsonDecode(raw) as Map<String, dynamic>)['ocs'] as Map<String, dynamic>;
static Map<String, String> headers() => {
'Accept': 'application/json',
'OCS-APIRequest': 'true',
@@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
@@ -28,9 +27,7 @@ class SearchFiles {
'Files search failed with ${response.statusCode}: ${response.body}',
);
}
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
final ocs = decoded['ocs'] as Map<String, dynamic>;
final data = ocs['data'] as Map<String, dynamic>;
final data = NextcloudOcs.decode(response.body)['data'] as Map<String, dynamic>;
return SearchFilesResponse.fromJson(data);
}
}
@@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import '../../../api_params.dart';
@@ -62,3 +64,44 @@ class DeleteMessage extends TalkApi {
Map<String, String>? headers,
) => http.delete(uri, headers: headers);
}
class SetRoomAvatar extends TalkApi {
final String chatToken;
final Uint8List bytes;
final String filename;
SetRoomAvatar(this.chatToken, this.bytes, {this.filename = 'avatar.jpg'})
: super('v1/room/$chatToken/avatar', null);
@override
ApiResponse? assemble(String raw) => null;
@override
Future<http.Response> request(
Uri uri,
ApiParams? body,
Map<String, String>? headers,
) async {
final req = http.MultipartRequest('POST', uri)
..headers.addAll(headers ?? const {})
..files.add(http.MultipartFile.fromBytes('file', bytes, filename: filename));
final streamed = await req.send();
return http.Response.fromStream(streamed);
}
}
class DeleteRoomAvatar extends TalkApi {
final String chatToken;
DeleteRoomAvatar(this.chatToken) : super('v1/room/$chatToken/avatar', null);
@override
ApiResponse? assemble(String raw) => null;
@override
Future<http.Response> request(
Uri uri,
ApiParams? body,
Map<String, String>? headers,
) => http.delete(uri, headers: headers);
}
@@ -1,8 +1,7 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_chat_params.dart';
import 'get_chat_response.dart';
@@ -15,10 +14,8 @@ class GetChat extends TalkApi<GetChatResponse> {
: super('v1/chat/$chatToken', null, getParameters: params.toJson());
@override
GetChatResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetChatResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
}
GetChatResponse assemble(String raw) =>
GetChatResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<Response> request(
@@ -16,7 +16,10 @@ class GetChatCache extends SimpleCache<GetChatResponse> {
GetChatParams(
lookIntoFuture: GetChatParamsSwitch.off,
setReadMarker: GetChatParamsSwitch.on,
limit: 200,
// Small initial page; also the per-chat offline snapshot written to
// localstore. Older messages are paged in on scroll-up via
// GetChatHistory. Keep in sync with ChatBloc's _kInitialPageSize.
limit: 50,
),
).run(),
fromJson: GetChatResponse.fromJson,
@@ -0,0 +1,56 @@
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';
/// Backwards-paging variant of GetChat (`lookIntoFuture=0` + `lastKnownMessageId`)
/// that fetches the page of messages *older* than a given id. Bypasses [TalkApi]
/// because that layer treats non-2xx as errors, and the server answers HTTP 304
/// when there are no older messages left — a normal "start of chat" outcome here.
/// `setReadMarker=off` so paging into history never moves the read cursor.
class GetChatHistory {
final String chatToken;
final int lastKnownMessageId;
final int limit;
GetChatHistory({
required this.chatToken,
required this.lastKnownMessageId,
required this.limit,
});
/// Returns the older page, or `null` on HTTP 304 (no older messages).
Future<GetChatResponse?> run() async {
final params = GetChatParams(
lookIntoFuture: GetChatParamsSwitch.off,
lastKnownMessageId: lastKnownMessageId,
includeLastKnown: GetChatParamsSwitch.off,
setReadMarker: GetChatParamsSwitch.off,
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) {
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
..headers = response.headers;
}
throw ServerException(
statusCode: status,
technicalDetails: 'GetChatHistory $uri: HTTP $status',
);
}
}
@@ -1,11 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../../../errors/network_exception.dart';
import '../../../errors/server_exception.dart';
import '../../../http_errors.dart';
import '../../nextcloud_ocs.dart';
import 'get_chat_params.dart';
import 'get_chat_response.dart';
@@ -41,24 +37,17 @@ class LongPollChat {
);
final headers = NextcloudOcs.headers();
final http.Response response;
try {
response = await http
final response = (await sendGuarded(
'LongPollChat $uri',
() => http
.get(uri, headers: headers)
.timeout(Duration(seconds: timeoutSeconds + 15));
} on TimeoutException catch (e) {
throw NetworkException.timeout(technicalDetails: 'LongPollChat $uri: $e');
} on SocketException catch (e) {
throw NetworkException(technicalDetails: 'LongPollChat $uri: ${e.message}');
} on http.ClientException catch (e) {
throw NetworkException(technicalDetails: 'LongPollChat $uri: ${e.message}');
}
.timeout(Duration(seconds: timeoutSeconds + 15)),
))!;
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
return GetChatResponse.fromJson(decoded['ocs'] as Map<String, dynamic>)
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
..headers = response.headers;
}
throw ServerException(
@@ -0,0 +1,23 @@
import 'package:http/http.dart' as http;
import '../../../api_params.dart';
import '../../nextcloud_ocs.dart';
import '../get_poll/get_poll_state_response.dart';
import '../talk_api.dart';
/// Schließt eine Umfrage endgültig — nur Ersteller oder Moderatoren.
class ClosePoll extends TalkApi<GetPollStateResponse> {
ClosePoll({required String token, required int pollId})
: super('v1/poll/$token/$pollId', null);
@override
GetPollStateResponse assemble(String raw) =>
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
Uri uri,
ApiParams? body,
Map<String, String>? headers,
) => http.delete(uri, headers: headers);
}
@@ -0,0 +1,31 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../../api_params.dart';
import '../talk_api.dart';
import 'create_poll_params.dart';
/// Erstellt eine Umfrage; der Server postet die Poll-Nachricht selbst in den
/// Chat, danach genügt ein Chat-Refresh. Nur in Gruppen-Chats erlaubt.
class CreatePoll extends TalkApi {
CreatePoll({required String token, required CreatePollParams params})
: super(
'v1/poll/$token',
params,
headers: {'Content-Type': 'application/json'},
);
@override
Null assemble(String raw) => null;
@override
Future<http.Response>? request(
Uri uri,
ApiParams? body,
Map<String, String>? headers,
) {
if (body is! CreatePollParams) return null;
return http.post(uri, headers: headers, body: jsonEncode(body.toJson()));
}
}
@@ -0,0 +1,27 @@
import 'package:json_annotation/json_annotation.dart';
import '../../../api_params.dart';
part 'create_poll_params.g.dart';
@JsonSerializable()
class CreatePollParams extends ApiParams {
String question;
List<String> options;
/// 0 = Ergebnisse öffentlich, 1 = bis zum Schließen verborgen.
int resultMode;
/// Stimmen pro Teilnehmer; 0 = unbegrenzt.
int maxVotes;
CreatePollParams({
required this.question,
required this.options,
required this.resultMode,
required this.maxVotes,
});
factory CreatePollParams.fromJson(Map<String, dynamic> json) =>
_$CreatePollParamsFromJson(json);
Map<String, dynamic> toJson() => _$CreatePollParamsToJson(this);
}
@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'create_poll_params.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CreatePollParams _$CreatePollParamsFromJson(Map<String, dynamic> json) =>
CreatePollParams(
question: json['question'] as String,
options: (json['options'] as List<dynamic>)
.map((e) => e as String)
.toList(),
resultMode: (json['resultMode'] as num).toInt(),
maxVotes: (json['maxVotes'] as num).toInt(),
);
Map<String, dynamic> _$CreatePollParamsToJson(CreatePollParams instance) =>
<String, dynamic>{
'question': instance.question,
'options': instance.options,
'resultMode': instance.resultMode,
'maxVotes': instance.maxVotes,
};
@@ -1,16 +1,19 @@
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'create_room_params.dart';
import 'create_room_response.dart';
class CreateRoom extends TalkApi {
class CreateRoom extends TalkApi<CreateRoomResponse> {
CreateRoomParams params;
CreateRoom(this.params) : super('v4/room', params);
@override
Null assemble(String raw) => null;
CreateRoomResponse assemble(String raw) =>
CreateRoomResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<Response>? request(
@@ -0,0 +1,27 @@
import 'package:json_annotation/json_annotation.dart';
import '../../../api_response.dart';
part 'create_room_response.g.dart';
@JsonSerializable(explicitToJson: true)
class CreateRoomResponse extends ApiResponse {
final CreateRoomResponseData data;
CreateRoomResponse(this.data);
factory CreateRoomResponse.fromJson(Map<String, dynamic> json) =>
_$CreateRoomResponseFromJson(json);
Map<String, dynamic> toJson() => _$CreateRoomResponseToJson(this);
}
@JsonSerializable()
class CreateRoomResponseData {
final String token;
CreateRoomResponseData(this.token);
factory CreateRoomResponseData.fromJson(Map<String, dynamic> json) =>
_$CreateRoomResponseDataFromJson(json);
Map<String, dynamic> toJson() => _$CreateRoomResponseDataToJson(this);
}
@@ -0,0 +1,29 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'create_room_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CreateRoomResponse _$CreateRoomResponseFromJson(Map<String, dynamic> json) =>
CreateRoomResponse(
CreateRoomResponseData.fromJson(json['data'] as Map<String, dynamic>),
)
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
);
Map<String, dynamic> _$CreateRoomResponseToJson(CreateRoomResponse instance) =>
<String, dynamic>{
'headers': ?instance.headers,
'data': instance.data.toJson(),
};
CreateRoomResponseData _$CreateRoomResponseDataFromJson(
Map<String, dynamic> json,
) => CreateRoomResponseData(json['token'] as String);
Map<String, dynamic> _$CreateRoomResponseDataToJson(
CreateRoomResponseData instance,
) => <String, dynamic>{'token': instance.token};
@@ -1,7 +1,6 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_participants_response.dart';
@@ -10,12 +9,8 @@ class GetParticipants extends TalkApi<GetParticipantsResponse> {
GetParticipants(this.token) : super('v4/room/$token/participants', null);
@override
GetParticipantsResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetParticipantsResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
}
GetParticipantsResponse assemble(String raw) =>
GetParticipantsResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
@@ -1,7 +1,6 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_poll_state_response.dart';
@@ -12,12 +11,8 @@ class GetPollState extends TalkApi<GetPollStateResponse> {
: super('v1/poll/$token/$pollId', null);
@override
GetPollStateResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetPollStateResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
}
GetPollStateResponse assemble(String raw) =>
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
@@ -4,6 +4,18 @@ import '../../../api_response.dart';
part 'get_poll_state_response.g.dart';
/// Poll-`status`-Werte der Talk-API.
const int pollStatusOpen = 0;
const int pollStatusClosed = 1;
/// Poll-`resultMode`-Werte der Talk-API.
const int pollResultModePublic = 0;
const int pollResultModeHidden = 1;
/// `participantType`-Werte (aus dem Room), die eine Umfrage schließen dürfen:
/// Owner (1), Moderator (2) und Gast-Moderator (6).
const Set<int> pollModeratorParticipantTypes = {1, 2, 6};
@JsonSerializable(explicitToJson: true)
class GetPollStateResponse extends ApiResponse {
GetPollStateResponseObject data;
@@ -50,4 +62,32 @@ class GetPollStateResponseObject {
factory GetPollStateResponseObject.fromJson(Map<String, dynamic> json) =>
_$GetPollStateResponseObjectFromJson(json);
Map<String, dynamic> toJson() => _$GetPollStateResponseObjectToJson(this);
bool get isClosed => status == pollStatusClosed;
bool get resultsHidden => resultMode == pollResultModeHidden;
/// Ergebnisse sichtbar: öffentliche Umfragen jederzeit, verborgene erst nach
/// dem Schließen. Der Typ von `votes` taugt nicht als Signal (siehe unten).
bool get resultsVisible => resultMode == pollResultModePublic || isClosed;
/// Normalisiert das dynamische `votes`-Feld zu einer Map: der Server liefert
/// bei verborgenen Ergebnissen (und ohne Stimmen) eine leere Liste statt Map.
Map<String, num> get voteCounts {
final raw = votes;
if (raw is! Map) return const {};
final result = <String, num>{};
raw.forEach((key, value) {
if (key is String && value is num) result[key] = value;
});
return result;
}
/// Darf der Nutzer die (offene) Umfrage schließen: als Ersteller oder Moderator.
bool canClose({required String selfId, required int participantType}) {
if (isClosed) return false;
final isCreator = actorType == 'users' && actorId == selfId;
final isModerator = pollModeratorParticipantTypes.contains(participantType);
return isCreator || isModerator;
}
}
@@ -1,9 +1,8 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import '../../../api_params.dart';
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_reactions_response.dart';
@@ -14,12 +13,8 @@ class GetReactions extends TalkApi<GetReactionsResponse> {
: super('v1/reaction/$chatToken/$messageId', null);
@override
GetReactionsResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetReactionsResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
}
GetReactionsResponse assemble(String raw) =>
GetReactionsResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<Response>? request(
@@ -0,0 +1,40 @@
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_shared_items_response.dart';
/// Fetches the messages that shared an item of a given [objectType] in a chat
/// (Talk's `GET /chat/{token}/share`). Paginated via [lastKnownMessageId] using
/// the `X-Chat-Last-Given` response header (see
/// [GetSharedItemsResponse.lastGivenMessageId]).
///
/// Known [objectType]s: `media`, `file`, `audio`, `voice`, `location`,
/// `deckcard`, `recording`, `other`.
class GetSharedItems extends TalkApi<GetSharedItemsResponse> {
GetSharedItems(
String token, {
required String objectType,
int limit = 20,
int? lastKnownMessageId,
}) : super(
'v1/chat/$token/share',
null,
getParameters: {
'objectType': objectType,
'limit': limit,
'lastKnownMessageId': ?lastKnownMessageId,
},
);
@override
GetSharedItemsResponse assemble(String raw) =>
GetSharedItemsResponse.fromOcs(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
}
@@ -0,0 +1,28 @@
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_shared_items_overview_response.dart';
/// Fetches the latest shared items of every type at once (Talk's
/// `GET /chat/{token}/share/overview`). Used to decide which category tabs to
/// show and to seed their first page. [limit] caps the items returned per type.
class GetSharedItemsOverview extends TalkApi<GetSharedItemsOverviewResponse> {
GetSharedItemsOverview(String token, {int limit = 20})
: super(
'v1/chat/$token/share/overview',
null,
getParameters: {'limit': limit},
);
@override
GetSharedItemsOverviewResponse assemble(String raw) =>
GetSharedItemsOverviewResponse.fromOcs(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
}
@@ -0,0 +1,32 @@
import '../../../api_response.dart';
import '../chat/get_chat_response.dart';
/// Response of Talk's `GET /chat/{token}/share/overview`: the latest shared
/// items grouped by object type (`media`, `file`, `voice`, `audio`, `location`,
/// `recording`, `deckcard`, `other`). Reuses [GetChatResponseObject] for the
/// message structure.
class GetSharedItemsOverviewResponse extends ApiResponse {
final Map<String, List<GetChatResponseObject>> itemsByType;
GetSharedItemsOverviewResponse(this.itemsByType);
factory GetSharedItemsOverviewResponse.fromOcs(Map<String, dynamic> ocs) {
final data = ocs['data'];
final result = <String, List<GetChatResponseObject>>{};
if (data is Map<String, dynamic>) {
for (final entry in data.entries) {
final value = entry.value;
final raw = switch (value) {
List<dynamic> list => list,
Map<dynamic, dynamic> map => map.values,
_ => const <dynamic>[],
};
result[entry.key] = raw
.whereType<Map<String, dynamic>>()
.map(GetChatResponseObject.fromJson)
.toList();
}
}
return GetSharedItemsOverviewResponse(result);
}
}
@@ -0,0 +1,35 @@
import '../../../api_response.dart';
import '../chat/get_chat_response.dart';
/// Response of Talk's shared-items endpoints. The message objects carry the
/// same structure as regular chat messages, so we reuse [GetChatResponseObject]
/// (the shared file lives in `messageParameters['file']`).
///
/// The server returns `data` either as a list or as a message-id-keyed map
/// depending on version; [fromOcs] normalises both to a list.
class GetSharedItemsResponse extends ApiResponse {
final List<GetChatResponseObject> items;
GetSharedItemsResponse(this.items);
factory GetSharedItemsResponse.fromOcs(Map<String, dynamic> ocs) {
final data = ocs['data'];
final raw = switch (data) {
List<dynamic> list => list,
Map<dynamic, dynamic> map => map.values,
_ => const <dynamic>[],
};
final items = raw
.whereType<Map<String, dynamic>>()
.map(GetChatResponseObject.fromJson)
.toList();
return GetSharedItemsResponse(items);
}
/// Offset for the next page, taken from the `X-Chat-Last-Given` header.
/// Null when the header is absent (older server) — treat that as "stop".
int? get lastGivenMessageId {
final value = headers?['x-chat-last-given'];
return value == null ? null : int.tryParse(value);
}
}
@@ -1,7 +1,6 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_room_params.dart';
import 'get_room_response.dart';
@@ -11,10 +10,8 @@ class GetRoom extends TalkApi<GetRoomResponse> {
GetRoom(this.params) : super('v4/room', null, getParameters: params.toJson());
@override
GetRoomResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetRoomResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
}
GetRoomResponse assemble(String raw) =>
GetRoomResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
+16 -44
View File
@@ -1,29 +1,20 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../../api_params.dart';
import '../../api_request.dart';
import '../../api_response.dart';
import '../../errors/auth_exception.dart';
import '../../errors/network_exception.dart';
import '../../errors/not_found_exception.dart';
import '../../errors/parse_exception.dart';
import '../../errors/server_exception.dart';
import '../../http_errors.dart';
import '../nextcloud_ocs.dart';
enum TalkApiMethod { get, post, put, delete }
abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
abstract class TalkApi<T extends ApiResponse?> {
String path;
ApiParams? body;
Map<String, String>? headers;
Map<String, dynamic>? getParameters;
http.Response? response;
TalkApi(this.path, this.body, {this.headers, this.getParameters});
Future<http.Response>? request(
@@ -40,44 +31,25 @@ abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
);
final mergedHeaders = {...NextcloudOcs.headers(), ...?headers};
final http.Response data;
try {
final raw = await request(endpoint, body, mergedHeaders);
if (raw == null) {
throw const NetworkException(
userMessage: 'Keine Antwort vom Talk-Server erhalten.',
technicalDetails: 'Talk request returned null',
);
}
data = raw;
} on SocketException catch (e) {
throw NetworkException(technicalDetails: 'Talk $endpoint: ${e.message}');
} on TimeoutException catch (e) {
throw NetworkException.timeout(technicalDetails: 'Talk $endpoint: $e');
} on http.ClientException catch (e) {
throw NetworkException(technicalDetails: 'Talk $endpoint: ${e.message}');
final data = await sendGuarded(
'Talk $endpoint',
() => request(endpoint, body, mergedHeaders),
);
if (data == null) {
throw const NetworkException(
userMessage: 'Keine Antwort vom Talk-Server erhalten.',
technicalDetails: 'Talk request returned null',
);
}
final status = data.statusCode;
if (status < 200 || status >= 300) {
// Talk's OCS errors put the real reason in the response body (e.g.
// expired session, removed participant, malformed reply target).
// Include a trimmed preview so the in-app error dialog and logs
// surface the cause instead of just the bare status code.
final body = data.body.replaceAll(RegExp(r'\s+'), ' ').trim();
final preview = body.length > 500 ? '${body.substring(0, 500)}' : body;
final detail = body.isEmpty
? 'Talk $endpoint -> HTTP $status'
: 'Talk $endpoint -> HTTP $status body=$preview';
// Talk's OCS errors carry the real reason in the body (expired session,
// removed participant, ...); include a trimmed preview so the dialog and
// logs surface the cause instead of just the bare status code.
final detail = httpErrorDetail('Talk $endpoint', data.body, status);
log(detail);
if (status == 401) {
throw AuthException.unauthorized(technicalDetails: detail);
}
if (status == 403) {
throw AuthException.forbidden(technicalDetails: detail);
}
if (status == 404) throw NotFoundException(technicalDetails: detail);
throw ServerException(statusCode: status, technicalDetails: detail);
throwForStatus(status, detail);
}
try {
@@ -0,0 +1,38 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../../api_params.dart';
import '../../nextcloud_ocs.dart';
import '../get_poll/get_poll_state_response.dart';
import '../talk_api.dart';
import 'vote_poll_params.dart';
class VotePoll extends TalkApi<GetPollStateResponse> {
// Body als echtes JSON (nicht form-encoded wie die anderen Endpunkte): nur
// so kommt das int-Array an; sonst liest der Server optionIds als [] und
// löscht die eigene Stimme (Ursache des Readonly-Fallbacks, Issue #42).
VotePoll({
required String token,
required int pollId,
required VotePollParams params,
}) : super(
'v1/poll/$token/$pollId',
params,
headers: {'Content-Type': 'application/json'},
);
@override
GetPollStateResponse assemble(String raw) =>
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response>? request(
Uri uri,
ApiParams? body,
Map<String, String>? headers,
) {
if (body is! VotePollParams) return null;
return http.post(uri, headers: headers, body: jsonEncode(body.toJson()));
}
}

Some files were not shown because too many files have changed in this diff Show More