From 74a2ddd17f927520e102c149edc0c4bbd3d6bf24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sat, 4 Jul 2026 22:50:18 +0200 Subject: [PATCH] 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. --- android/app/src/main/AndroidManifest.xml | 4 + ios/NotificationServiceExtension/Info.plist | 31 ++ .../NotificationService.swift | 244 +++++++++++++++ .../NotificationServiceExtension.entitlements | 14 + ios/NotificationServiceExtension/PEM.swift | 105 +++++++ ios/PUSH_NSE_SETUP.md | 286 ++++++++++++++++++ ios/Runner/AppDelegate.swift | 189 +++++++++++- ios/Runner/Runner.entitlements | 4 + .../app_password/delete_app_password.dart | 20 ++ .../app_password/get_app_password.dart | 45 +++ .../get_capabilities_response.dart | 10 +- .../get_capabilities_response.g.dart | 6 +- .../push_device_register.dart | 40 +++ .../push_device_test/push_device_test.dart | 25 ++ .../push_device_unregister.dart | 25 ++ .../mhsl/notify/register/notify_register.dart | 22 -- .../register/notify_register_params.dart | 20 -- .../register/notify_register_params.g.dart | 23 -- lib/app.dart | 37 ++- lib/main.dart | 32 +- lib/model/account_data.dart | 56 ++++ lib/notification/notification_controller.dart | 39 +-- lib/notification/notification_service.dart | 57 ++-- lib/notification/notification_tasks.dart | 17 +- lib/notification/notify_updater.dart | 51 ---- lib/push/nextcloud_push_api.dart | 88 ++++++ lib/push/nid_store.dart | 72 +++++ lib/push/push_actions.dart | 101 +++++++ lib/push/push_decryptor.dart | 77 +++++ lib/push/push_keypair.dart | 105 +++++++ lib/push/push_message_handler.dart | 193 ++++++++++++ lib/push/push_registration.dart | 251 +++++++++++++++ lib/push/push_registration_store.dart | 87 ++++++ lib/push/push_renderer.dart | 196 ++++++++++++ lib/push/push_secure_storage.dart | 27 ++ lib/push/push_subject.dart | 66 ++++ lib/push/push_tap_router.dart | 40 +++ .../capabilities/bloc/capabilities_cubit.dart | 3 + .../capabilities/bloc/capabilities_state.dart | 1 + .../bloc/capabilities_state.freezed.dart | 39 +-- .../bloc/capabilities_state.g.dart | 2 + lib/storage/notification_settings.dart | 10 +- lib/storage/notification_settings.g.dart | 10 +- lib/view/login/login_controller.dart | 6 + .../pages/settings/data/default_settings.dart | 5 +- .../settings/sections/account_section.dart | 9 +- .../pages/settings/sections/talk_section.dart | 142 +++++++-- .../settings/widgets/endpoint_picker.dart | 21 ++ lib/view/pages/talk/chat_list.dart | 41 --- pubspec.yaml | 8 +- test/push/nid_store_test.dart | 29 ++ test/push/push_decryptor_test.dart | 92 ++++++ test/push/push_endpoint_change_test.dart | 44 +++ test/push/push_keypair_test.dart | 39 +++ test/push/push_message_handler_test.dart | 32 ++ test/push/push_permission_test.dart | 34 +++ 56 files changed, 2987 insertions(+), 285 deletions(-) create mode 100644 ios/NotificationServiceExtension/Info.plist create mode 100644 ios/NotificationServiceExtension/NotificationService.swift create mode 100644 ios/NotificationServiceExtension/NotificationServiceExtension.entitlements create mode 100644 ios/NotificationServiceExtension/PEM.swift create mode 100644 ios/PUSH_NSE_SETUP.md create mode 100644 lib/api/marianumcloud/app_password/delete_app_password.dart create mode 100644 lib/api/marianumcloud/app_password/get_app_password.dart create mode 100644 lib/api/marianumconnect/queries/push_device_register/push_device_register.dart create mode 100644 lib/api/marianumconnect/queries/push_device_test/push_device_test.dart create mode 100644 lib/api/marianumconnect/queries/push_device_unregister/push_device_unregister.dart delete mode 100644 lib/api/mhsl/notify/register/notify_register.dart delete mode 100644 lib/api/mhsl/notify/register/notify_register_params.dart delete mode 100644 lib/api/mhsl/notify/register/notify_register_params.g.dart delete mode 100644 lib/notification/notify_updater.dart create mode 100644 lib/push/nextcloud_push_api.dart create mode 100644 lib/push/nid_store.dart create mode 100644 lib/push/push_actions.dart create mode 100644 lib/push/push_decryptor.dart create mode 100644 lib/push/push_keypair.dart create mode 100644 lib/push/push_message_handler.dart create mode 100644 lib/push/push_registration.dart create mode 100644 lib/push/push_registration_store.dart create mode 100644 lib/push/push_renderer.dart create mode 100644 lib/push/push_secure_storage.dart create mode 100644 lib/push/push_subject.dart create mode 100644 lib/push/push_tap_router.dart create mode 100644 test/push/nid_store_test.dart create mode 100644 test/push/push_decryptor_test.dart create mode 100644 test/push/push_endpoint_change_test.dart create mode 100644 test/push/push_keypair_test.dart create mode 100644 test/push/push_message_handler_test.dart create mode 100644 test/push/push_permission_test.dart 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>( + MarianumConnectEndpoint.resolve('me/push-device/test'), + ); + return (response.data?['devices'] as num?)?.toInt() ?? 0; + } on DioException catch (e) { + throw mapMarianumConnectError(e); + } + } +} diff --git a/lib/api/marianumconnect/queries/push_device_unregister/push_device_unregister.dart b/lib/api/marianumconnect/queries/push_device_unregister/push_device_unregister.dart new file mode 100644 index 0000000..6a06c2c --- /dev/null +++ b/lib/api/marianumconnect/queries/push_device_unregister/push_device_unregister.dart @@ -0,0 +1,25 @@ +import 'package:dio/dio.dart'; + +import '../../errors/marianumconnect_error.dart'; +import '../../marianumconnect_api.dart'; +import '../../marianumconnect_endpoint.dart'; + +/// Removes this device's push subscription from MarianumConnect via +/// `DELETE /api/mobile/v1/me/push-device?deviceIdentifier=...`. Idempotent +/// (204 even when the row is already gone). +class PushDeviceUnregister { + final Dio _dio; + + PushDeviceUnregister({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio(); + + Future run({required String deviceIdentifier}) async { + try { + await _dio.delete( + MarianumConnectEndpoint.resolve('me/push-device'), + queryParameters: {'deviceIdentifier': deviceIdentifier}, + ); + } on DioException catch (e) { + throw mapMarianumConnectError(e); + } + } +} diff --git a/lib/api/mhsl/notify/register/notify_register.dart b/lib/api/mhsl/notify/register/notify_register.dart deleted file mode 100644 index 2f9f4b0..0000000 --- a/lib/api/mhsl/notify/register/notify_register.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'dart:convert'; -import 'dart:developer'; - -import 'package:http/http.dart' as http; - -import '../../mhsl_api.dart'; -import 'notify_register_params.dart'; - -class NotifyRegister extends MhslApi { - NotifyRegisterParams params; - NotifyRegister(this.params) : super('notify/register/'); - - @override - void assemble(String raw) {} - - @override - Future request(Uri uri) { - var requestString = jsonEncode(params.toJson()); - log('register at push proxy with username ${params.username}'); - return http.post(uri, body: requestString); - } -} diff --git a/lib/api/mhsl/notify/register/notify_register_params.dart b/lib/api/mhsl/notify/register/notify_register_params.dart deleted file mode 100644 index 243904e..0000000 --- a/lib/api/mhsl/notify/register/notify_register_params.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'notify_register_params.g.dart'; - -@JsonSerializable() -class NotifyRegisterParams { - String username; - String password; - String fcmToken; - - NotifyRegisterParams({ - required this.username, - required this.password, - required this.fcmToken, - }); - - factory NotifyRegisterParams.fromJson(Map json) => - _$NotifyRegisterParamsFromJson(json); - Map toJson() => _$NotifyRegisterParamsToJson(this); -} diff --git a/lib/api/mhsl/notify/register/notify_register_params.g.dart b/lib/api/mhsl/notify/register/notify_register_params.g.dart deleted file mode 100644 index ad53ab5..0000000 --- a/lib/api/mhsl/notify/register/notify_register_params.g.dart +++ /dev/null @@ -1,23 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'notify_register_params.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -NotifyRegisterParams _$NotifyRegisterParamsFromJson( - Map json, -) => NotifyRegisterParams( - username: json['username'] as String, - password: json['password'] as String, - fcmToken: json['fcmToken'] as String, -); - -Map _$NotifyRegisterParamsToJson( - NotifyRegisterParams instance, -) => { - 'username': instance.username, - 'password': instance.password, - 'fcmToken': instance.fcmToken, -}; diff --git a/lib/app.dart b/lib/app.dart index e5317a6..9b0e564 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -12,7 +12,8 @@ import 'main.dart'; import 'model/data_cleaner.dart'; import 'notification/notification_controller.dart'; import 'notification/notification_tasks.dart'; -import 'notification/notify_updater.dart'; +import 'push/push_registration.dart'; +import 'push/push_tap_router.dart'; import 'routing/app_routes.dart'; import 'share_intent/share_intent_listener.dart'; import 'state/app/modules/app_modules.dart'; @@ -85,6 +86,13 @@ class _AppState extends State with WidgetsBindingObserver { } } + void _onPushTapPending() { + final token = PushTapRouter.pendingChatToken.value; + if (token == null || !mounted) return; + PushTapRouter.pendingChatToken.value = null; + NotificationTasks.navigateToTalk(context, chatToken: token); + } + Future _handlePendingWidgetNavigation() async { final pending = await WidgetNavigation.consumePendingTimetableTap(); if (!pending || !mounted) return; @@ -165,22 +173,32 @@ class _AppState extends State with WidgetsBindingObserver { UpdateUserIndex.index(); + // A refreshed FCM token invalidates the existing push subscription — the + // NC device identifier stays stable, so we simply re-register (NC first, + // then the proxy). Debounced so a burst of refreshes triggers one call. if (context.read().val().notificationSettings.enabled) { - void update() => NotifyUpdater.registerToServer(); - _fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen( - (_) => update(), - ); - update(); + _fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen(( + _, + ) { + Debouncer.debounce( + 'pushTokenRefresh', + const Duration(seconds: 3), + () => unawaited(PushRegistration().onTokenRefresh()), + ); + }); } + // Android renders pushes locally, so a tap arrives via the local + // notifications callback (PushTapRouter) rather than onMessageOpenedApp. + PushTapRouter.pendingChatToken.addListener(_onPushTapPending); + _onMessageSub = FirebaseMessaging.onMessage.listen((message) { if (!mounted) return; NotificationController.onForegroundMessageHandler(message, context); }); - FirebaseMessaging.onBackgroundMessage( - NotificationController.onBackgroundMessageHandler, - ); + // iOS delivers alert pushes (Connect direct pushes, and NC pushes rendered + // by the NSE) natively; a tap surfaces here. _onMessageOpenedAppSub = FirebaseMessaging.onMessageOpenedApp.listen(( message, ) { @@ -202,6 +220,7 @@ class _AppState extends State with WidgetsBindingObserver { _onMessageSub?.cancel(); _onMessageOpenedAppSub?.cancel(); _fcmTokenRefreshSub?.cancel(); + PushTapRouter.pendingChatToken.removeListener(_onPushTapPending); ShareIntentListener.pending.removeListener(_handlePendingShare); ShareIntentListener.instance.detach(); Main.bottomNavigator.removeListener(_onTabControllerChanged); diff --git a/lib/main.dart b/lib/main.dart index f968c55..8c235b6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -25,6 +25,10 @@ import 'app.dart'; import 'background/widget_background_task.dart'; import 'firebase_options.dart'; import 'model/account_data.dart'; +import 'notification/notification_service.dart'; +import 'push/push_message_handler.dart'; +import 'push/push_registration.dart'; +import 'push/push_renderer.dart'; import 'routing/app_routes.dart'; import 'share_intent/share_intent_listener.dart'; import 'state/app/modules/account/bloc/account_bloc.dart'; @@ -87,6 +91,13 @@ Future main() async { await Future.wait(initialisationTasks); log('app initialisation done!'); + // Local notifications: init the plugin (with tap/action callbacks) and the + // Android channels, then register the FCM background isolate handler that + // decrypts and renders Nextcloud pushes while the app is not in foreground. + await NotificationService().initializeNotifications(); + await PushRenderer.ensureChannels(); + FirebaseMessaging.onBackgroundMessage(PushMessageHandler.onBackgroundMessage); + // Wire up the home-screen widget bridge before runApp so any widget render // triggered during startup hits initialised native storage. await WidgetSync.ensureInitialized(); @@ -207,8 +218,14 @@ class _MainState extends State
{ _scheduleSessionValidation(accountBloc); // Cold start while already logged in: the account status doesn't // change, so the loggedIn listener below never fires — refresh - // capabilities here. - unawaited(context.read().load()); + // capabilities here, then self-heal the push registration. + final settingsCubit = context.read(); + unawaited( + context.read().load().then((_) { + if (!mounted) return; + _syncPush(settingsCubit, context.read()); + }), + ); unawaited(context.read().load()); } }); @@ -225,6 +242,17 @@ class _MainState extends State
{ unawaited(ListFilesCache.prefetchRootListing()); } + /// Registers/self-heals the push subscription when push is user-enabled and + /// the backend advertises the capability. Fire-and-forget. + void _syncPush(SettingsCubit settings, CapabilitiesCubit capabilities) { + unawaited( + PushRegistration.syncSubscription( + enabled: settings.val().notificationSettings.enabled, + capable: capabilities.canReceivePushNotifications, + ), + ); + } + void _scheduleSessionValidation(AccountBloc accountBloc) { unawaited( SessionValidator.probeStored( diff --git a/lib/model/account_data.dart b/lib/model/account_data.dart index 6ec006a..787b20e 100644 --- a/lib/model/account_data.dart +++ b/lib/model/account_data.dart @@ -6,9 +6,14 @@ import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../push/push_secure_storage.dart'; + class AccountData { static const _usernameField = 'username'; static const _passwordField = 'password'; + // App password lives in the push-shared (group-scoped) keystore so the iOS + // Notification Service Extension can authenticate Nextcloud calls too. + static const _appPasswordField = 'nextcloud_app_password'; static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(); @@ -23,6 +28,7 @@ class AccountData { String? _username; String? _password; + String? _appPassword; String getUsername() { if (_username == null) throw Exception('Username not initialized'); @@ -58,14 +64,49 @@ class AccountData { _populated = Completer(); _username = null; _password = null; + _appPassword = null; await _secureStorage.delete(key: _usernameField); await _secureStorage.delete(key: _passwordField); + await _clearAppPasswordStorage(); + } + + /// Persists a freshly minted Nextcloud app password. After this every + /// [getBasicAuthHeader] call authenticates with the app password instead of + /// the real password. + Future setAppPassword(String appPassword) async { + _appPassword = appPassword; + try { + await pushSecureStorage.write(key: _appPasswordField, value: appPassword); + } on Object { + // Group-scoped keystore may be unavailable (e.g. iOS entitlement not yet + // provisioned). Keeping it in memory still lets this session use it. + } + } + + Future clearAppPassword() async { + _appPassword = null; + await _clearAppPasswordStorage(); + } + + bool hasAppPassword() => _appPassword != null && _appPassword!.isNotEmpty; + + Future _clearAppPasswordStorage() async { + try { + await pushSecureStorage.delete(key: _appPasswordField); + } on Object { + // ignore — nothing stored or keystore unavailable + } } Future _migrateAndLoad() async { await _migrateFromLegacyStorage(); _username = await _secureStorage.read(key: _usernameField); _password = await _secureStorage.read(key: _passwordField); + try { + _appPassword = await pushSecureStorage.read(key: _appPasswordField); + } on Object { + _appPassword = null; + } if (!_populated.isCompleted) _populated.complete(); } @@ -97,6 +138,21 @@ class AccountData { /// Prefer this over embedding credentials in URLs — error logs and crash /// reports often capture the URL but not headers. String getBasicAuthHeader() { + if (!isPopulated()) { + throw Exception( + 'AccountData (e.g. username or password) is not initialized!', + ); + } + // Prefer the scoped app password once available; it survives real-password + // rotation and is what the push-v2 registration is bound to. + final secret = _appPassword ?? _password; + return 'Basic ${base64Encode(utf8.encode('$_username:$secret'))}'; + } + + /// Basic-auth header that always uses the real password. Needed exactly once, + /// to mint the app password via `core/getapppassword` (an app password cannot + /// mint another). + String getRealPasswordBasicAuthHeader() { if (!isPopulated()) { throw Exception( 'AccountData (e.g. username or password) is not initialized!', diff --git a/lib/notification/notification_controller.dart b/lib/notification/notification_controller.dart index fafe77b..33d2920 100644 --- a/lib/notification/notification_controller.dart +++ b/lib/notification/notification_controller.dart @@ -4,44 +4,36 @@ import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import '../push/push_message_handler.dart'; import '../state/app/modules/chat/bloc/chat_bloc.dart'; import '../widget/debug/debug_tile.dart'; import '../widget/debug/json_viewer.dart'; import '../widget/info_dialog.dart'; import 'notification_tasks.dart'; -// `vm:entry-point` keeps this alive through AOT tree-shaking — the FCM -// background isolate looks the class up by name from native code. -@pragma('vm:entry-point') +/// Bridges FCM lifecycle callbacks to the push pipeline. Background messages are +/// handled directly by [PushMessageHandler.onBackgroundMessage]; this class +/// covers the foreground and app-opened paths where a [BuildContext] is +/// available. class NotificationController { - @pragma('vm:entry-point') - static Future onBackgroundMessageHandler(RemoteMessage message) async { - NotificationTasks.updateBadgeCount(message); - } - static Future onForegroundMessageHandler( RemoteMessage message, BuildContext context, ) async { - final pushToken = _extractChatToken(message); final chatBloc = context.read(); // hasOpenChat, not currentToken: currentToken sticks around after // leaveChat so didPopNext can re-claim a stacked chat. - final activeToken = chatBloc.state.data?.currentToken ?? ''; - final chatIsOpen = - chatBloc.hasOpenChat && - pushToken != null && - pushToken.isNotEmpty && - pushToken == activeToken; - - NotificationTasks.updateBadgeCount(message); - - if (chatIsOpen) { - // Long-poll handles the message; just dismiss any stray tray entry. - unawaited(NotificationTasks.clearNotificationsForChat(pushToken)); - return; - } + final openChatToken = chatBloc.hasOpenChat + ? (chatBloc.state.data?.currentToken ?? '') + : null; + await PushMessageHandler().handle( + message, + foreground: true, + openChatToken: openChatToken, + ); + await NotificationTasks.refreshBadge(); + if (!context.mounted) return; NotificationTasks.updateProviders(context); } @@ -54,6 +46,7 @@ class NotificationController { chatToken: _extractChatToken(message), ); NotificationTasks.updateProviders(context); + unawaited(NotificationTasks.refreshBadge()); DebugTile(context).run(() { InfoDialog.show( diff --git a/lib/notification/notification_service.dart b/lib/notification/notification_service.dart index 7ee3d86..fa7e4ff 100644 --- a/lib/notification/notification_service.dart +++ b/lib/notification/notification_service.dart @@ -1,5 +1,9 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import '../push/push_actions.dart'; +import '../push/push_renderer.dart'; +import '../push/push_tap_router.dart'; + class NotificationService { static final NotificationService _instance = NotificationService._internal(); @@ -15,7 +19,27 @@ class NotificationService { '@mipmap/ic_launcher', ); - final iosSettings = DarwinInitializationSettings(); + // iOS Talk category mirrors the Android inline reply + mark-as-read actions + // so both platforms expose the same quick actions. The actual delivery of + // these while the app is terminated is handled by the (Phase 3) NSE. + final iosSettings = DarwinInitializationSettings( + notificationCategories: [ + DarwinNotificationCategory( + PushRenderer.iosTalkCategory, + actions: [ + DarwinNotificationAction.text( + kTalkReplyActionId, + 'Antworten', + buttonTitle: 'Senden', + options: const { + DarwinNotificationActionOption.authenticationRequired, + }, + ), + DarwinNotificationAction.plain(kTalkMarkReadActionId, 'Gelesen'), + ], + ), + ], + ); final initializationSettings = InitializationSettings( android: androidSettings, @@ -24,34 +48,9 @@ class NotificationService { await flutterLocalNotificationsPlugin.initialize( settings: initializationSettings, - ); - } - - Future showNotification({ - required String title, - required String body, - required int badgeCount, - }) async { - const androidPlatformChannelSpecifics = AndroidNotificationDetails( - 'marmobile', - 'Marianum Fulda', - importance: Importance.defaultImportance, - priority: Priority.defaultPriority, - ticker: 'Marianum Fulda', - ); - - const iosPlatformChannelSpecifics = DarwinNotificationDetails(); - - const platformChannelSpecifics = NotificationDetails( - android: androidPlatformChannelSpecifics, - iOS: iosPlatformChannelSpecifics, - ); - - await flutterLocalNotificationsPlugin.show( - id: 0, - title: title, - body: body, - notificationDetails: platformChannelSpecifics, + onDidReceiveNotificationResponse: PushTapRouter.handleResponse, + onDidReceiveBackgroundNotificationResponse: + PushActions.handleBackgroundResponse, ); } } diff --git a/lib/notification/notification_tasks.dart b/lib/notification/notification_tasks.dart index 12dc16d..d0dc98a 100644 --- a/lib/notification/notification_tasks.dart +++ b/lib/notification/notification_tasks.dart @@ -1,7 +1,6 @@ import 'dart:developer'; import 'package:eraser/eraser.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart'; import 'package:flutter_app_badge/flutter_app_badge.dart'; @@ -12,10 +11,18 @@ import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import 'notification_service.dart'; class NotificationTasks { - static void updateBadgeCount(RemoteMessage notification) { - FlutterAppBadge.count( - int.parse((notification.data['unreadCount'] as String?) ?? '0'), - ); + /// Recomputes the app badge from the notifications currently in the tray. + /// Deterministic — no server-provided counter to drift out of sync — so the + /// badge always matches what the user actually sees. Called after rendering, + /// cancelling, or opening the app. + static Future refreshBadge() async { + try { + final plugin = NotificationService().flutterLocalNotificationsPlugin; + final actives = await plugin.getActiveNotifications(); + await FlutterAppBadge.count(actives.length); + } on Object catch (e) { + log('Badge refresh failed: $e'); + } } /// Per-chat tag scheme. MUST match the Notify backend, which sets this diff --git a/lib/notification/notify_updater.dart b/lib/notification/notify_updater.dart deleted file mode 100644 index 260c884..0000000 --- a/lib/notification/notify_updater.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'dart:async'; - -import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/material.dart'; - -import '../api/mhsl/notify/register/notify_register.dart'; -import '../api/mhsl/notify/register/notify_register_params.dart'; -import '../model/account_data.dart'; -import '../state/app/modules/settings/bloc/settings_cubit.dart'; -import '../widget/confirm_dialog.dart'; - -class NotifyUpdater { - static ConfirmDialog enableAfterDisclaimer( - SettingsCubit settings, - ) => ConfirmDialog( - title: 'Warnung', - icon: Icons.warning_amber, - content: - '' - 'Die Push-Benachrichtigungen werden durch mhsl.eu versendet.\n\n' - 'Durch das aktivieren dieser Funktion wird dein Nutzername, dein Password und eine Geräte-ID von mhsl dauerhaft gespeichert und verarbeitet.\n\n' - 'Für mehr Informationen drücke lange auf die Einstellungsoption!', - confirmButton: 'Aktivieren', - onConfirm: () { - unawaited( - FirebaseMessaging.instance.requestPermission(provisional: false), - ); - settings.val(write: true).notificationSettings.enabled = true; - unawaited(NotifyUpdater.registerToServer()); - }, - ); - - static Future registerToServer() async { - final fcmToken = await FirebaseMessaging.instance.getToken(); - if (fcmToken == null) { - throw Exception( - 'Failed to register push notification because there is no FBC token!', - ); - } - - unawaited( - NotifyRegister( - NotifyRegisterParams( - username: AccountData().getUsername(), - password: AccountData().getPassword(), - fcmToken: fcmToken, - ), - ).run(), - ); - } -} diff --git a/lib/push/nextcloud_push_api.dart b/lib/push/nextcloud_push_api.dart new file mode 100644 index 0000000..dd066d0 --- /dev/null +++ b/lib/push/nextcloud_push_api.dart @@ -0,0 +1,88 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../api/marianumcloud/nextcloud_ocs.dart'; + +/// Result of a successful Nextcloud push-v2 registration. +class NextcloudPushRegistration { + /// Per-user server public key (PEM). Forwarded to MarianumConnect as + /// `userPublicKey` and used device-side to verify push signatures. + final String publicKey; + + /// Opaque device identifier assigned by Nextcloud. + final String deviceIdentifier; + + /// Signature over [deviceIdentifier], forwarded as `deviceIdentifierSignature`. + final String signature; + + /// True when Nextcloud created a new subscription (HTTP 201); false when the + /// existing one was already up to date (HTTP 200). + final bool created; + + const NextcloudPushRegistration({ + required this.publicKey, + required this.deviceIdentifier, + required this.signature, + required this.created, + }); +} + +/// Thin client for Nextcloud's push-v2 device endpoints under +/// `/ocs/v2.php/apps/notifications/api/v2/push`. Uses the shared OCS headers, +/// which authenticate with the app password once available (the push +/// registration binds to it). +class NextcloudPushApi { + static const _path = 'apps/notifications/api/v2/push'; + + final http.Client _client; + + NextcloudPushApi({http.Client? client}) : _client = client ?? http.Client(); + + /// Registers (or refreshes) this device. [devicePublicKeyPem] must be the + /// 64-column SPKI PEM. [proxyServer] is the MarianumConnect push-proxy base + /// URL (with trailing slash). + Future register({ + required String pushTokenHash, + required String devicePublicKeyPem, + required String proxyServer, + }) async { + final response = await _client.post( + NextcloudOcs.uri(_path), + headers: NextcloudOcs.headers(), + body: { + 'pushTokenHash': pushTokenHash, + 'devicePublicKey': devicePublicKeyPem, + 'proxyServer': proxyServer, + }, + ); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('NC push register HTTP ${response.statusCode}'); + } + final json = jsonDecode(utf8.decode(response.bodyBytes)); + final data = (json as Map)['ocs']?['data']; + if (data is! Map) throw Exception('NC push register: malformed response'); + final publicKey = data['publicKey'] as String?; + final deviceIdentifier = data['deviceIdentifier'] as String?; + final signature = data['signature'] as String?; + if (publicKey == null || deviceIdentifier == null || signature == null) { + throw Exception('NC push register: missing fields'); + } + return NextcloudPushRegistration( + publicKey: publicKey, + deviceIdentifier: deviceIdentifier, + signature: signature, + created: response.statusCode == 201, + ); + } + + /// Unregisters this device from Nextcloud push. Returns true when the server + /// responded 202, meaning the proxy subscription should also be removed. + Future unregister() async { + final response = await _client.delete( + NextcloudOcs.uri(_path), + headers: NextcloudOcs.headers(), + ); + return response.statusCode == 202; + } +} diff --git a/lib/push/nid_store.dart b/lib/push/nid_store.dart new file mode 100644 index 0000000..20f1ec1 --- /dev/null +++ b/lib/push/nid_store.dart @@ -0,0 +1,72 @@ +import 'package:localstore/localstore.dart'; + +/// A rendered notification's bookkeeping, keyed by the Nextcloud notification +/// id (`nid`). Lets a later delete-push (which only carries the `nid`) find and +/// cancel the exact tray notification that was shown. +class NidEntry { + final int nid; + final int notificationId; + final String tag; + final String? chatToken; + + const NidEntry({ + required this.nid, + required this.notificationId, + required this.tag, + this.chatToken, + }); + + Map toJson() => { + 'nid': nid, + 'notificationId': notificationId, + 'tag': tag, + if (chatToken != null) 'chatToken': chatToken, + }; + + factory NidEntry.fromJson(Map json) => NidEntry( + nid: (json['nid'] as num).toInt(), + notificationId: (json['notificationId'] as num).toInt(), + tag: json['tag'] as String, + chatToken: json['chatToken'] as String?, + ); +} + +/// Persists the `nid → tray notification` mapping via [Localstore] so both the +/// foreground and background isolates can resolve delete-pushes. +class NidStore { + static const _collection = 'push_nids'; + + final Localstore _db; + + NidStore({Localstore? db}) : _db = db ?? Localstore.instance; + + Future put(NidEntry entry) => + _db.collection(_collection).doc('${entry.nid}').set(entry.toJson()); + + Future get(int nid) async { + final data = await _db.collection(_collection).doc('$nid').get(); + if (data == null) return null; + return NidEntry.fromJson(data); + } + + Future delete(int nid) => + _db.collection(_collection).doc('$nid').delete(); + + Future> all() async { + final docs = await _db.collection(_collection).get(); + if (docs == null) return const []; + return docs.values + .map((e) => NidEntry.fromJson(e as Map)) + .toList(); + } + + Future clear() async { + final docs = await _db.collection(_collection).get(); + if (docs == null) return; + for (final id in docs.keys) { + // Localstore keys are the full document paths (/push_nids/). + final nid = int.tryParse(id.split('/').last); + if (nid != null) await delete(nid); + } + } +} diff --git a/lib/push/push_actions.dart b/lib/push/push_actions.dart new file mode 100644 index 0000000..0b5d15f --- /dev/null +++ b/lib/push/push_actions.dart @@ -0,0 +1,101 @@ +import 'dart:convert'; +import 'dart:developer'; + +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:http/http.dart' as http; + +import '../api/marianumcloud/nextcloud_ocs.dart'; +import '../model/account_data.dart'; +import 'nid_store.dart'; + +/// Notification action identifiers shared between the renderer (which attaches +/// the actions) and the response handlers (which dispatch them). +const String kTalkReplyActionId = 'TALK_REPLY'; +const String kTalkMarkReadActionId = 'TALK_MARK_READ'; + +/// Handles Talk notification actions (inline reply, mark-as-read). Runs in the +/// background isolate spawned by flutter_local_notifications, so it may not +/// share any app state — it reads credentials straight from secure storage via +/// the [AccountData] singleton after awaiting population. +class PushActions { + /// Background entry point for notification actions. Must be a top-level or + /// static function annotated with `vm:entry-point` so AOT keeps it alive. + @pragma('vm:entry-point') + static Future handleBackgroundResponse( + NotificationResponse response, + ) async { + final chatToken = _chatTokenFrom(response.payload); + if (chatToken == null) return; + switch (response.actionId) { + case kTalkReplyActionId: + final text = response.input?.trim(); + if (text != null && text.isNotEmpty) { + await sendReply(chatToken, text); + } + await markRead(chatToken); + break; + case kTalkMarkReadActionId: + await markRead(chatToken); + await _cancelForToken(response); + break; + default: + break; + } + } + + static Future sendReply(String chatToken, String message) async { + await _ocsPost( + 'apps/spreed/api/v1/chat/$chatToken', + body: {'message': message}, + ); + } + + static Future markRead(String chatToken) async { + await _ocsPost('apps/spreed/api/v1/chat/$chatToken/read'); + } + + static Future _ocsPost(String path, {Map? body}) async { + try { + await AccountData().waitForPopulation(); + final response = await http.post( + NextcloudOcs.uri(path), + headers: NextcloudOcs.headers(), + body: body, + ); + if (response.statusCode < 200 || response.statusCode >= 300) { + log('Push action $path -> HTTP ${response.statusCode}'); + } + } on Object catch (e) { + log('Push action $path failed: $e'); + } + } + + static Future _cancelForToken(NotificationResponse response) async { + final nid = _nidFrom(response.payload); + if (nid == null) return; + try { + await NidStore().delete(nid); + } on Object catch (e) { + log('Push action nid cleanup failed: $e'); + } + } + + static String? _chatTokenFrom(String? payload) => + _payloadField(payload, 'chatToken'); + + static int? _nidFrom(String? payload) { + final raw = _payloadField(payload, 'nid'); + return raw == null ? null : int.tryParse(raw); + } + + static String? _payloadField(String? payload, String key) { + if (payload == null || payload.isEmpty) return null; + try { + final map = jsonDecode(payload) as Map; + final value = map[key]; + return value?.toString(); + } on Object { + return null; + } + } +} diff --git a/lib/push/push_decryptor.dart b/lib/push/push_decryptor.dart new file mode 100644 index 0000000..7f7785c --- /dev/null +++ b/lib/push/push_decryptor.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypton/crypton.dart'; +import 'package:pointycastle/export.dart' as pc; + +import 'push_subject.dart'; + +/// Verifies and decrypts the encrypted `subject` of a Nextcloud push-v2 +/// notification. Pure crypto, no plugin/platform access, so it runs safely +/// inside the FCM background isolate. +/// +/// - Signature: `SHA512withRSA` over the *encrypted* subject bytes, verified +/// with the per-user server public key returned at registration. +/// - Encryption: the subject is encrypted with the device public key, so the +/// device decrypts with its private key. Nextcloud 32 defaults to +/// OAEP (SHA-1/MGF1-SHA-1); older instances use PKCS#1 v1.5. We try OAEP +/// first and fall back to PKCS#1. +class PushDecryptor { + final RSAPrivateKey devicePrivateKey; + + /// Per-user server public key (the `publicKey` from the NC registration + /// response). When null, signature verification is skipped. + final RSAPublicKey? serverPublicKey; + + const PushDecryptor({required this.devicePrivateKey, this.serverPublicKey}); + + /// Returns true when [signatureBase64] is a valid server signature over the + /// encrypted subject. Returns true when no server key is configured (the + /// proxy already verified the signature before forwarding). + bool verify(String subjectBase64, String signatureBase64) { + final key = serverPublicKey; + if (key == null) return true; + try { + final signed = Uint8List.fromList(base64.decode(subjectBase64)); + final signature = Uint8List.fromList(base64.decode(signatureBase64)); + return key.verifySHA512Signature(signed, signature); + } on Object { + return false; + } + } + + /// Decrypts the base64 subject into a [PushSubject], or returns null when + /// neither padding scheme yields valid JSON. + PushSubject? decrypt(String subjectBase64) { + final encrypted = Uint8List.fromList(base64.decode(subjectBase64)); + final plain = _decryptOaep(encrypted) ?? _decryptPkcs1(encrypted); + if (plain == null) return null; + try { + final json = jsonDecode(plain) as Map; + return PushSubject.fromJson(json); + } on Object { + return null; + } + } + + String? _decryptOaep(Uint8List data) => + _tryDecrypt(pc.OAEPEncoding(pc.RSAEngine()), data); + + String? _decryptPkcs1(Uint8List data) => + _tryDecrypt(pc.PKCS1Encoding(pc.RSAEngine()), data); + + String? _tryDecrypt(pc.AsymmetricBlockCipher cipher, Uint8List data) { + try { + cipher.init( + false, + pc.PrivateKeyParameter( + devicePrivateKey.asPointyCastle, + ), + ); + final out = cipher.process(data); + return utf8.decode(out); + } on Object { + return null; + } + } +} diff --git a/lib/push/push_keypair.dart b/lib/push/push_keypair.dart new file mode 100644 index 0000000..8f54b0d --- /dev/null +++ b/lib/push/push_keypair.dart @@ -0,0 +1,105 @@ +import 'package:crypton/crypton.dart'; +import 'package:flutter/foundation.dart'; + +import 'push_secure_storage.dart'; + +/// An RSA-2048 keypair exported as PEM strings ready for storage and for the +/// Nextcloud push-v2 registration. +@immutable +class PushKeypairPems { + /// PKCS#1 private-key PEM (`-----BEGIN RSA PRIVATE KEY-----`). + final String privateKeyPem; + + /// SPKI public-key PEM in Nextcloud's expected shape: base64 wrapped at 64 + /// characters per line. For RSA-2048 this is exactly 450 or 451 characters. + final String publicKeyPem; + + const PushKeypairPems({ + required this.privateKeyPem, + required this.publicKeyPem, + }); +} + +/// Generates a fresh keypair. Pure and synchronous so it can be run both inside +/// an isolate ([PushKeypair.generate]) and directly from unit tests. The public +/// PEM uses [RSAPublicKey.toFormattedPEM] which produces the 64-column SPKI +/// layout Nextcloud validates against. +PushKeypairPems generatePushKeypairPems() { + final keypair = RSAKeypair.fromRandom(); + return PushKeypairPems( + privateKeyPem: keypair.privateKey.toPEM(), + publicKeyPem: keypair.publicKey.toFormattedPEM(), + ); +} + +PushKeypairPems _generateInIsolate(void _) => generatePushKeypairPems(); + +/// Persists and lazily generates the device RSA keypair used for push +/// encryption. The private key never leaves the secure keystore; the public +/// key PEM is what gets registered with Nextcloud. +class PushKeypair { + static const _privateKeyKey = 'push_device_private_key_pem'; + static const _publicKeyKey = 'push_device_public_key_pem'; + + final FlutterSecureStorageLike _storage; + + const PushKeypair({FlutterSecureStorageLike? storage}) + : _storage = storage ?? const _DefaultStorage(); + + /// Returns the stored keypair PEMs, generating and persisting a fresh keypair + /// on first use. Generation is offloaded to an isolate because RSA-2048 key + /// generation blocks the UI thread for a noticeable moment. + Future ensure() async { + final existing = await _load(); + if (existing != null) return existing; + final generated = await compute(_generateInIsolate, null); + await _storage.write(key: _privateKeyKey, value: generated.privateKeyPem); + await _storage.write(key: _publicKeyKey, value: generated.publicKeyPem); + return generated; + } + + /// Loads the stored private key, or `null` when no keypair exists yet. + Future loadPrivateKey() async { + final pem = await _storage.read(key: _privateKeyKey); + if (pem == null || pem.isEmpty) return null; + return RSAPrivateKey.fromPEM(pem); + } + + Future loadPublicKeyPem() => _storage.read(key: _publicKeyKey); + + Future clear() async { + await _storage.delete(key: _privateKeyKey); + await _storage.delete(key: _publicKeyKey); + } + + Future _load() async { + final priv = await _storage.read(key: _privateKeyKey); + final pub = await _storage.read(key: _publicKeyKey); + if (priv == null || priv.isEmpty || pub == null || pub.isEmpty) return null; + return PushKeypairPems(privateKeyPem: priv, publicKeyPem: pub); + } +} + +/// Minimal storage contract so tests can inject an in-memory fake instead of +/// touching the platform keystore. +abstract class FlutterSecureStorageLike { + Future read({required String key}); + Future write({required String key, required String? value}); + Future delete({required String key}); +} + +class _DefaultStorage implements FlutterSecureStorageLike { + const _DefaultStorage(); + + @override + Future read({required String key}) => + pushSecureStorage.read(key: key); + + @override + Future write({required String key, required String? value}) => + pushSecureStorage.write(key: key, value: value); + + @override + Future delete({required String key}) => + pushSecureStorage.delete(key: key); +} diff --git a/lib/push/push_message_handler.dart b/lib/push/push_message_handler.dart new file mode 100644 index 0000000..87930c8 --- /dev/null +++ b/lib/push/push_message_handler.dart @@ -0,0 +1,193 @@ +import 'dart:developer'; + +import 'package:crypton/crypton.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; + +import '../notification/notification_service.dart'; +import 'nid_store.dart'; +import 'push_decryptor.dart'; +import 'push_keypair.dart'; +import 'push_registration_store.dart'; +import 'push_renderer.dart'; +import 'push_subject.dart'; + +/// How an incoming FCM payload should be interpreted. +enum PushKind { + /// Encrypted Nextcloud push-v2 notification (`subject` + `signature`). + nextcloud, + + /// Plaintext MarianumConnect direct push (`source == "connect"`). + connect, + + /// Neither — ignored. + unknown, +} + +/// Classifies a raw FCM data map. Pure, so it's unit-testable and safe in any +/// isolate. Nextcloud pushes are distinguished by the presence of both +/// `subject` and `signature`; Connect pushes by `source == "connect"`. +PushKind classifyPush(Map data) { + final hasSubject = (data['subject'] as String?)?.isNotEmpty ?? false; + final hasSignature = (data['signature'] as String?)?.isNotEmpty ?? false; + if (hasSubject && hasSignature) return PushKind.nextcloud; + if (data['source'] == 'connect') return PushKind.connect; + return PushKind.unknown; +} + +/// Verifies, decrypts, and renders incoming push messages. Delete-pushes cancel +/// the matching tray notification via [NidStore]. Works both in the FCM +/// background isolate and the foreground. +class PushMessageHandler { + final PushKeypair _keypair; + final PushRegistrationStore _registrationStore; + final PushRenderer _renderer; + final NidStore _nidStore; + + PushMessageHandler({ + PushKeypair? keypair, + PushRegistrationStore? registrationStore, + PushRenderer? renderer, + NidStore? nidStore, + }) : _keypair = keypair ?? const PushKeypair(), + _registrationStore = registrationStore ?? const PushRegistrationStore(), + _renderer = renderer ?? PushRenderer(), + _nidStore = nidStore ?? NidStore(); + + /// Background isolate entry point registered with + /// `FirebaseMessaging.onBackgroundMessage`. + @pragma('vm:entry-point') + static Future onBackgroundMessage(RemoteMessage message) async { + await NotificationService().initializeNotifications(); + await PushRenderer.ensureChannels(); + await PushMessageHandler().handle(message); + } + + /// Processes [message]. In the foreground, pass [foreground] true and + /// [openChatToken] so a message for the currently open chat is suppressed + /// (the long-poll already shows it) instead of raising a tray notification. + Future handle( + RemoteMessage message, { + bool foreground = false, + String? openChatToken, + }) async { + final data = message.data; + switch (classifyPush(data)) { + case PushKind.connect: + await _handleConnect(message, foreground: foreground); + break; + case PushKind.nextcloud: + await _handleNextcloud( + data, + foreground: foreground, + openChatToken: openChatToken, + ); + break; + case PushKind.unknown: + break; + } + } + + Future _handleConnect( + RemoteMessage message, { + required bool foreground, + }) async { + // On iOS the alert is delivered natively by the system; only Android needs + // to render the plaintext payload locally. + final data = message.data; + final title = data['title'] as String?; + final body = data['body'] as String?; + if (title == null) return; + await _renderer.renderConnect( + title: title, + body: body ?? '', + data: data.map((k, v) => MapEntry(k, '$v')), + ); + } + + Future _handleNextcloud( + Map data, { + required bool foreground, + required String? openChatToken, + }) async { + final subjectBase64 = data['subject'] as String; + final signatureBase64 = data['signature'] as String; + + final privateKey = await _keypair.loadPrivateKey(); + if (privateKey == null) { + log('Push: no device private key, cannot decrypt'); + return; + } + final serverPublicKey = await _loadServerPublicKey(); + + final decryptor = PushDecryptor( + devicePrivateKey: privateKey, + serverPublicKey: serverPublicKey, + ); + if (!decryptor.verify(subjectBase64, signatureBase64)) { + log('Push: signature verification failed'); + return; + } + final subject = decryptor.decrypt(subjectBase64); + if (subject == null) { + log('Push: could not decrypt subject'); + return; + } + + if (subject.isAnyDelete) { + await _handleDelete(subject); + return; + } + + // Foreground + the referenced chat already open: the long-poll renders the + // message, so just make sure no stale tray entry lingers. + if (foreground && + subject.isTalk && + subject.id != null && + subject.id == openChatToken) { + return; + } + + await _renderer.render(subject); + } + + Future _handleDelete(PushSubject subject) async { + if (subject.deleteAll) { + final all = await _nidStore.all(); + for (final entry in all) { + await _cancel(entry); + } + await _nidStore.clear(); + return; + } + final nids = [ + if (subject.delete && subject.nid != null) subject.nid!, + ...subject.nids, + ]; + for (final nid in nids) { + final entry = await _nidStore.get(nid); + if (entry != null) await _cancel(entry); + await _nidStore.delete(nid); + } + } + + Future _cancel(NidEntry entry) async { + try { + await NotificationService().flutterLocalNotificationsPlugin.cancel( + id: entry.notificationId, + tag: entry.tag, + ); + } on Object catch (e) { + log('Push: cancel ${entry.nid} failed: $e'); + } + } + + Future _loadServerPublicKey() async { + final pem = await _registrationStore.serverPublicKeyPem(); + if (pem == null || pem.isEmpty) return null; + try { + return RSAPublicKey.fromPEM(pem); + } on Object { + return null; + } + } +} diff --git a/lib/push/push_registration.dart b/lib/push/push_registration.dart new file mode 100644 index 0000000..14f8d85 --- /dev/null +++ b/lib/push/push_registration.dart @@ -0,0 +1,251 @@ +import 'dart:developer'; +import 'dart:io'; + +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:nextcloud/notifications.dart' show generatePushTokenHash; +import 'package:package_info_plus/package_info_plus.dart'; + +import '../api/marianumcloud/app_password/delete_app_password.dart'; +import '../api/marianumcloud/app_password/get_app_password.dart'; +import '../api/marianumconnect/marianumconnect_endpoint.dart'; +import '../api/marianumconnect/queries/push_device_register/push_device_register.dart'; +import '../api/marianumconnect/queries/push_device_unregister/push_device_unregister.dart'; +import '../model/account_data.dart'; +import '../model/endpoint_data.dart'; +import 'nextcloud_push_api.dart'; +import 'push_keypair.dart'; +import 'push_registration_store.dart'; + +/// Orchestrates the full push-v2 registration lifecycle: +/// Nextcloud device registration → MarianumConnect proxy registration, plus +/// unregister and token-refresh handling. +class PushRegistration { + final PushKeypair _keypair; + final PushRegistrationStore _store; + final NextcloudPushApi _nextcloud; + + PushRegistration({ + PushKeypair? keypair, + PushRegistrationStore? store, + NextcloudPushApi? nextcloud, + }) : _keypair = keypair ?? const PushKeypair(), + _store = store ?? const PushRegistrationStore(), + _nextcloud = nextcloud ?? NextcloudPushApi(); + + String get _platform => Platform.isIOS ? 'ios' : 'android'; + + /// Derives the push-proxy base URL from the active MarianumConnect endpoint, + /// so a beta/dev build registers against the matching proxy automatically. + String get _proxyServer => '${MarianumConnectEndpoint.current()}/push-proxy/'; + + /// Nextcloud origin the registration targets (full origin, no trailing + /// slash) — persisted alongside the registration to detect endpoint changes. + String get _ncBaseUrl => 'https://${EndpointData().nextcloud().full()}'; + + /// Ensures the Nextcloud app password exists (idempotent, best-effort). Push + /// registration binds to it, so it must be obtained before registering. + Future ensureAppPassword() async { + if (AccountData().hasAppPassword()) return; + try { + final appPassword = await GetAppPassword().run(); + await AccountData().setAppPassword(appPassword); + } on Object catch (e) { + log('Push: could not obtain app password (non-blocking): $e'); + } + } + + /// Registers this device end-to-end. No-op-safe: transport failures are + /// logged and swallowed so callers can fire-and-forget. + Future register() async { + try { + final fcmToken = await FirebaseMessaging.instance.getToken(); + if (fcmToken == null || fcmToken.isEmpty) { + log('Push: no FCM token, skipping registration'); + return; + } + await ensureAppPassword(); + await _persistNativeAuthContext(); + + final proxyServer = _proxyServer; + final ncBaseUrl = _ncBaseUrl; + final pems = await _keypair.ensure(); + final registration = await _nextcloud.register( + pushTokenHash: generatePushTokenHash(fcmToken), + devicePublicKeyPem: pems.publicKeyPem, + proxyServer: proxyServer, + ); + + await _store.save( + deviceIdentifier: registration.deviceIdentifier, + serverPublicKeyPem: registration.publicKey, + fcmToken: fcmToken, + proxyServer: proxyServer, + ncBaseUrl: ncBaseUrl, + ); + + String? appVersion; + try { + appVersion = (await PackageInfo.fromPlatform()).version; + } on Object { + appVersion = null; + } + + await PushDeviceRegister().run( + deviceIdentifier: registration.deviceIdentifier, + deviceIdentifierSignature: registration.signature, + userPublicKey: registration.publicKey, + pushToken: fcmToken, + platform: _platform, + appVersion: appVersion, + ); + log('Push: registered (created=${registration.created})'); + } on Object catch (e) { + log('Push: registration failed: $e'); + } + } + + /// Writes the username and Nextcloud base URL into the shared keychain so the + /// native iOS Talk action handler can authenticate OCS calls without the + /// Flutter engine. Best-effort — a failure here must not abort registration. + Future _persistNativeAuthContext() async { + try { + final endpoint = EndpointData().nextcloud(); + await _store.saveNativeAuthContext( + username: AccountData().getUsername(), + baseUrl: 'https://${endpoint.full()}', + ); + } on Object catch (e) { + log('Push: could not persist native auth context: $e'); + } + } + + /// Removes the subscription from Nextcloud and the proxy. Best-effort. + Future unregister() async { + final deviceIdentifier = await _store.deviceIdentifier(); + try { + await _nextcloud.unregister(); + } on Object catch (e) { + log('Push: NC unregister failed: $e'); + } + if (deviceIdentifier != null && deviceIdentifier.isNotEmpty) { + try { + await PushDeviceUnregister().run(deviceIdentifier: deviceIdentifier); + } on Object catch (e) { + log('Push: proxy unregister failed: $e'); + } + } + await _store.clear(); + } + + /// Pure decision for whether a persisted registration endpoint no longer + /// matches the currently active one. A missing/empty stored value never + /// forces a re-registration — old installs (pre endpoint-tracking) heal via + /// the regular register-on-start path instead of a forced extra roundtrip. + static bool endpointChanged({ + required String? registered, + required String current, + }) => registered != null && registered.isNotEmpty && registered != current; + + /// True when an existing registration was made against a different + /// MarianumConnect proxy or Nextcloud base URL than the ones currently + /// configured (dev-tools endpoint switch, live/beta/custom). + Future needsEndpointReRegistration() async { + if (!await _store.isRegistered()) return false; + return endpointChanged( + registered: await _store.registeredProxyServer(), + current: _proxyServer, + ) || + endpointChanged( + registered: await _store.registeredNcBaseUrl(), + current: _ncBaseUrl, + ); + } + + /// Re-registers when the active endpoints diverge from the registered ones. + /// The NC POST updates the existing subscription server-side to the new + /// proxyServer URL; afterwards the device row lands at the NEW backend via + /// `PUT me/push-device`. No DELETE at the old proxy: its bearer token belongs + /// to a different token universe (cleared on endpoint switch) and the stale + /// row ages out through the backend's cleanup cron once pushes stop. + Future reRegisterIfEndpointChanged() async { + if (!await needsEndpointReRegistration()) return; + log('Push: endpoint changed, re-registering'); + await register(); + } + + /// Pure decision for whether push may be delivered given an OS permission + /// [status]: only an explicit denial blocks registration. `authorized` and + /// `provisional` obviously allow it; `notDetermined` is kept permissive so a + /// transient plugin/platform hiccup never silently disables push (the OS + /// simply won't show notifications until the user decides). + static bool isPermissionUsable(AuthorizationStatus status) => + status != AuthorizationStatus.denied; + + /// Requests the OS notification permission (covers iOS + Android 13) and + /// returns whether registration should proceed. Errors from the plugin are + /// treated as usable — better a possibly-idle registration than silently + /// losing push over a transient failure. + static Future requestOsPermission() async { + try { + final settings = await FirebaseMessaging.instance.requestPermission(); + return isPermissionUsable(settings.authorizationStatus); + } on Object catch (e) { + log('Push: requestPermission failed: $e'); + return true; + } + } + + /// True when the user has explicitly denied the OS notification permission. + /// Read-only (no prompt) — used by the settings UI to surface the state. + static Future isOsPermissionDenied() async { + try { + final settings = await FirebaseMessaging.instance + .getNotificationSettings(); + return settings.authorizationStatus == AuthorizationStatus.denied; + } on Object { + return false; + } + } + + /// Registers this device when push is both user-enabled and backend-capable. + /// Requests the OS notification permission first (covers iOS + Android 13); + /// an explicit denial skips registration entirely so NC/proxy never push to a + /// device that cannot display notifications. Safe to call on every start — + /// Nextcloud dedups an unchanged registration — which also self-heals a + /// device whose registration was lost. + static Future syncSubscription({ + required bool enabled, + required bool capable, + }) async { + if (!(enabled && capable)) return; + if (!await requestOsPermission()) { + log('Push: OS notification permission denied, skipping registration'); + return; + } + final registration = PushRegistration(); + // register() below refreshes an unchanged subscription anyway; the check + // only surfaces the endpoint switch in the log for diagnosability. + if (await registration.needsEndpointReRegistration()) { + log('Push: registered endpoints outdated, re-registering'); + } + await registration.register(); + } + + /// Re-registers after an FCM token refresh. The Nextcloud device identifier + /// stays stable across refreshes, so this simply re-runs registration (NC + /// first, then the proxy) with the new token. + Future onTokenRefresh() => register(); + + /// Full teardown for logout: unregister push, revoke the app password, then + /// clear it locally. Ordered so the proxy stops pushing before credentials + /// are gone. + Future logoutCleanup() async { + await unregister(); + try { + await DeleteAppPassword().run(); + } on Object catch (e) { + log('Push: delete app password failed: $e'); + } + await AccountData().clearAppPassword(); + } +} diff --git a/lib/push/push_registration_store.dart b/lib/push/push_registration_store.dart new file mode 100644 index 0000000..13e2c30 --- /dev/null +++ b/lib/push/push_registration_store.dart @@ -0,0 +1,87 @@ +import 'push_secure_storage.dart'; + +/// Persists the bookkeeping produced by a successful push registration: +/// the Nextcloud device identifier, the per-user server public key (needed to +/// verify incoming push signatures), the FCM token the registration was made +/// with (so a token refresh can be detected) and the endpoints it was bound to +/// (so an endpoint switch in the dev tools can be detected). +class PushRegistrationStore { + static const _deviceIdentifierKey = 'push_device_identifier'; + static const _serverPublicKeyKey = 'push_server_public_key_pem'; + static const _registeredTokenKey = 'push_registered_fcm_token'; + static const _proxyServerKey = 'push_registered_proxy_server'; + static const _ncBaseUrlKey = 'push_registered_nc_base_url'; + // Native-only context: the iOS AppDelegate answers Talk notification actions + // (reply / mark-as-read) directly via URLSession while the Flutter engine is + // not guaranteed to run. It needs the Nextcloud username and base URL from the + // shared (group-scoped) keychain; the app password already lives there + // (AccountData writes `nextcloud_app_password` group-scoped). + static const _usernameKey = 'nextcloud_username'; + static const _baseUrlKey = 'nextcloud_base_url'; + + const PushRegistrationStore(); + + Future save({ + required String deviceIdentifier, + required String serverPublicKeyPem, + required String fcmToken, + required String proxyServer, + required String ncBaseUrl, + }) async { + await pushSecureStorage.write( + key: _deviceIdentifierKey, + value: deviceIdentifier, + ); + await pushSecureStorage.write( + key: _serverPublicKeyKey, + value: serverPublicKeyPem, + ); + await pushSecureStorage.write(key: _registeredTokenKey, value: fcmToken); + await pushSecureStorage.write(key: _proxyServerKey, value: proxyServer); + await pushSecureStorage.write(key: _ncBaseUrlKey, value: ncBaseUrl); + } + + /// Persists the username and Nextcloud base URL group-scoped so the native + /// iOS Talk action handler (AppDelegate) can authenticate OCS calls. The base + /// URL is a full origin like `https://cloud.marianum-fulda.de` (domain + + /// optional path, no trailing slash). + Future saveNativeAuthContext({ + required String username, + required String baseUrl, + }) async { + await pushSecureStorage.write(key: _usernameKey, value: username); + await pushSecureStorage.write(key: _baseUrlKey, value: baseUrl); + } + + Future deviceIdentifier() => + pushSecureStorage.read(key: _deviceIdentifierKey); + + Future serverPublicKeyPem() => + pushSecureStorage.read(key: _serverPublicKeyKey); + + Future registeredFcmToken() => + pushSecureStorage.read(key: _registeredTokenKey); + + /// Proxy-server URL the current registration was made with. + Future registeredProxyServer() => + pushSecureStorage.read(key: _proxyServerKey); + + /// Nextcloud base URL the current registration was made against. + Future registeredNcBaseUrl() => + pushSecureStorage.read(key: _ncBaseUrlKey); + + /// True when a registration has been persisted (used by the cold-start + /// self-heal to decide whether to (re-)register). + Future isRegistered() async => + (await registeredFcmToken())?.isNotEmpty ?? false; + + Future clear() async { + await pushSecureStorage.delete(key: _deviceIdentifierKey); + await pushSecureStorage.delete(key: _serverPublicKeyKey); + await pushSecureStorage.delete(key: _registeredTokenKey); + await pushSecureStorage.delete(key: _proxyServerKey); + await pushSecureStorage.delete(key: _ncBaseUrlKey); + await pushSecureStorage.delete(key: _usernameKey); + await pushSecureStorage.delete(key: _baseUrlKey); + } +} diff --git a/lib/push/push_renderer.dart b/lib/push/push_renderer.dart new file mode 100644 index 0000000..29ae755 --- /dev/null +++ b/lib/push/push_renderer.dart @@ -0,0 +1,196 @@ +import 'dart:convert'; + +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +import '../notification/notification_service.dart'; +import '../notification/notification_tasks.dart'; +import 'nid_store.dart'; +import 'push_actions.dart'; +import 'push_subject.dart'; + +/// Renders decrypted push subjects (and plaintext Connect pushes) as local +/// notifications. Talk messages get a [MessagingStyleInformation] with inline +/// reply + mark-as-read actions and a per-chat tag; everything else renders in +/// a generic channel. +class PushRenderer { + static const talkChannelId = 'talk_messages'; + static const talkChannelName = 'Talk-Nachrichten'; + static const generalChannelId = 'nextcloud_general'; + static const generalChannelName = 'Benachrichtigungen'; + + static const String iosTalkCategory = 'TALK_MESSAGE'; + + final NidStore _nidStore; + + PushRenderer({NidStore? nidStore}) : _nidStore = nidStore ?? NidStore(); + + FlutterLocalNotificationsPlugin get _plugin => + NotificationService().flutterLocalNotificationsPlugin; + + /// Creates the Android notification channels. Safe to call repeatedly. + static Future ensureChannels() async { + final android = NotificationService().flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + if (android == null) return; + await android.createNotificationChannel( + const AndroidNotificationChannel( + talkChannelId, + talkChannelName, + description: 'Neue Nachrichten aus Nextcloud Talk', + importance: Importance.high, + ), + ); + await android.createNotificationChannel( + const AndroidNotificationChannel( + generalChannelId, + generalChannelName, + description: 'Allgemeine Benachrichtigungen', + ), + ); + } + + /// Renders a decrypted Nextcloud push subject. + Future render(PushSubject subject) async { + if (subject.isTalk) { + await _renderTalk(subject); + } else { + await _renderGeneric(subject); + } + } + + Future _renderTalk(PushSubject subject) async { + final nid = subject.nid ?? _fallbackId(subject.id); + final chatToken = subject.id; + final tag = chatToken != null + ? NotificationTasks.chatTag(chatToken) + : 'talk_$nid'; + final text = subject.subject ?? 'Neue Nachricht'; + final (senderName, messageText) = _splitSender(text); + + final payload = _payload(chatToken: chatToken, nid: nid); + + final messagingStyle = MessagingStyleInformation( + const Person(key: 'self', name: 'Ich'), + conversationTitle: senderName, + groupConversation: false, + messages: [ + Message(messageText, DateTime.now(), Person(name: senderName)), + ], + ); + + final androidDetails = AndroidNotificationDetails( + talkChannelId, + talkChannelName, + importance: Importance.high, + priority: Priority.high, + category: AndroidNotificationCategory.message, + tag: tag, + styleInformation: messagingStyle, + actions: const [ + AndroidNotificationAction( + kTalkReplyActionId, + 'Antworten', + showsUserInterface: false, + cancelNotification: false, + inputs: [AndroidNotificationActionInput(label: 'Nachricht')], + ), + AndroidNotificationAction( + kTalkMarkReadActionId, + 'Gelesen', + showsUserInterface: false, + ), + ], + ); + + final iosDetails = DarwinNotificationDetails( + threadIdentifier: tag, + categoryIdentifier: iosTalkCategory, + ); + + await _nidStore.put( + NidEntry(nid: nid, notificationId: nid, tag: tag, chatToken: chatToken), + ); + + await _plugin.show( + id: nid, + title: senderName, + body: messageText, + notificationDetails: NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ), + payload: payload, + ); + } + + Future _renderGeneric(PushSubject subject) async { + final nid = subject.nid ?? _fallbackId(subject.subject); + const androidDetails = AndroidNotificationDetails( + generalChannelId, + generalChannelName, + ); + const iosDetails = DarwinNotificationDetails(); + await _nidStore.put( + NidEntry(nid: nid, notificationId: nid, tag: 'nc_$nid'), + ); + await _plugin.show( + id: nid, + title: subject.subject ?? 'Neue Benachrichtigung', + body: null, + notificationDetails: const NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ), + payload: _payload(chatToken: null, nid: nid), + ); + } + + /// Renders a plaintext MarianumConnect direct push (Android only — iOS shows + /// the native alert itself). + Future renderConnect({ + required String title, + required String body, + Map? data, + }) async { + final id = _fallbackId('$title$body'); + const androidDetails = AndroidNotificationDetails( + generalChannelId, + generalChannelName, + importance: Importance.high, + priority: Priority.high, + ); + await _plugin.show( + id: id, + title: title, + body: body, + notificationDetails: const NotificationDetails(android: androidDetails), + payload: data == null ? null : jsonEncode(data), + ); + } + + String _payload({required String? chatToken, required int nid}) => + jsonEncode({'chatToken': ?chatToken, 'nid': nid}); + + /// Splits a `"Sender: message"` subject into its parts, falling back to a + /// generic sender label when there's no delimiter. + (String, String) _splitSender(String subject) { + final idx = subject.indexOf(': '); + if (idx > 0 && idx < subject.length - 2) { + return (subject.substring(0, idx), subject.substring(idx + 2)); + } + return ('Talk', subject); + } + + /// Deterministic non-negative 31-bit id from a string, used when the push + /// carries no `nid`. + int _fallbackId(String? seed) { + if (seed == null || seed.isEmpty) return 0; + var hash = 0; + for (final unit in seed.codeUnits) { + hash = (hash * 31 + unit) & 0x7fffffff; + } + return hash; + } +} diff --git a/lib/push/push_secure_storage.dart b/lib/push/push_secure_storage.dart new file mode 100644 index 0000000..bc2cb62 --- /dev/null +++ b/lib/push/push_secure_storage.dart @@ -0,0 +1,27 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Keychain access group shared between the Runner and the (Phase 3) iOS +/// Notification Service Extension so the NSE can read the RSA private key, +/// the server public key and the Nextcloud app password to decrypt pushes +/// while the app is not running. +/// +/// The value reuses the existing app-group id already present in the iOS +/// project (`ios/Runner/Runner.entitlements`). Phase 3 must additionally list +/// it under `keychain-access-groups` for both the Runner and the NSE target. +/// On Android `groupId` is ignored, so this is a no-op there. +const String kPushKeychainGroup = 'group.eu.mhsl.marianum.mobile.client.widget'; + +/// [IOSOptions] used for every push-related secure-storage entry. Uses +/// `first_unlock` accessibility so the NSE can read the key material after the +/// first device unlock following a reboot (the NSE may run while locked). +const IOSOptions kPushIosOptions = IOSOptions( + groupId: kPushKeychainGroup, + accessibility: KeychainAccessibility.first_unlock, +); + +/// Shared secure storage instance for all push key material and registration +/// bookkeeping. Kept separate from [AccountData]'s default storage because the +/// entries here are group-scoped for NSE access. +const FlutterSecureStorage pushSecureStorage = FlutterSecureStorage( + iOptions: kPushIosOptions, +); diff --git a/lib/push/push_subject.dart b/lib/push/push_subject.dart new file mode 100644 index 0000000..536eebc --- /dev/null +++ b/lib/push/push_subject.dart @@ -0,0 +1,66 @@ +/// The decrypted `subject` JSON of a Nextcloud push-v2 notification. +/// +/// Covers the full shape the notifications app emits, including the three +/// delete variants which the neon `DecryptedSubject` helper only partially +/// models (it lacks `delete-multiple`/`nids`). +class PushSubject { + /// Nextcloud notification id. + final int? nid; + + /// App that raised the notification (e.g. `spreed` for Talk). + final String? app; + + /// Human-readable subject line. + final String? subject; + + /// Notification type (e.g. `chat`, `background`). + final String? type; + + /// App-specific object id. For Talk this is the chat/room token. + final String? id; + + final bool delete; + final bool deleteMultiple; + final bool deleteAll; + + /// Notification ids to remove for a `delete-multiple` push. + final List nids; + + const PushSubject({ + this.nid, + this.app, + this.subject, + this.type, + this.id, + this.delete = false, + this.deleteMultiple = false, + this.deleteAll = false, + this.nids = const [], + }); + + bool get isAnyDelete => delete || deleteMultiple || deleteAll; + + /// True when this notification originates from Nextcloud Talk. + bool get isTalk => app == 'spreed'; + + factory PushSubject.fromJson(Map json) { + final rawNids = json['nids']; + return PushSubject( + nid: (json['nid'] as num?)?.toInt(), + app: json['app'] as String?, + subject: json['subject'] as String?, + type: json['type'] as String?, + id: json['id'] as String?, + delete: json['delete'] == true, + deleteMultiple: json['delete-multiple'] == true, + deleteAll: json['delete-all'] == true, + nids: rawNids is List + ? rawNids + .whereType() + .map((e) => e is num ? e.toInt() : int.tryParse('$e')) + .whereType() + .toList() + : const [], + ); + } +} diff --git a/lib/push/push_tap_router.dart b/lib/push/push_tap_router.dart new file mode 100644 index 0000000..80db51c --- /dev/null +++ b/lib/push/push_tap_router.dart @@ -0,0 +1,40 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +import 'push_actions.dart'; + +/// Routes foreground notification interactions from the single +/// flutter_local_notifications response callback. Action responses (reply / +/// mark-read) are dispatched straight to [PushActions]; a plain tap publishes +/// the target chat token via [pendingChatToken] for [App] to navigate to. +class PushTapRouter { + PushTapRouter._(); + + /// Chat token of the most recently tapped Talk notification, or null. [App] + /// listens to this and opens the chat, then resets it to null. + static final ValueNotifier pendingChatToken = ValueNotifier(null); + + static void handleResponse(NotificationResponse response) { + final actionId = response.actionId; + if (actionId == kTalkReplyActionId || actionId == kTalkMarkReadActionId) { + // Reuse the isolate-safe action dispatch for foreground actions too. + PushActions.handleBackgroundResponse(response); + return; + } + final token = _chatTokenFrom(response.payload); + if (token != null) pendingChatToken.value = token; + } + + static String? _chatTokenFrom(String? payload) { + if (payload == null || payload.isEmpty) return null; + try { + final map = jsonDecode(payload) as Map; + final token = map['chatToken']; + return token is String && token.isNotEmpty ? token : null; + } on Object { + return null; + } + } +} diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart b/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart index 667ae45..4217c9c 100644 --- a/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart +++ b/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart @@ -13,6 +13,8 @@ class CapabilitiesCubit extends HydratedCubit { bool get canViewForeignTimetables => state.viewForeignTimetables; + bool get canReceivePushNotifications => state.pushNotifications; + /// Refreshes capabilities from the server. On any failure (endpoint not yet /// live, network error, 4xx) the previously hydrated flags are kept but the /// state is marked `loaded` — a failed fetch never silently grants a @@ -23,6 +25,7 @@ class CapabilitiesCubit extends HydratedCubit { emit( CapabilitiesState( viewForeignTimetables: response.viewForeignTimetables, + pushNotifications: response.pushNotifications, loaded: true, ), ); diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_state.dart b/lib/state/app/modules/capabilities/bloc/capabilities_state.dart index 0216c7f..7d9a75b 100644 --- a/lib/state/app/modules/capabilities/bloc/capabilities_state.dart +++ b/lib/state/app/modules/capabilities/bloc/capabilities_state.dart @@ -7,6 +7,7 @@ part 'capabilities_state.g.dart'; abstract class CapabilitiesState with _$CapabilitiesState { const factory CapabilitiesState({ @Default(false) bool viewForeignTimetables, + @Default(false) bool pushNotifications, // Whether a capability response (or a definitive failure) has been // observed at least once this session. Lets the UI distinguish "still // unknown" from "confirmed not allowed". diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_state.freezed.dart b/lib/state/app/modules/capabilities/bloc/capabilities_state.freezed.dart index 713f14b..ba5903f 100644 --- a/lib/state/app/modules/capabilities/bloc/capabilities_state.freezed.dart +++ b/lib/state/app/modules/capabilities/bloc/capabilities_state.freezed.dart @@ -15,7 +15,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$CapabilitiesState { - bool get viewForeignTimetables;// Whether a capability response (or a definitive failure) has been + bool get viewForeignTimetables; bool get pushNotifications;// Whether a capability response (or a definitive failure) has been // observed at least once this session. Lets the UI distinguish "still // unknown" from "confirmed not allowed". bool get loaded; @@ -31,16 +31,16 @@ $CapabilitiesStateCopyWith get copyWith => _$CapabilitiesStat @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.loaded, loaded) || other.loaded == loaded)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.loaded, loaded) || other.loaded == loaded)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,viewForeignTimetables,loaded); +int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,loaded); @override String toString() { - return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, loaded: $loaded)'; + return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, loaded: $loaded)'; } @@ -51,7 +51,7 @@ abstract mixin class $CapabilitiesStateCopyWith<$Res> { factory $CapabilitiesStateCopyWith(CapabilitiesState value, $Res Function(CapabilitiesState) _then) = _$CapabilitiesStateCopyWithImpl; @useResult $Res call({ - bool viewForeignTimetables, bool loaded + bool viewForeignTimetables, bool pushNotifications, bool loaded }); @@ -68,9 +68,10 @@ class _$CapabilitiesStateCopyWithImpl<$Res> /// Create a copy of CapabilitiesState /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? viewForeignTimetables = null,Object? loaded = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? loaded = null,}) { return _then(_self.copyWith( viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable +as bool,pushNotifications: null == pushNotifications ? _self.pushNotifications : pushNotifications // ignore: cast_nullable_to_non_nullable as bool,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable as bool, )); @@ -157,10 +158,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( bool viewForeignTimetables, bool loaded)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( bool viewForeignTimetables, bool pushNotifications, bool loaded)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _CapabilitiesState() when $default != null: -return $default(_that.viewForeignTimetables,_that.loaded);case _: +return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded);case _: return orElse(); } @@ -178,10 +179,10 @@ return $default(_that.viewForeignTimetables,_that.loaded);case _: /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( bool viewForeignTimetables, bool loaded) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( bool viewForeignTimetables, bool pushNotifications, bool loaded) $default,) {final _that = this; switch (_that) { case _CapabilitiesState(): -return $default(_that.viewForeignTimetables,_that.loaded);case _: +return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded);case _: throw StateError('Unexpected subclass'); } @@ -198,10 +199,10 @@ return $default(_that.viewForeignTimetables,_that.loaded);case _: /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool viewForeignTimetables, bool loaded)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool viewForeignTimetables, bool pushNotifications, bool loaded)? $default,) {final _that = this; switch (_that) { case _CapabilitiesState() when $default != null: -return $default(_that.viewForeignTimetables,_that.loaded);case _: +return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded);case _: return null; } @@ -213,10 +214,11 @@ return $default(_that.viewForeignTimetables,_that.loaded);case _: @JsonSerializable() class _CapabilitiesState implements CapabilitiesState { - const _CapabilitiesState({this.viewForeignTimetables = false, this.loaded = false}); + const _CapabilitiesState({this.viewForeignTimetables = false, this.pushNotifications = false, this.loaded = false}); factory _CapabilitiesState.fromJson(Map json) => _$CapabilitiesStateFromJson(json); @override@JsonKey() final bool viewForeignTimetables; +@override@JsonKey() final bool pushNotifications; // Whether a capability response (or a definitive failure) has been // observed at least once this session. Lets the UI distinguish "still // unknown" from "confirmed not allowed". @@ -235,16 +237,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.loaded, loaded) || other.loaded == loaded)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.loaded, loaded) || other.loaded == loaded)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,viewForeignTimetables,loaded); +int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,loaded); @override String toString() { - return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, loaded: $loaded)'; + return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, loaded: $loaded)'; } @@ -255,7 +257,7 @@ abstract mixin class _$CapabilitiesStateCopyWith<$Res> implements $CapabilitiesS factory _$CapabilitiesStateCopyWith(_CapabilitiesState value, $Res Function(_CapabilitiesState) _then) = __$CapabilitiesStateCopyWithImpl; @override @useResult $Res call({ - bool viewForeignTimetables, bool loaded + bool viewForeignTimetables, bool pushNotifications, bool loaded }); @@ -272,9 +274,10 @@ class __$CapabilitiesStateCopyWithImpl<$Res> /// Create a copy of CapabilitiesState /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? viewForeignTimetables = null,Object? loaded = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? loaded = null,}) { return _then(_CapabilitiesState( viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable +as bool,pushNotifications: null == pushNotifications ? _self.pushNotifications : pushNotifications // ignore: cast_nullable_to_non_nullable as bool,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable as bool, )); diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_state.g.dart b/lib/state/app/modules/capabilities/bloc/capabilities_state.g.dart index 66447a6..266a000 100644 --- a/lib/state/app/modules/capabilities/bloc/capabilities_state.g.dart +++ b/lib/state/app/modules/capabilities/bloc/capabilities_state.g.dart @@ -9,11 +9,13 @@ part of 'capabilities_state.dart'; _CapabilitiesState _$CapabilitiesStateFromJson(Map json) => _CapabilitiesState( viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false, + pushNotifications: json['pushNotifications'] as bool? ?? false, loaded: json['loaded'] as bool? ?? false, ); Map _$CapabilitiesStateToJson(_CapabilitiesState instance) => { 'viewForeignTimetables': instance.viewForeignTimetables, + 'pushNotifications': instance.pushNotifications, 'loaded': instance.loaded, }; diff --git a/lib/storage/notification_settings.dart b/lib/storage/notification_settings.dart index 664cd2f..cf6a4b7 100644 --- a/lib/storage/notification_settings.dart +++ b/lib/storage/notification_settings.dart @@ -4,13 +4,13 @@ part 'notification_settings.g.dart'; @JsonSerializable() class NotificationSettings { - bool askUsageDismissed; + /// Whether push notifications are enabled. Defaults to `true` — the OS + /// permission prompt at login is now the gate, so there is no separate + /// in-app opt-in step anymore. + @JsonKey(defaultValue: true) bool enabled; - NotificationSettings({ - required this.askUsageDismissed, - required this.enabled, - }); + NotificationSettings({this.enabled = true}); factory NotificationSettings.fromJson(Map json) => _$NotificationSettingsFromJson(json); diff --git a/lib/storage/notification_settings.g.dart b/lib/storage/notification_settings.g.dart index ac37ecb..6c053aa 100644 --- a/lib/storage/notification_settings.g.dart +++ b/lib/storage/notification_settings.g.dart @@ -8,14 +8,8 @@ part of 'notification_settings.dart'; NotificationSettings _$NotificationSettingsFromJson( Map json, -) => NotificationSettings( - askUsageDismissed: json['askUsageDismissed'] as bool, - enabled: json['enabled'] as bool, -); +) => NotificationSettings(enabled: json['enabled'] as bool? ?? true); Map _$NotificationSettingsToJson( NotificationSettings instance, -) => { - 'askUsageDismissed': instance.askUsageDismissed, - 'enabled': instance.enabled, -}; +) => {'enabled': instance.enabled}; diff --git a/lib/view/login/login_controller.dart b/lib/view/login/login_controller.dart index 524f572..bae4600 100644 --- a/lib/view/login/login_controller.dart +++ b/lib/view/login/login_controller.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:developer'; import 'package:flutter/foundation.dart'; @@ -8,6 +9,7 @@ import '../../api/marianumconnect/auth/device_token_name.dart'; import '../../api/marianumconnect/auth/token_storage.dart'; import '../../api/marianumconnect/queries/auth_login/auth_login.dart'; import '../../model/account_data.dart'; +import '../../push/push_registration.dart'; import '../../widget_data/widget_sync.dart'; /// Owns the login flow's transient state (loading, last error) so it can be @@ -48,6 +50,10 @@ class LoginController extends ChangeNotifier { tokenName: await DeviceTokenName.resolve(), ); await AccountData().setData(user, password); + // Mint the Nextcloud app password now so it's ready for the push + // registration and subsequent NC calls. Non-blocking: on failure push + // stays off and retries on the next start. + unawaited(PushRegistration().ensureAppPassword()); _loading = false; notifyListeners(); return true; diff --git a/lib/view/pages/settings/data/default_settings.dart b/lib/view/pages/settings/data/default_settings.dart index 683e935..a261077 100644 --- a/lib/view/pages/settings/data/default_settings.dart +++ b/lib/view/pages/settings/data/default_settings.dart @@ -67,10 +67,7 @@ class DefaultSettings { showPastEvents: false, ), fileViewSettings: FileViewSettings(alwaysOpenExternally: Platform.isIOS), - notificationSettings: NotificationSettings( - askUsageDismissed: false, - enabled: false, - ), + notificationSettings: NotificationSettings(enabled: true), devToolsSettings: DevToolsSettings( checkerboardOffscreenLayers: false, checkerboardRasterCacheImages: false, diff --git a/lib/view/pages/settings/sections/account_section.dart b/lib/view/pages/settings/sections/account_section.dart index 94bb806..511426d 100644 --- a/lib/view/pages/settings/sections/account_section.dart +++ b/lib/view/pages/settings/sections/account_section.dart @@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../api/marianumcloud/cloud_users/cloud_users_actions.dart'; import '../../../../api/marianumconnect/queries/auth_logout/auth_logout.dart'; import '../../../../model/account_data.dart'; +import '../../../../push/push_registration.dart'; import '../../../../state/app/modules/account/bloc/account_bloc.dart'; import '../../../../state/app/modules/account/bloc/account_state.dart'; import '../../../../widget/app_progress_indicator.dart'; @@ -192,10 +193,12 @@ class _AccountSectionState extends State { context.read().setStatus(AccountStatus.loggedOut); } - // Best-effort revoke of the MC bearer token before we wipe local credentials. - // The token storage itself is cleared inside AuthLogout regardless of network - // success, so an offline logout still gets us into a clean local state. + // Ordered teardown: unregister push at Nextcloud + proxy and revoke the app + // password (while Nextcloud credentials are still available), THEN revoke the + // MC bearer token, and finally wipe local credentials. Each step is + // best-effort so an offline logout still reaches a clean local state. Future _performLogout() async { + await PushRegistration().logoutCleanup(); await AuthLogout().run(); await AccountData().removeData(); _cachedDisplayName = null; diff --git a/lib/view/pages/settings/sections/talk_section.dart b/lib/view/pages/settings/sections/talk_section.dart index 3b28e54..f14e8c5 100644 --- a/lib/view/pages/settings/sections/talk_section.dart +++ b/lib/view/pages/settings/sections/talk_section.dart @@ -1,12 +1,16 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../../notification/notify_updater.dart'; +import '../../../../api/errors/error_mapper.dart'; +import '../../../../api/marianumconnect/queries/push_device_test/push_device_test.dart'; +import '../../../../push/push_registration.dart'; +import '../../../../push/push_registration_store.dart'; import '../../../../routing/app_routes.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../utils/haptics.dart'; import '../../../../widget/centered_leading.dart'; -import '../../../../widget/info_dialog.dart'; class TalkSection extends StatelessWidget { const TalkSection({super.key}); @@ -51,32 +55,134 @@ class TalkSection extends StatelessWidget { leading: const CenteredLeading( Icon(Icons.notifications_active_outlined), ), - title: const Text('Push-Benachrichtigungen aktivieren'), - subtitle: const Text('Lange tippen für mehr Informationen'), + title: const Text('Push-Benachrichtigungen'), + subtitle: const Text('Neue Talk-Nachrichten direkt aufs Gerät'), trailing: Checkbox( value: notificationSettings.enabled, onChanged: (e) { Haptics.selection(); - if (e!) { - NotifyUpdater.enableAfterDisclaimer(settings).asDialog(context); + final enabled = e ?? false; + settings.val(write: true).notificationSettings.enabled = enabled; + if (enabled) { + final messenger = ScaffoldMessenger.of(context); + unawaited(() async { + // Only register when the OS permission isn't explicitly + // denied — otherwise NC + proxy would push into the void. + if (await PushRegistration.requestOsPermission()) { + await PushRegistration().register(); + } else { + messenger.showSnackBar( + const SnackBar( + content: Text( + 'Benachrichtigungen sind in den Systemeinstellungen ' + 'deaktiviert — bitte dort erlauben.', + ), + ), + ); + } + }()); } else { - settings.val(write: true).notificationSettings.enabled = e; + unawaited(PushRegistration().unregister()); } }, ), - onLongPress: () => _showInfoDialog(context), ), + if (notificationSettings.enabled) const _TestNotificationTile(), ], ); } - - void _showInfoDialog(BuildContext context) => InfoDialog.show( - context, - "Aufgrund technischer Limitationen müssen Push-Nachrichten über einen externen Server - hier 'mhsl.eu' (Author dieser App) - erfolgen.\n\n" - 'Wenn Push aktiviert wird, werden deine Zugangsdaten und ein Token verschlüsselt an den Betreiber gesendet und von ihm unverschlüsselt gespeichert.\n\n' - 'Der extene Server verwendet die Zugangsdaten um sich maschinell in Talk anzumelden und via Websockets auf neue Nachrichten zu warten.\n\n' - 'Wenn eine neue Nachricht eintrifft wird dein Telefon via FBC-Messaging (Google Firebase Push) vom externen Server benachrichtigt.\n\n' - 'Behalte im Hinterkopf, dass deine Zugangsdaten auf einem externen Server gespeichert werden und dies trotz bester Absichten ein Sicherheitsrisiko sein kann!', - title: 'Info über Push', - ); +} + +/// "Send a test notification" action, shown only while push is enabled. The +/// button stays disabled until a registration is confirmed present, then calls +/// the backend and reports the result via a SnackBar. +class _TestNotificationTile extends StatefulWidget { + const _TestNotificationTile(); + + @override + State<_TestNotificationTile> createState() => _TestNotificationTileState(); +} + +class _TestNotificationTileState extends State<_TestNotificationTile> { + static const _permissionDeniedHint = + 'Benachrichtigungen sind in den Systemeinstellungen deaktiviert'; + + bool _registered = false; + bool _permissionDenied = false; + bool _sending = false; + + @override + void initState() { + super.initState(); + _loadState(); + } + + Future _loadState() async { + final registered = await const PushRegistrationStore().isRegistered(); + final denied = await PushRegistration.isOsPermissionDenied(); + if (!mounted) return; + setState(() { + _registered = registered; + _permissionDenied = denied; + }); + } + + Future _sendTest() async { + if (_sending) return; + Haptics.selection(); + final messenger = ScaffoldMessenger.of(context); + // Re-check right before sending: the user may have flipped the OS + // permission in the system settings since this tile was built. + final denied = await PushRegistration.isOsPermissionDenied(); + if (!mounted) return; + if (denied) { + setState(() => _permissionDenied = true); + messenger.showSnackBar( + const SnackBar( + content: Text('$_permissionDeniedHint — bitte dort erlauben.'), + ), + ); + return; + } + setState(() { + _permissionDenied = false; + _sending = true; + }); + String message; + try { + final devices = await PushDeviceTest().run(); + message = devices >= 1 + ? 'Testbenachrichtigung an $devices Gerät(e) gesendet' + : 'Kein Gerät registriert — Push-Registrierung prüfen'; + } on Object catch (e) { + message = errorToUserMessage(e); + } + if (!mounted) return; + setState(() => _sending = false); + messenger.showSnackBar(SnackBar(content: Text(message))); + } + + @override + Widget build(BuildContext context) { + if (!_registered) return const SizedBox.shrink(); + return ListTile( + leading: const CenteredLeading(Icon(Icons.send_outlined)), + title: const Text('Testbenachrichtigung senden'), + subtitle: _permissionDenied + ? Text( + _permissionDeniedHint, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ) + : const Text('Prüft, ob Push auf diesem Gerät ankommt'), + trailing: _sending + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.arrow_right), + enabled: !_sending, + onTap: _sending ? null : _sendTest, + ); + } } diff --git a/lib/view/pages/settings/widgets/endpoint_picker.dart b/lib/view/pages/settings/widgets/endpoint_picker.dart index 11b6ead..eb9a261 100644 --- a/lib/view/pages/settings/widgets/endpoint_picker.dart +++ b/lib/view/pages/settings/widgets/endpoint_picker.dart @@ -1,7 +1,14 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../api/marianumconnect/auth/token_storage.dart'; +// Prefixed: the dio endpoint singleton shares its name with the enum from +// dev_tools_settings.dart imported below. +import '../../../../api/marianumconnect/marianumconnect_endpoint.dart' + as mc_api; +import '../../../../push/push_registration.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../storage/dev_tools_settings.dart'; import '../../../../storage/settings.dart' as model; @@ -35,6 +42,20 @@ class MarianumConnectEndpointPicker { mutable.marianumConnectEndpoint = next; if (custom != null) mutable.marianumConnectCustomUrl = custom; await const MarianumConnectTokenStorage().clear(); + // main.dart's BlocBuilder syncs the dio endpoint singleton on + // its next rebuild, but the push re-registration below must + // see the new base URL right now — update it here first + // (idempotent, same value the rebuild would set). + mc_api.MarianumConnectEndpoint.update( + settings + .val() + .devToolsSettings + .resolveMarianumConnectBaseUrl(), + ); + // A push registration bound to the old proxy would keep + // routing pushes there; no-op when not registered or the + // endpoints are unchanged. + unawaited(PushRegistration().reRegisterIfEndpointChanged()); }, ); }, diff --git a/lib/view/pages/talk/chat_list.dart b/lib/view/pages/talk/chat_list.dart index 223960f..5309ee0 100644 --- a/lib/view/pages/talk/chat_list.dart +++ b/lib/view/pages/talk/chat_list.dart @@ -1,9 +1,7 @@ -import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_split_view/flutter_split_view.dart'; -import '../../../notification/notify_updater.dart'; import '../../../routing/app_routes.dart'; import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; @@ -11,7 +9,6 @@ import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../widget/confirm_dialog.dart'; -import '../../../widget/info_dialog.dart'; import '../../../widget/placeholder_view.dart'; import 'join_chat.dart'; import 'search_chat.dart'; @@ -46,7 +43,6 @@ class _ChatListViewState extends State<_ChatListView> { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - _maybeAskForNotificationPermission(); _maybeOpenPendingChat(); }); } @@ -71,43 +67,6 @@ class _ChatListViewState extends State<_ChatListView> { ); } - void _maybeAskForNotificationPermission() { - final notificationSettings = _settings.val().notificationSettings; - if (notificationSettings.enabled || - notificationSettings.askUsageDismissed) { - return; - } - - _settings.val(write: true).notificationSettings.askUsageDismissed = true; - ConfirmDialog( - icon: Icons.notifications_active_outlined, - title: 'Benachrichtigungen aktivieren', - content: - 'Auf wunsch kannst du Push-Benachrichtigungen aktivieren. Deine Einstellungen kannst du jederzeit ändern.', - confirmButton: 'Weiter', - onConfirm: () { - FirebaseMessaging.instance.requestPermission(provisional: false).then(( - value, - ) { - if (!mounted) return; - switch (value.authorizationStatus) { - case AuthorizationStatus.authorized: - NotifyUpdater.enableAfterDisclaimer(_settings).asDialog(context); - break; - case AuthorizationStatus.denied: - InfoDialog.show( - context, - 'Du kannst die Benachrichtigungen später jederzeit in den App-Einstellungen aktivieren.', - ); - break; - default: - break; - } - }); - }, - ).asDialog(context); - } - @override Widget build(BuildContext context) { final bloc = context.read(); diff --git a/pubspec.yaml b/pubspec.yaml index f193ae0..f61707c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Mobile client for Webuntis and Nextcloud with Talk integration publish_to: 'none' -version: 1.2.2+57 +version: 1.3.0+58 environment: sdk: ">=3.8.0 <4.0.0" @@ -24,6 +24,12 @@ dependencies: collection: ^1.19.0 connectivity_plus: ^7.1.0 crypto: ^3.0.6 + # Push (Nextcloud push-v2): RSA-2048 keypair + SPKI-PEM export. Already + # present transitively via the nextcloud-neon fork; pinned as a direct + # dependency because lib/push/ uses it directly. + crypton: ^2.2.1 + # OAEP-SHA1 decryption of the encrypted push subject (with PKCS1 fallback). + pointycastle: ^3.9.1 device_info_plus: ^12.4.0 dio: ^5.9.2 emoji_picker_flutter: ^4.3.0 diff --git a/test/push/nid_store_test.dart b/test/push/nid_store_test.dart new file mode 100644 index 0000000..6d5ec5c --- /dev/null +++ b/test/push/nid_store_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/push/nid_store.dart'; + +void main() { + group('NidEntry', () { + test('round-trips through JSON with a chat token', () { + const entry = NidEntry( + nid: 42, + notificationId: 42, + tag: 'talk_abc123', + chatToken: 'abc123', + ); + final restored = NidEntry.fromJson(entry.toJson()); + expect(restored.nid, 42); + expect(restored.notificationId, 42); + expect(restored.tag, 'talk_abc123'); + expect(restored.chatToken, 'abc123'); + }); + + test('omits the chat token when absent', () { + const entry = NidEntry(nid: 7, notificationId: 7, tag: 'nc_7'); + final json = entry.toJson(); + expect(json.containsKey('chatToken'), isFalse); + final restored = NidEntry.fromJson(json); + expect(restored.chatToken, isNull); + expect(restored.tag, 'nc_7'); + }); + }); +} diff --git a/test/push/push_decryptor_test.dart b/test/push/push_decryptor_test.dart new file mode 100644 index 0000000..c5ae99f --- /dev/null +++ b/test/push/push_decryptor_test.dart @@ -0,0 +1,92 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypton/crypton.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/push/push_decryptor.dart'; +import 'package:pointycastle/export.dart' as pc; + +/// Encrypts [plain] with [publicKey] using OAEP (SHA-1), mirroring Nextcloud's +/// default padding. +String _encryptOaep(RSAPublicKey publicKey, String plain) { + final cipher = pc.OAEPEncoding(pc.RSAEngine()) + ..init( + true, + pc.PublicKeyParameter(publicKey.asPointyCastle), + ); + final out = cipher.process(Uint8List.fromList(utf8.encode(plain))); + return base64.encode(out); +} + +/// Signs the encrypted bytes with the server private key (SHA512withRSA). +String _sign(RSAPrivateKey serverPrivate, String subjectBase64) { + final signature = serverPrivate.createSHA512Signature( + Uint8List.fromList(base64.decode(subjectBase64)), + ); + return base64.encode(signature); +} + +void main() { + final device = RSAKeypair.fromRandom(); + final server = RSAKeypair.fromRandom(); + + const subjectJson = + '{"app":"spreed","subject":"Max: Hallo","type":"chat","id":"abc123","nid":42}'; + + group('PushDecryptor', () { + test('decrypts an OAEP-encrypted subject', () { + final encrypted = _encryptOaep(device.publicKey, subjectJson); + final decryptor = PushDecryptor( + devicePrivateKey: device.privateKey, + serverPublicKey: server.publicKey, + ); + + expect( + decryptor.verify(encrypted, _sign(server.privateKey, encrypted)), + isTrue, + ); + + final subject = decryptor.decrypt(encrypted); + expect(subject, isNotNull); + expect(subject!.app, 'spreed'); + expect(subject.isTalk, isTrue); + expect(subject.id, 'abc123'); + expect(subject.nid, 42); + }); + + test('decrypts a PKCS1-encrypted subject (fallback)', () { + // crypton's encrypt uses PKCS#1 v1.5. + final encrypted = device.publicKey.encrypt(subjectJson); + final subject = PushDecryptor( + devicePrivateKey: device.privateKey, + ).decrypt(encrypted); + expect(subject, isNotNull); + expect(subject!.subject, 'Max: Hallo'); + }); + + test('rejects a signature made with the wrong key', () { + final encrypted = _encryptOaep(device.publicKey, subjectJson); + final wrong = RSAKeypair.fromRandom(); + final decryptor = PushDecryptor( + devicePrivateKey: device.privateKey, + serverPublicKey: server.publicKey, + ); + expect( + decryptor.verify(encrypted, _sign(wrong.privateKey, encrypted)), + isFalse, + ); + }); + + test('parses delete-multiple subjects', () { + const deleteJson = '{"delete-multiple":true,"nids":[1,2,3]}'; + final encrypted = _encryptOaep(device.publicKey, deleteJson); + final subject = PushDecryptor( + devicePrivateKey: device.privateKey, + ).decrypt(encrypted); + expect(subject, isNotNull); + expect(subject!.deleteMultiple, isTrue); + expect(subject.isAnyDelete, isTrue); + expect(subject.nids, [1, 2, 3]); + }); + }); +} diff --git a/test/push/push_endpoint_change_test.dart b/test/push/push_endpoint_change_test.dart new file mode 100644 index 0000000..92d8b3c --- /dev/null +++ b/test/push/push_endpoint_change_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/push/push_registration.dart'; + +void main() { + group('PushRegistration.endpointChanged', () { + const live = 'https://connect.marianum-fulda.de/push-proxy/'; + const beta = 'https://connect-beta.marianum-fulda.de/push-proxy/'; + + test('different registered endpoint forces re-registration', () { + expect( + PushRegistration.endpointChanged(registered: live, current: beta), + isTrue, + ); + }); + + test('matching endpoint does not force re-registration', () { + expect( + PushRegistration.endpointChanged(registered: live, current: live), + isFalse, + ); + }); + + test('missing stored endpoint (pre-tracking install) does not force', () { + expect( + PushRegistration.endpointChanged(registered: null, current: live), + isFalse, + ); + expect( + PushRegistration.endpointChanged(registered: '', current: live), + isFalse, + ); + }); + + test('nextcloud base URL change is detected the same way', () { + expect( + PushRegistration.endpointChanged( + registered: 'https://cloud.marianum-fulda.de', + current: 'https://mhsl.eu/marianum/marianummobile/cloud', + ), + isTrue, + ); + }); + }); +} diff --git a/test/push/push_keypair_test.dart b/test/push/push_keypair_test.dart new file mode 100644 index 0000000..91fe8e0 --- /dev/null +++ b/test/push/push_keypair_test.dart @@ -0,0 +1,39 @@ +import 'package:crypton/crypton.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/push/push_keypair.dart'; + +void main() { + group('generatePushKeypairPems', () { + test('public PEM matches the Nextcloud SPKI format', () { + final pems = generatePushKeypairPems(); + final pem = pems.publicKeyPem; + + expect(pem, startsWith('-----BEGIN PUBLIC KEY-----\n')); + expect(pem, endsWith('\n-----END PUBLIC KEY-----')); + + // RSA-2048 SPKI base64 wrapped at 64 columns is byte-exactly 450 or 451 + // characters — the length Nextcloud validates against. + expect(pem.length, anyOf(450, 451)); + + // The END marker sits at a fixed offset (header + 392 base64 chars + 6 + // embedded newlines = 425, then '\n-----END...'). + expect(pem.indexOf('\n-----END PUBLIC KEY-----'), 425); + + // Body lines (between the header and footer) wrap at 64 columns. + final body = pem + .split('\n') + .where((l) => !l.startsWith('-----')) + .toList(); + for (final line in body.take(body.length - 1)) { + expect(line.length, 64); + } + }); + + test('private PEM round-trips back into a usable key', () { + final pems = generatePushKeypairPems(); + final restored = RSAPrivateKey.fromPEM(pems.privateKeyPem); + final restoredPublicPem = restored.publicKey.toFormattedPEM(); + expect(restoredPublicPem, pems.publicKeyPem); + }); + }); +} diff --git a/test/push/push_message_handler_test.dart b/test/push/push_message_handler_test.dart new file mode 100644 index 0000000..e4f694c --- /dev/null +++ b/test/push/push_message_handler_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/push/push_message_handler.dart'; + +void main() { + group('classifyPush', () { + test('nextcloud push has subject + signature', () { + expect( + classifyPush({'subject': 'enc', 'signature': 'sig', 'type': 'chat'}), + PushKind.nextcloud, + ); + }); + + test('connect push identified by source', () { + expect( + classifyPush({'source': 'connect', 'title': 'Hi', 'body': 'There'}), + PushKind.connect, + ); + }); + + test('subject without signature is not a nextcloud push', () { + expect(classifyPush({'subject': 'enc'}), PushKind.unknown); + }); + + test('empty subject/signature is unknown', () { + expect(classifyPush({'subject': '', 'signature': ''}), PushKind.unknown); + }); + + test('unrelated payload is unknown', () { + expect(classifyPush({'foo': 'bar'}), PushKind.unknown); + }); + }); +} diff --git a/test/push/push_permission_test.dart b/test/push/push_permission_test.dart new file mode 100644 index 0000000..81755c6 --- /dev/null +++ b/test/push/push_permission_test.dart @@ -0,0 +1,34 @@ +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/push/push_registration.dart'; + +void main() { + group('PushRegistration.isPermissionUsable', () { + test('explicit denial blocks registration', () { + expect( + PushRegistration.isPermissionUsable(AuthorizationStatus.denied), + isFalse, + ); + }); + + test('all other statuses allow registration', () { + const usable = [ + AuthorizationStatus.authorized, + AuthorizationStatus.provisional, + AuthorizationStatus.notDetermined, + ]; + for (final status in usable) { + expect( + PushRegistration.isPermissionUsable(status), + isTrue, + reason: '$status should be usable', + ); + } + }); + + test('covers every AuthorizationStatus value', () { + // Guard: if firebase_messaging ever adds a status, revisit the gate. + expect(AuthorizationStatus.values, hasLength(4)); + }); + }); +}