Files
Client/ios/PUSH_NSE_SETUP.md
T

17 KiB

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" 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.swiftTarget 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 neinfirebase_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 = <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. 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.