diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index d0e5142..c14f862 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -104,6 +104,10 @@
+
+
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
new file mode 100644
index 0000000..8c1f5e6
--- /dev/null
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -0,0 +1,31 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ NotificationServiceExtension
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.usernotifications.service
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).NotificationService
+
+
+
diff --git a/ios/NotificationServiceExtension/NotificationService.swift b/ios/NotificationServiceExtension/NotificationService.swift
new file mode 100644
index 0000000..55e9d8e
--- /dev/null
+++ b/ios/NotificationServiceExtension/NotificationService.swift
@@ -0,0 +1,244 @@
+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 = "group.eu.mhsl.marianum.mobile.client.widget"
+
+ 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 `"Sender: message"` into its parts; falls back to a generic sender.
+ private func splitSender(_ subject: String) -> (String, String) {
+ if let range = subject.range(of: ": ") {
+ let sender = String(subject[.. 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?
+ 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?
+ 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 App Group id
+ /// 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)
+ }
+}
diff --git a/ios/NotificationServiceExtension/NotificationServiceExtension.entitlements b/ios/NotificationServiceExtension/NotificationServiceExtension.entitlements
new file mode 100644
index 0000000..59b1233
--- /dev/null
+++ b/ios/NotificationServiceExtension/NotificationServiceExtension.entitlements
@@ -0,0 +1,14 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.eu.mhsl.marianum.mobile.client.widget
+
+ keychain-access-groups
+
+ group.eu.mhsl.marianum.mobile.client.widget
+
+
+
diff --git a/ios/NotificationServiceExtension/PEM.swift b/ios/NotificationServiceExtension/PEM.swift
new file mode 100644
index 0000000..77881b2
--- /dev/null
+++ b/ios/NotificationServiceExtension/PEM.swift
@@ -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?
+ 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)? {
+ 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.. **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"** | **FEHLT** | muss in Xcode angelegt werden (Abschnitt 3) |
+| `ios/Runner.xcodeproj/project.pbxproj` | **unverändert** | bewusst NICHT von Hand editiert — Xcode legt das Target an |
+
+> **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)
+
+### 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: 'group.eu.mhsl.marianum.mobile.client.widget', accessibility: first_unlock)`.
+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` | `group.eu.mhsl.marianum.mobile.client.widget` |
+| `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 = ` +
+`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 -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.** Die Entitlements listen die App-Group
+ ohne `$(AppIdentifierPrefix)` als `keychain-access-groups`. Das ist das von
+ `flutter_secure_storage` erwartete Verhalten (Access-Group == App-Group-ID).
+ Sollte der Keychain-Zugriff wider Erwarten scheitern (Status `-34018` /
+ `errSecMissingEntitlement`), in **beiden** Targets die Keychain-Sharing-
+ Capability über die Xcode-UI neu setzen und Provisioning-Profile erneuern.
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index c30b367..c806d8f 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -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 = "group.eu.mhsl.marianum.mobile.client.widget"
+ 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)
+ }
}
diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements
index 711c2c5..01e802c 100644
--- a/ios/Runner/Runner.entitlements
+++ b/ios/Runner/Runner.entitlements
@@ -9,5 +9,9 @@
group.eu.mhsl.marianum.mobile.client.widget
group.eu.mhsl.marianum.mobile.client.share
+ keychain-access-groups
+
+ group.eu.mhsl.marianum.mobile.client.widget
+
diff --git a/lib/api/marianumcloud/app_password/delete_app_password.dart b/lib/api/marianumcloud/app_password/delete_app_password.dart
new file mode 100644
index 0000000..d8d5b9b
--- /dev/null
+++ b/lib/api/marianumcloud/app_password/delete_app_password.dart
@@ -0,0 +1,20 @@
+import 'package:http/http.dart' as http;
+
+import '../nextcloud_ocs.dart';
+
+/// Revokes the current app password server-side via
+/// `DELETE /ocs/v2.php/core/apppassword`. Best-effort: the shared OCS headers
+/// authenticate with the app password itself (it revokes the credential it was
+/// made with) and the result is ignored — logout clears local state regardless.
+class DeleteAppPassword {
+ final http.Client _client;
+
+ DeleteAppPassword({http.Client? client}) : _client = client ?? http.Client();
+
+ Future run() async {
+ await _client.delete(
+ NextcloudOcs.uri('core/apppassword'),
+ headers: NextcloudOcs.headers(),
+ );
+ }
+}
diff --git a/lib/api/marianumcloud/app_password/get_app_password.dart b/lib/api/marianumcloud/app_password/get_app_password.dart
new file mode 100644
index 0000000..8e3eb7d
--- /dev/null
+++ b/lib/api/marianumcloud/app_password/get_app_password.dart
@@ -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 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;
+ }
+}
diff --git a/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.dart b/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.dart
index 1974d9b..f28a864 100644
--- a/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.dart
+++ b/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.dart
@@ -11,7 +11,15 @@ class CapabilitiesResponse {
@JsonKey(defaultValue: false)
final bool viewForeignTimetables;
- CapabilitiesResponse({required this.viewForeignTimetables});
+ /// Whether the backend push-proxy feature is configured and enabled for this
+ /// user. The app only registers for push when this is true.
+ @JsonKey(defaultValue: false)
+ final bool pushNotifications;
+
+ CapabilitiesResponse({
+ required this.viewForeignTimetables,
+ required this.pushNotifications,
+ });
factory CapabilitiesResponse.fromJson(Map json) =>
_$CapabilitiesResponseFromJson(json);
diff --git a/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.g.dart b/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.g.dart
index f55593b..3a7096a 100644
--- a/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.g.dart
+++ b/lib/api/marianumconnect/queries/get_capabilities/get_capabilities_response.g.dart
@@ -10,8 +10,12 @@ CapabilitiesResponse _$CapabilitiesResponseFromJson(
Map json,
) => CapabilitiesResponse(
viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false,
+ pushNotifications: json['pushNotifications'] as bool? ?? false,
);
Map _$CapabilitiesResponseToJson(
CapabilitiesResponse instance,
-) => {'viewForeignTimetables': instance.viewForeignTimetables};
+) => {
+ 'viewForeignTimetables': instance.viewForeignTimetables,
+ 'pushNotifications': instance.pushNotifications,
+};
diff --git a/lib/api/marianumconnect/queries/push_device_register/push_device_register.dart b/lib/api/marianumconnect/queries/push_device_register/push_device_register.dart
new file mode 100644
index 0000000..717ac2d
--- /dev/null
+++ b/lib/api/marianumconnect/queries/push_device_register/push_device_register.dart
@@ -0,0 +1,40 @@
+import 'package:dio/dio.dart';
+
+import '../../errors/marianumconnect_error.dart';
+import '../../marianumconnect_api.dart';
+import '../../marianumconnect_endpoint.dart';
+
+/// Registers (upserts) this device's push subscription with MarianumConnect via
+/// `PUT /api/mobile/v1/me/push-device`. The backend verifies the Nextcloud
+/// device-identifier signature, stores the routing metadata and starts
+/// forwarding Nextcloud pushes to this device's FCM token. Responds 204.
+class PushDeviceRegister {
+ final Dio _dio;
+
+ PushDeviceRegister({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
+
+ Future run({
+ required String deviceIdentifier,
+ required String deviceIdentifierSignature,
+ required String userPublicKey,
+ required String pushToken,
+ required String platform,
+ String? appVersion,
+ }) async {
+ try {
+ await _dio.put(
+ MarianumConnectEndpoint.resolve('me/push-device'),
+ data: {
+ 'deviceIdentifier': deviceIdentifier,
+ 'deviceIdentifierSignature': deviceIdentifierSignature,
+ 'userPublicKey': userPublicKey,
+ 'pushToken': pushToken,
+ 'platform': platform,
+ 'appVersion': ?appVersion,
+ },
+ );
+ } on DioException catch (e) {
+ throw mapMarianumConnectError(e);
+ }
+ }
+}
diff --git a/lib/api/marianumconnect/queries/push_device_test/push_device_test.dart b/lib/api/marianumconnect/queries/push_device_test/push_device_test.dart
new file mode 100644
index 0000000..d688008
--- /dev/null
+++ b/lib/api/marianumconnect/queries/push_device_test/push_device_test.dart
@@ -0,0 +1,25 @@
+import 'package:dio/dio.dart';
+
+import '../../errors/marianumconnect_error.dart';
+import '../../marianumconnect_api.dart';
+import '../../marianumconnect_endpoint.dart';
+
+/// Triggers a test push to all of the current user's registered devices via
+/// `POST /api/mobile/v1/me/push-device/test`. Returns the number of devices the
+/// backend dispatched to (0 when none are registered).
+class PushDeviceTest {
+ final Dio _dio;
+
+ PushDeviceTest({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
+
+ Future run() async {
+ try {
+ final response = await _dio.post