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.

This commit is contained in:
2026-07-04 22:50:18 +02:00
parent 32f7c311bc
commit 74a2ddd17f
56 changed files with 2987 additions and 285 deletions
+4
View File
@@ -104,6 +104,10 @@
</intent> </intent>
</queries> </queries>
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<!-- Android 13+ runtime notification permission. Requested at login via
FirebaseMessaging.requestPermission(); without this declaration the
locally rendered push notifications are silently dropped. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- Workmanager periodic widget refresh needs to reschedule after device <!-- Workmanager periodic widget refresh needs to reschedule after device
reboot, otherwise the widget freezes until the user opens the app. --> reboot, otherwise the widget freezes until the user opens the app. -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>NotificationServiceExtension</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,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[..<range.lowerBound])
let message = String(subject[range.upperBound...])
if !sender.isEmpty && !message.isEmpty {
return (sender, message)
}
}
return ("Talk", subject)
}
private func stringValue(_ value: Any?) -> String? {
if let s = value as? String { return s }
if let n = value as? NSNumber { return n.stringValue }
return nil
}
// MARK: - Crypto
private func verifySignature(_ signature: Data, over message: Data, with key: SecKey) -> Bool {
var error: Unmanaged<CFError>?
let ok = SecKeyVerifySignature(
key,
.rsaSignatureMessagePKCS1v15SHA512,
message as CFData,
signature as CFData,
&error
)
return ok
}
private func decrypt(_ data: Data, with key: SecKey) -> Data? {
// NC 32 defaults to OAEP(SHA-1); older instances use PKCS#1 v1.5. Try
// OAEP first, fall back to PKCS#1 mirrors the Dart PushDecryptor.
for algorithm in [SecKeyAlgorithm.rsaEncryptionOAEPSHA1,
SecKeyAlgorithm.rsaEncryptionPKCS1] {
var error: Unmanaged<CFError>?
if let plain = SecKeyCreateDecryptedData(key, algorithm, data as CFData, &error) as Data? {
return plain
}
}
return nil
}
// MARK: - Key loading
private func loadPrivateKey() -> SecKey? {
guard let pem = keychainString(NotificationService.devicePrivateKeyAccount),
let der = PEM.der(fromPem: pem),
// crypton labels it "RSA PRIVATE KEY" but the body is PKCS#8.
let pkcs1 = PEM.pkcs1PrivateKey(fromPkcs8: der)
else { return nil }
return PEM.rsaKey(pkcs1: pkcs1, isPrivate: true)
}
private func loadServerPublicKey() -> SecKey? {
guard let pem = keychainString(NotificationService.serverPublicKeyAccount),
let der = PEM.der(fromPem: pem),
let pkcs1 = PEM.pkcs1PublicKey(fromSpki: der)
else { return nil }
return PEM.rsaKey(pkcs1: pkcs1, isPrivate: false)
}
// MARK: - Keychain
/// Reads a flutter_secure_storage entry from the shared keychain. Must match
/// exactly how flutter_secure_storage_darwin 0.3.2 stores items:
/// kSecClass = kSecClassGenericPassword
/// kSecAttrAccount = the Dart key, verbatim
/// kSecAttrService = (unset the Dart IOSOptions set no accountName)
/// kSecAttrAccessGroup = the 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)
}
}
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
</array>
</dict>
</plist>
+105
View File
@@ -0,0 +1,105 @@
import Foundation
import Security
/// Minimal PEM/DER helpers to turn the crypton-produced PEM strings into
/// `SecKey`s. iOS's `SecKeyCreateWithData` only accepts *raw PKCS#1* DER for
/// RSA, so we must strip the two wrapper formats crypton/Nextcloud use:
///
/// - Private key: crypton's `toPEM()` emits the `RSA PRIVATE KEY` label but the
/// DER body is actually **PKCS#8** `PrivateKeyInfo`
/// (SEQUENCE { INTEGER version, SEQUENCE algId, OCTET STRING pkcs1 }).
/// We extract the OCTET STRING content = the PKCS#1 `RSAPrivateKey`.
///
/// - Public key: `PUBLIC KEY` label, **SPKI** `SubjectPublicKeyInfo`
/// (SEQUENCE { SEQUENCE algId, BIT STRING spki }). We extract the BIT STRING
/// content and drop its leading 0x00 "unused bits" byte = the PKCS#1
/// `RSAPublicKey`.
///
/// Only DER lengths up to 4 bytes are handled more than enough for RSA-2048.
enum PEM {
/// Strips the PEM armor and returns the base64-decoded DER.
static func der(fromPem pem: String) -> Data? {
let body = pem
.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
.filter { !$0.hasPrefix("-----") }
.joined()
return Data(base64Encoded: body)
}
/// PKCS#8 `PrivateKeyInfo` -> inner PKCS#1 `RSAPrivateKey`.
static func pkcs1PrivateKey(fromPkcs8 der: Data) -> Data? {
var reader = DERReader(der)
guard let outer = reader.readTLV(), outer.tag == 0x30 else { return nil }
var inner = DERReader(Data(outer.value))
guard let version = inner.readTLV(), version.tag == 0x02 else { return nil } // INTEGER version
guard let algId = inner.readTLV(), algId.tag == 0x30 else { return nil } // SEQUENCE algId
guard let octet = inner.readTLV(), octet.tag == 0x04 else { return nil } // OCTET STRING
return Data(octet.value)
}
/// SPKI `SubjectPublicKeyInfo` -> inner PKCS#1 `RSAPublicKey`.
static func pkcs1PublicKey(fromSpki der: Data) -> Data? {
var reader = DERReader(der)
guard let outer = reader.readTLV(), outer.tag == 0x30 else { return nil }
var inner = DERReader(Data(outer.value))
guard let algId = inner.readTLV(), algId.tag == 0x30 else { return nil } // SEQUENCE algId
guard let bitString = inner.readTLV(), bitString.tag == 0x03 else { return nil } // BIT STRING
var bytes = Array(bitString.value)
guard let first = bytes.first, first == 0x00 else { return nil } // unused-bits count
bytes.removeFirst()
return Data(bytes)
}
/// Builds an RSA `SecKey` from raw PKCS#1 DER.
static func rsaKey(pkcs1 der: Data, isPrivate: Bool) -> SecKey? {
let attributes: [CFString: Any] = [
kSecAttrKeyType: kSecAttrKeyTypeRSA,
kSecAttrKeyClass: isPrivate ? kSecAttrKeyClassPrivate : kSecAttrKeyClassPublic,
kSecAttrKeySizeInBits: 2048,
]
var error: Unmanaged<CFError>?
let key = SecKeyCreateWithData(der as CFData, attributes as CFDictionary, &error)
if key == nil {
NSLog("[NSE] SecKeyCreateWithData failed: \(String(describing: error?.takeRetainedValue()))")
}
return key
}
}
/// Tiny non-recursive DER TLV reader over a byte buffer.
private struct DERReader {
private let bytes: [UInt8]
private var pos = 0
init(_ data: Data) { bytes = [UInt8](data) }
/// Reads one tag-length-value triple, advancing past the value. Returns the
/// tag byte and the value bytes, or nil on a malformed/truncated buffer.
mutating func readTLV() -> (tag: UInt8, value: ArraySlice<UInt8>)? {
guard pos < bytes.count else { return nil }
let tag = bytes[pos]
pos += 1
guard pos < bytes.count else { return nil }
var length = Int(bytes[pos])
pos += 1
if length & 0x80 != 0 {
let numLengthBytes = length & 0x7F
guard numLengthBytes > 0, numLengthBytes <= 4, pos + numLengthBytes <= bytes.count else {
return nil
}
length = 0
for _ in 0..<numLengthBytes {
length = (length << 8) | Int(bytes[pos])
pos += 1
}
}
guard pos + length <= bytes.count else { return nil }
let value = bytes[pos..<pos + length]
pos += length
return (tag, value)
}
}
+286
View File
@@ -0,0 +1,286 @@
# iOS Notification Service Extension (NSE) — Setup & Übergabe
> **Für wen:** Eine spätere (kontextlose) Claude-Session **und** den Menschen, der
> auf einem Mac mit Xcode weiterbaut. Dieses Dokument ist die einzige Quelle der
> Wahrheit für die iOS-Push-Fertigstellung. Die Swift-/Dart-Dateien existieren
> bereits (auf Linux textuell erstellt, **nie kompiliert**). Was fehlt, ist die
> reine Xcode-Verdrahtung (Target anlegen, Capabilities, Signing) — das geht
> **nur** auf dem Mac.
---
## 1. Architektur-Kurzfassung
```
Nextcloud (cloud.marianum-fulda.de)
│ push-v2: E2E-verschlüsselte Notification (mit Device-Public-Key verschlüsselt)
MarianumConnect Push-Proxy (connect.marianum-fulda.de/push-proxy/notifications)
│ verifiziert Signatur, leitet per FCM HTTP v1 weiter
FCM → APNs → iOS-Gerät
│ alert-Push mit `mutable-content: 1`, `subject` + `signature` top-level im Payload
NotificationServiceExtension (DIESES Target)
│ liest Device-Private-Key + Server-Public-Key aus dem geteilten Keychain
│ verifiziert Signatur → entschlüsselt subject → setzt Titel/Body/Category
iOS zeigt die fertige Notification
```
- **Verschlüsselung:** OAEP-SHA1 (NC-32-Default) mit Fallback PKCS#1 v1.5.
- **Signatur:** `SHA512withRSA` über die **base64-dekodierten (verschlüsselten)**
subject-Bytes, geprüft mit dem per-User-Server-Public-Key aus der Registrierung.
- **Dart-Referenz:** `lib/push/` — insb. `push_decryptor.dart` (Krypto-Spiegel),
`push_secure_storage.dart` (Keychain-Optionen), `push_keypair.dart` (Keyformat),
`push_registration_store.dart` (was group-scoped gespeichert wird),
`push_registration.dart` (`_persistNativeAuthContext`).
- **Backend-Referenz:** MarianumConnect-Service `marMobileApi`, Endpunkte
`push-proxy/notifications` (NC→Proxy) und `PUT/DELETE me/push-device`.
- **Talk-Actions (Antworten / Als gelesen markieren):** werden **nativ** im
`AppDelegate` per `URLSession` an die Talk-OCS-API geschickt, weil bei einer
Notification-Action nicht garantiert ist, dass die Flutter-Engine läuft.
---
## 2. Dateiinventar
| Datei | Status | Anmerkung |
|-------|--------|-----------|
| `ios/NotificationServiceExtension/NotificationService.swift` | **existiert** | NSE-Hauptklasse (entschlüsseln + Content setzen) |
| `ios/NotificationServiceExtension/PEM.swift` | **existiert** | PEM/DER→`SecKey` (PKCS#8/SPKI-Stripping) |
| `ios/NotificationServiceExtension/Info.plist` | **existiert** | `NSExtensionPointIdentifier = com.apple.usernotifications.service` |
| `ios/NotificationServiceExtension/NotificationServiceExtension.entitlements` | **existiert** | App Group + keychain-access-groups |
| `ios/Runner/Runner.entitlements` | **geändert** | `keychain-access-groups` (widget-Group) ergänzt |
| `ios/Runner/AppDelegate.swift` | **geändert** | TALK_MESSAGE-Category + native Action-Behandlung |
| `lib/push/push_registration_store.dart` | **geändert** | schreibt `nextcloud_username` + `nextcloud_base_url` group-scoped |
| `lib/push/push_registration.dart` | **geändert** | `_persistNativeAuthContext()` bei `register()` |
| **Xcode-Target „NotificationServiceExtension"** | **FEHLT** | muss in Xcode angelegt werden (Abschnitt 3) |
| `ios/Runner.xcodeproj/project.pbxproj` | **unverändert** | bewusst NICHT von Hand editiert — Xcode legt das Target an |
> **Wichtig:** Die vier Dateien unter `ios/NotificationServiceExtension/` liegen
> schon auf der Platte. Beim Anlegen des Targets erzeugt Xcode eigene
> Platzhalter-Dateien — **diese löschen und die vorhandenen Dateien zuordnen**
> (siehe 3.2), sonst überschreibst du die fertige Implementierung.
---
## 3. Xcode-Checkliste (auf dem Mac)
### 3.1 Target anlegen
1. `ios/Runner.xcworkspace` in Xcode öffnen (nicht `.xcodeproj`).
2. **File → New → Target… → iOS → Notification Service Extension**.
3. **Product Name exakt: `NotificationServiceExtension`** (der
`NSExtensionPrincipalClass` in der Info.plist ist
`$(PRODUCT_MODULE_NAME).NotificationService` — bei abweichendem Namen zerbricht
das). Language: Swift. „Embed in Application": Runner.
4. Beim Dialog „Activate scheme?" → Activate.
5. **Bundle Identifier:** `eu.mhsl.marianum.mobile.client.NotificationServiceExtension`
(Muster wie bei den bestehenden Extensions).
6. **Deployment Target = 15.0** (identisch zum Runner; im `post_install` des
Podfiles wird ohnehin alles auf 15.0 gezwungen).
### 3.2 Generierte Dateien durch die vorhandenen ersetzen
- Xcode legt eine eigene `NotificationService.swift` und `Info.plist` im
Target-Ordner an. **Beide aus dem Projekt entfernen** („Move to Trash" für die
frisch generierten) und stattdessen die bereits vorhandenen Dateien hinzufügen:
- `NotificationService.swift`, `PEM.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 = <key>` +
`kSecAttrAccessGroup` + `kSecMatchLimit=One` + `kSecReturnData=true`, **ohne**
`kSecAttrService`. (Ein gesetztes `kSecAttrService` würde nicht matchen.)
### Konkrete Einträge (Account-Namen)
| Account (`kSecAttrAccount`) | Inhalt | Geschrieben von | Genutzt von |
|-----------------------------|--------|-----------------|-------------|
| `push_device_private_key_pem` | Device-**Private**-Key, PEM | `push_keypair.dart` | **NSE** (entschlüsseln) |
| `push_device_public_key_pem` | Device-Public-Key, SPKI-PEM | `push_keypair.dart` | — (nur Registrierung) |
| `push_server_public_key_pem` | **Server**-Public-Key (per User), SPKI-PEM | `push_registration_store.dart` | **NSE** (Signatur prüfen) |
| `push_device_identifier` | NC Device-Identifier | `push_registration_store.dart` | Dart |
| `push_registered_fcm_token` | FCM-Token der Registrierung | `push_registration_store.dart` | Dart |
| `nextcloud_app_password` | NC App-Password | `account_data.dart` | **AppDelegate** (Basic-Auth) |
| `nextcloud_username` | NC Username | `push_registration_store.dart` (**neu**) | **AppDelegate** (Basic-Auth) |
| `nextcloud_base_url` | z.B. `https://cloud.marianum-fulda.de` | `push_registration_store.dart` (**neu**) | **AppDelegate** (OCS-URL) |
> `username` und `password` (Realpasswort) liegen in AccountDatas **Default**-Storage
> **ohne** `groupId` → nur im primären Access-Group der App, **für die NSE
> unsichtbar**. Deshalb werden `nextcloud_username` + `nextcloud_base_url` bei
> jeder `register()`-Runde zusätzlich **group-scoped** geschrieben. Das
> App-Password lag schon immer group-scoped.
### Schlüsselformate (kritisch für das PEM-Parsing)
`crypton` (2.2.1) erzeugt die Keys:
- **Private Key:** Label `-----BEGIN RSA PRIVATE KEY-----`, der DER-Body ist aber
**PKCS#8** `PrivateKeyInfo` (SEQ{ INTEGER version, SEQ algId, OCTET STRING
pkcs1 }) — **nicht** rohes PKCS#1! `SecKeyCreateWithData` will bei RSA aber
**rohes PKCS#1**. `PEM.pkcs1PrivateKey(fromPkcs8:)` strippt den PKCS#8-Wrapper
(extrahiert den OCTET-STRING-Inhalt).
- **Public Key** (Device **und** der von NC gelieferte Server-Key): Label
`-----BEGIN PUBLIC KEY-----`, DER = **SPKI** `SubjectPublicKeyInfo`.
`PEM.pkcs1PublicKey(fromSpki:)` extrahiert den BIT-STRING-Inhalt und wirft das
führende `0x00`-Byte (unused bits) weg → PKCS#1 `RSAPublicKey`.
---
## 5. Verifikation auf dem Mac
### 5.1 Build
- `flutter clean && flutter pub get`
- `cd ios && pod install`
- In Xcode: **beide** Targets bauen. NSE-Target-Scheme separat bauen, um Swift-
Fehler früh zu sehen.
- Auf echtem Gerät installieren (Push funktioniert **nicht** im Simulator).
### 5.2 aps-environment im Archive prüfen
- Product → Archive → Distribute (oder `.ipa` exportieren) → `.ipa` entpacken →
`codesign -d --entitlements :- Payload/Runner.app` → muss
`aps-environment = production` zeigen.
### 5.3 Testbenachrichtigung (schneller Smoke-Test, ohne NC-Krypto)
- In den App-Einstellungen → Benachrichtigungen → **Testbenachrichtigung**
(Backend `POST me/push-device/test`). Erwartung: Alert „Testbenachrichtigung /
Push-Benachrichtigungen funktionieren! 🎉". Testet Registrierung + FCM +
Rendering — die NSE ist hier **nicht** beteiligt (Connect-Push, kein
`mutable-content`).
### 5.4 Echte Talk-Nachricht (End-to-End inkl. NSE)
- Gerät sperren. Von einem Zweitaccount eine Talk-Nachricht schicken.
- **Erwartung:** Banner mit **echtem Absender + Nachrichtentext** (nicht der
Platzhalter „Neue Benachrichtigung / Tippen zum Öffnen"). Long-press →
Actions „Antworten" + „Als gelesen markieren".
- „Antworten" → Text senden → Nachricht erscheint im Chat (nativ via URLSession),
Chat wird als gelesen markiert.
- Alternativ auf der NC-Instanz: `occ notification:test-push --talk <user> -v`.
### 5.5 Woran erkenne ich, ob die NSE lief?
- **NSE lief & OK:** echter Absender/Text im Banner.
- **NSE lief nicht / Fehler:** Platzhalter-Text bleibt stehen. Das ist der
bewusste Fallback (nie leer, nie Crash).
- **Logs:** Über die macOS-**Console.app** das Gerät wählen und nach `[NSE]` bzw.
`[Talk action]` filtern (`NSLog`-Ausgaben). Oder in Xcode: Debug → Attach to
Process → `NotificationServiceExtension`.
---
## 6. Bekannte Fehlerbilder + Fixes
| Symptom | Wahrscheinliche Ursache | Fix |
|---------|-------------------------|-----|
| NSE greift gar nicht (immer Platzhalter, auch bei Talk) | `mutable-content` fehlt / Signing des NSE-Targets kaputt / Bundle-ID falsch | Backend-`FcmMessageFactory` setzt `mutable-content:1` (prüfen). NSE-Target-Signing + Provisioning prüfen. Bundle-ID = `…client.NotificationServiceExtension`. |
| Banner zeigt Platzhalter trotz Talk | PEM-Parsing/Padding schlägt fehl | Console.app nach `[NSE] SecKeyCreateWithData failed` / `could not decrypt`. Prüfen, dass Private-Key wirklich als PKCS#8-in-„RSA PRIVATE KEY" ankommt (crypton). OAEP↔PKCS1-Fallback ist schon drin. |
| `[NSE] no device private key in keychain` | Keychain-Group greift nicht | In **beiden** Targets Keychain Sharing + App Group aktiv? Team-ID identisch? Access-Group-String exakt `group.eu.mhsl.marianum.mobile.client.widget`? |
| Signatur schlägt immer fehl | Falscher/kein Server-Public-Key | `push_server_public_key_pem` vorhanden? (wird bei `register()` gesetzt). Notfalls: ohne Server-Key überspringt die NSE die Prüfung — bleibt sie hängen, ist der Key da aber falsch geparst. |
| Reply-Action tut nichts | Credentials fehlen / Delegate-Konflikt (s.u.) | Console.app nach `[Talk action]`. `nextcloud_username`/`_app_password`/`_base_url` im Keychain? App einmal neu einloggen (schreibt sie via `register()`). |
| Reply wird **doppelt** gesendet | AppDelegate **und** ein Plugin behandeln dieselbe Action | Der AppDelegate returnt bei TALK_REPLY/TALK_MARK_READ **ohne** `super`-Aufruf, das Plugin sieht die Action also nicht. Falls doch doppelt: prüfen, ob `flutter_local_notifications` auf iOS separat als Delegate registriert ist. |
### ⚠️ Delegate-Ownership (wichtigster Verifikationspunkt)
`FlutterAppDelegate` konformiert (über `FlutterAppLifeCycleProvider`) zu
`UNUserNotificationCenterDelegate` und **leitet** `userNotificationCenter(...)` an
die registrierten Plugins weiter (firebase_messaging,
flutter_local_notifications). Der `AppDelegate` setzt sich in
`didFinishLaunching` als `UNUserNotificationCenter.current().delegate = self`,
**überschreibt** die beiden Delegate-Methoden und ruft für alles **außer** den
zwei Talk-Actions `super` auf — dadurch bleibt das Plugin-Forwarding intakt. Das
ist der fragilste Teil und **muss auf dem Gerät verifiziert werden**:
- Normale Push-Taps müssen weiterhin ins richtige Chat/Ziel navigieren
(läuft über `super` → Plugins).
- Foreground-Darstellung von FCM darf nicht kaputtgehen (`willPresent` wird hier
bewusst **nicht** überschrieben, läuft komplett über `super`/Plugins).
- Sollte ein Plugin den Delegate **nach** `didFinishLaunching` erneut übernehmen
(und damit unsere Talk-Actions abfangen), das Setzen von
`UNUserNotificationCenter.current().delegate = self` an einen späteren Punkt
verschieben (z. B. nach dem ersten Dart-Frame). Ohne Gerät nicht abschließend
testbar.
> **Compile-Hinweis:** `override` an den beiden `userNotificationCenter`-Methoden
> ist korrekt, weil die Methoden über das von `FlutterAppDelegate` adoptierte
> Protokoll `FlutterAppLifeCycleProvider : UNUserNotificationCenterDelegate`
> sichtbar sind (verifiziert im Flutter-3.44-Engine-Header). `super.userNotification…`
> ist daher aufrufbar.
---
## 7. Offene TODOs / bewusste Einschränkungen
1. **Delete-Handling auf iOS (best effort).** Delete-Pushes kommen als silent
`background`-Pushes (`content-available`, **kein** `mutable-content`) → die NSE
läuft dafür **nicht**. Das Wegräumen erledigt der Flutter-Background-Handler
(`PushMessageHandler`) bzw. ein Sweep beim App-Öffnen. Erreicht doch mal ein
Delete-Subject die NSE, **kann** sie die Notification **nicht** unterdrücken
(eine NSE muss immer *irgendeinen* Content liefern) — sie lässt den Platzhalter
stehen und loggt. Kein Fix möglich, dokumentierte iOS-Limitierung.
2. **Rich-Detail via OCS (spätere Erweiterung).** v1 lädt **nichts** nach. Optional
könnte die NSE bei Talk `GET …/ocs/v2.php/apps/notifications/api/v2/notifications/{nid}`
mit dem App-Password abrufen, um Avatar/Rich-Subject anzuzeigen (Timeout < 25 s,
innerhalb des NSE-Budgets). Nicht implementiert.
3. **`aps-environment = production`** ist noch nicht hart gesetzt (Abschnitt 3.6) —
vor dem Release erledigen und im Archive gegenchecken (5.2).
4. **Keychain-Access-Group-Schreibweise.** Die Entitlements listen die App-Group
ohne `$(AppIdentifierPrefix)` als `keychain-access-groups`. Das ist das von
`flutter_secure_storage` erwartete Verhalten (Access-Group == App-Group-ID).
Sollte der Keychain-Zugriff wider Erwarten scheitern (Status `-34018` /
`errSecMissingEntitlement`), in **beiden** Targets die Keychain-Sharing-
Capability über die Xcode-UI neu setzen und Provisioning-Profile erneuern.
+188 -1
View File
@@ -1,16 +1,203 @@
import Flutter import Flutter
import UIKit import UIKit
import UserNotifications
@main @main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { @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( override func application(
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> 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) { func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
} }
private func registerTalkCategory() {
let reply = UNTextInputNotificationAction(
identifier: replyActionId,
title: "Antworten",
options: [],
textInputButtonTitle: "Senden",
textInputPlaceholder: "Nachricht"
)
let markRead = UNNotificationAction(
identifier: markReadActionId,
title: "Als gelesen markieren",
options: []
)
let category = UNNotificationCategory(
identifier: talkCategoryId,
actions: [reply, markRead],
intentIdentifiers: [],
options: []
)
UNUserNotificationCenter.current().setNotificationCategories([category])
}
// MARK: - UNUserNotificationCenterDelegate
override func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let actionId = response.actionIdentifier
if actionId == replyActionId || actionId == markReadActionId {
// Handled natively; deliberately NOT forwarded to super so the plugins
// don't also process it (which would double-send the reply).
handleTalkAction(response, completion: completionHandler)
return
}
super.userNotificationCenter(
center, didReceive: response, withCompletionHandler: completionHandler)
}
// MARK: - Native Talk action handling
private func handleTalkAction(
_ response: UNNotificationResponse,
completion: @escaping () -> Void
) {
guard
let token = chatToken(from: response),
let credentials = loadCredentials()
else {
completion()
return
}
let group = DispatchGroup()
if response.actionIdentifier == replyActionId,
let text = (response as? UNTextInputNotificationResponse)?.userText
.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty {
group.enter()
ocsPost(
credentials: credentials,
path: "apps/spreed/api/v1/chat/\(token)",
form: ["message": text]
) { group.leave() }
}
// Both reply and mark-read clear the unread marker on the chat.
group.enter()
ocsPost(
credentials: credentials,
path: "apps/spreed/api/v1/chat/\(token)/read",
form: nil
) { group.leave() }
group.notify(queue: .main) { completion() }
}
private func chatToken(from response: UNNotificationResponse) -> String? {
let userInfo = response.notification.request.content.userInfo
if let token = userInfo["chatToken"] as? String, !token.isEmpty {
return token
}
let thread = response.notification.request.content.threadIdentifier
if thread.hasPrefix("talk_") {
let token = String(thread.dropFirst("talk_".count))
return token.isEmpty ? nil : token
}
return nil
}
private struct Credentials {
let baseUrl: String
let authorization: String
}
private func loadCredentials() -> Credentials? {
guard
let username = keychainString(usernameAccount),
let appPassword = keychainString(appPasswordAccount),
let baseUrl = keychainString(baseUrlAccount),
let token = "\(username):\(appPassword)".data(using: .utf8)
else { return nil }
let authorization = "Basic \(token.base64EncodedString())"
let trimmed = baseUrl.hasSuffix("/") ? String(baseUrl.dropLast()) : baseUrl
return Credentials(baseUrl: trimmed, authorization: authorization)
}
private func ocsPost(
credentials: Credentials,
path: String,
form: [String: String]?,
completion: @escaping () -> Void
) {
guard let url = URL(string: "\(credentials.baseUrl)/ocs/v2.php/\(path)") else {
completion()
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue(credentials.authorization, forHTTPHeaderField: "Authorization")
request.setValue("true", forHTTPHeaderField: "OCS-APIRequest")
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let form = form {
request.setValue(
"application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.httpBody = form
.map { "\($0.key)=\(formEncode($0.value))" }
.joined(separator: "&")
.data(using: .utf8)
}
URLSession.shared.dataTask(with: request) { _, response, error in
if let error = error {
NSLog("[Talk action] \(path) failed: \(error.localizedDescription)")
} else if let http = response as? HTTPURLResponse,
!(200..<300).contains(http.statusCode) {
NSLog("[Talk action] \(path) -> HTTP \(http.statusCode)")
}
completion()
}.resume()
}
private func formEncode(_ value: String) -> String {
var allowed = CharacterSet.alphanumerics
allowed.insert(charactersIn: "-._~")
return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
}
// MARK: - Keychain (shared App Group)
private func keychainString(_ account: String) -> String? {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: account,
kSecAttrAccessGroup: keychainAccessGroup,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne,
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
} }
+4
View File
@@ -9,5 +9,9 @@
<string>group.eu.mhsl.marianum.mobile.client.widget</string> <string>group.eu.mhsl.marianum.mobile.client.widget</string>
<string>group.eu.mhsl.marianum.mobile.client.share</string> <string>group.eu.mhsl.marianum.mobile.client.share</string>
</array> </array>
<key>keychain-access-groups</key>
<array>
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
</array>
</dict> </dict>
</plist> </plist>
@@ -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<void> run() async {
await _client.delete(
NextcloudOcs.uri('core/apppassword'),
headers: NextcloudOcs.headers(),
);
}
}
@@ -0,0 +1,45 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../../model/account_data.dart';
import '../nextcloud_ocs.dart';
/// Exchanges the user's real Nextcloud password for a scoped app password via
/// `GET /ocs/v2.php/core/getapppassword`. All subsequent Nextcloud calls then
/// authenticate with the app password (see [AccountData.getBasicAuthHeader]),
/// which is what the push-v2 registration binds to.
///
/// Must authenticate with the *real* password — an app password cannot mint
/// another one.
class GetAppPassword {
final http.Client _client;
GetAppPassword({http.Client? client}) : _client = client ?? http.Client();
/// Returns the freshly minted app password. Throws on any transport or
/// protocol error — callers treat push registration as best-effort and swallow
/// failures.
Future<String> run() async {
final response = await _client.get(
NextcloudOcs.uri('core/getapppassword'),
headers: {
...NextcloudOcs.headers(),
// Deliberately NOT the shared Authorization value: that one prefers
// the app password, but an app password cannot mint another one —
// this endpoint requires the real password.
'Authorization': AccountData().getRealPasswordBasicAuthHeader(),
},
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('getapppassword HTTP ${response.statusCode}');
}
final json = jsonDecode(utf8.decode(response.bodyBytes));
final data = (json as Map)['ocs']?['data'];
final appPassword = data is Map ? data['apppassword'] as String? : null;
if (appPassword == null || appPassword.isEmpty) {
throw Exception('getapppassword: no apppassword in response');
}
return appPassword;
}
}
@@ -11,7 +11,15 @@ class CapabilitiesResponse {
@JsonKey(defaultValue: false) @JsonKey(defaultValue: false)
final bool viewForeignTimetables; 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<String, dynamic> json) => factory CapabilitiesResponse.fromJson(Map<String, dynamic> json) =>
_$CapabilitiesResponseFromJson(json); _$CapabilitiesResponseFromJson(json);
@@ -10,8 +10,12 @@ CapabilitiesResponse _$CapabilitiesResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => CapabilitiesResponse( ) => CapabilitiesResponse(
viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false, viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false,
pushNotifications: json['pushNotifications'] as bool? ?? false,
); );
Map<String, dynamic> _$CapabilitiesResponseToJson( Map<String, dynamic> _$CapabilitiesResponseToJson(
CapabilitiesResponse instance, CapabilitiesResponse instance,
) => <String, dynamic>{'viewForeignTimetables': instance.viewForeignTimetables}; ) => <String, dynamic>{
'viewForeignTimetables': instance.viewForeignTimetables,
'pushNotifications': instance.pushNotifications,
};
@@ -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<void> run({
required String deviceIdentifier,
required String deviceIdentifierSignature,
required String userPublicKey,
required String pushToken,
required String platform,
String? appVersion,
}) async {
try {
await _dio.put<void>(
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);
}
}
}
@@ -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<int> run() async {
try {
final response = await _dio.post<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve('me/push-device/test'),
);
return (response.data?['devices'] as num?)?.toInt() ?? 0;
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -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<void> run({required String deviceIdentifier}) async {
try {
await _dio.delete<void>(
MarianumConnectEndpoint.resolve('me/push-device'),
queryParameters: {'deviceIdentifier': deviceIdentifier},
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -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<void> {
NotifyRegisterParams params;
NotifyRegister(this.params) : super('notify/register/');
@override
void assemble(String raw) {}
@override
Future<http.Response> request(Uri uri) {
var requestString = jsonEncode(params.toJson());
log('register at push proxy with username ${params.username}');
return http.post(uri, body: requestString);
}
}
@@ -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<String, dynamic> json) =>
_$NotifyRegisterParamsFromJson(json);
Map<String, dynamic> toJson() => _$NotifyRegisterParamsToJson(this);
}
@@ -1,23 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'notify_register_params.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
NotifyRegisterParams _$NotifyRegisterParamsFromJson(
Map<String, dynamic> json,
) => NotifyRegisterParams(
username: json['username'] as String,
password: json['password'] as String,
fcmToken: json['fcmToken'] as String,
);
Map<String, dynamic> _$NotifyRegisterParamsToJson(
NotifyRegisterParams instance,
) => <String, dynamic>{
'username': instance.username,
'password': instance.password,
'fcmToken': instance.fcmToken,
};
+28 -9
View File
@@ -12,7 +12,8 @@ import 'main.dart';
import 'model/data_cleaner.dart'; import 'model/data_cleaner.dart';
import 'notification/notification_controller.dart'; import 'notification/notification_controller.dart';
import 'notification/notification_tasks.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 'routing/app_routes.dart';
import 'share_intent/share_intent_listener.dart'; import 'share_intent/share_intent_listener.dart';
import 'state/app/modules/app_modules.dart'; import 'state/app/modules/app_modules.dart';
@@ -85,6 +86,13 @@ class _AppState extends State<App> with WidgetsBindingObserver {
} }
} }
void _onPushTapPending() {
final token = PushTapRouter.pendingChatToken.value;
if (token == null || !mounted) return;
PushTapRouter.pendingChatToken.value = null;
NotificationTasks.navigateToTalk(context, chatToken: token);
}
Future<void> _handlePendingWidgetNavigation() async { Future<void> _handlePendingWidgetNavigation() async {
final pending = await WidgetNavigation.consumePendingTimetableTap(); final pending = await WidgetNavigation.consumePendingTimetableTap();
if (!pending || !mounted) return; if (!pending || !mounted) return;
@@ -165,22 +173,32 @@ class _AppState extends State<App> with WidgetsBindingObserver {
UpdateUserIndex.index(); 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<SettingsCubit>().val().notificationSettings.enabled) { if (context.read<SettingsCubit>().val().notificationSettings.enabled) {
void update() => NotifyUpdater.registerToServer(); _fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen((
_fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen( _,
(_) => update(), ) {
); Debouncer.debounce(
update(); '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) { _onMessageSub = FirebaseMessaging.onMessage.listen((message) {
if (!mounted) return; if (!mounted) return;
NotificationController.onForegroundMessageHandler(message, context); 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(( _onMessageOpenedAppSub = FirebaseMessaging.onMessageOpenedApp.listen((
message, message,
) { ) {
@@ -202,6 +220,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
_onMessageSub?.cancel(); _onMessageSub?.cancel();
_onMessageOpenedAppSub?.cancel(); _onMessageOpenedAppSub?.cancel();
_fcmTokenRefreshSub?.cancel(); _fcmTokenRefreshSub?.cancel();
PushTapRouter.pendingChatToken.removeListener(_onPushTapPending);
ShareIntentListener.pending.removeListener(_handlePendingShare); ShareIntentListener.pending.removeListener(_handlePendingShare);
ShareIntentListener.instance.detach(); ShareIntentListener.instance.detach();
Main.bottomNavigator.removeListener(_onTabControllerChanged); Main.bottomNavigator.removeListener(_onTabControllerChanged);
+30 -2
View File
@@ -25,6 +25,10 @@ import 'app.dart';
import 'background/widget_background_task.dart'; import 'background/widget_background_task.dart';
import 'firebase_options.dart'; import 'firebase_options.dart';
import 'model/account_data.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 'routing/app_routes.dart';
import 'share_intent/share_intent_listener.dart'; import 'share_intent/share_intent_listener.dart';
import 'state/app/modules/account/bloc/account_bloc.dart'; import 'state/app/modules/account/bloc/account_bloc.dart';
@@ -87,6 +91,13 @@ Future<void> main() async {
await Future.wait(initialisationTasks); await Future.wait(initialisationTasks);
log('app initialisation done!'); 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 // Wire up the home-screen widget bridge before runApp so any widget render
// triggered during startup hits initialised native storage. // triggered during startup hits initialised native storage.
await WidgetSync.ensureInitialized(); await WidgetSync.ensureInitialized();
@@ -207,8 +218,14 @@ class _MainState extends State<Main> {
_scheduleSessionValidation(accountBloc); _scheduleSessionValidation(accountBloc);
// Cold start while already logged in: the account status doesn't // Cold start while already logged in: the account status doesn't
// change, so the loggedIn listener below never fires — refresh // change, so the loggedIn listener below never fires — refresh
// capabilities here. // capabilities here, then self-heal the push registration.
unawaited(context.read<CapabilitiesCubit>().load()); final settingsCubit = context.read<SettingsCubit>();
unawaited(
context.read<CapabilitiesCubit>().load().then((_) {
if (!mounted) return;
_syncPush(settingsCubit, context.read<CapabilitiesCubit>());
}),
);
unawaited(context.read<NextcloudCapabilitiesCubit>().load()); unawaited(context.read<NextcloudCapabilitiesCubit>().load());
} }
}); });
@@ -225,6 +242,17 @@ class _MainState extends State<Main> {
unawaited(ListFilesCache.prefetchRootListing()); 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) { void _scheduleSessionValidation(AccountBloc accountBloc) {
unawaited( unawaited(
SessionValidator.probeStored( SessionValidator.probeStored(
+56
View File
@@ -6,9 +6,14 @@ import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../push/push_secure_storage.dart';
class AccountData { class AccountData {
static const _usernameField = 'username'; static const _usernameField = 'username';
static const _passwordField = 'password'; 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(); static const FlutterSecureStorage _secureStorage = FlutterSecureStorage();
@@ -23,6 +28,7 @@ class AccountData {
String? _username; String? _username;
String? _password; String? _password;
String? _appPassword;
String getUsername() { String getUsername() {
if (_username == null) throw Exception('Username not initialized'); if (_username == null) throw Exception('Username not initialized');
@@ -58,14 +64,49 @@ class AccountData {
_populated = Completer(); _populated = Completer();
_username = null; _username = null;
_password = null; _password = null;
_appPassword = null;
await _secureStorage.delete(key: _usernameField); await _secureStorage.delete(key: _usernameField);
await _secureStorage.delete(key: _passwordField); 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<void> 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<void> clearAppPassword() async {
_appPassword = null;
await _clearAppPasswordStorage();
}
bool hasAppPassword() => _appPassword != null && _appPassword!.isNotEmpty;
Future<void> _clearAppPasswordStorage() async {
try {
await pushSecureStorage.delete(key: _appPasswordField);
} on Object {
// ignore — nothing stored or keystore unavailable
}
} }
Future<void> _migrateAndLoad() async { Future<void> _migrateAndLoad() async {
await _migrateFromLegacyStorage(); await _migrateFromLegacyStorage();
_username = await _secureStorage.read(key: _usernameField); _username = await _secureStorage.read(key: _usernameField);
_password = await _secureStorage.read(key: _passwordField); _password = await _secureStorage.read(key: _passwordField);
try {
_appPassword = await pushSecureStorage.read(key: _appPasswordField);
} on Object {
_appPassword = null;
}
if (!_populated.isCompleted) _populated.complete(); if (!_populated.isCompleted) _populated.complete();
} }
@@ -97,6 +138,21 @@ class AccountData {
/// Prefer this over embedding credentials in URLs — error logs and crash /// Prefer this over embedding credentials in URLs — error logs and crash
/// reports often capture the URL but not headers. /// reports often capture the URL but not headers.
String getBasicAuthHeader() { 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()) { if (!isPopulated()) {
throw Exception( throw Exception(
'AccountData (e.g. username or password) is not initialized!', 'AccountData (e.g. username or password) is not initialized!',
+16 -23
View File
@@ -4,44 +4,36 @@ import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../push/push_message_handler.dart';
import '../state/app/modules/chat/bloc/chat_bloc.dart'; import '../state/app/modules/chat/bloc/chat_bloc.dart';
import '../widget/debug/debug_tile.dart'; import '../widget/debug/debug_tile.dart';
import '../widget/debug/json_viewer.dart'; import '../widget/debug/json_viewer.dart';
import '../widget/info_dialog.dart'; import '../widget/info_dialog.dart';
import 'notification_tasks.dart'; import 'notification_tasks.dart';
// `vm:entry-point` keeps this alive through AOT tree-shaking — the FCM /// Bridges FCM lifecycle callbacks to the push pipeline. Background messages are
// background isolate looks the class up by name from native code. /// handled directly by [PushMessageHandler.onBackgroundMessage]; this class
@pragma('vm:entry-point') /// covers the foreground and app-opened paths where a [BuildContext] is
/// available.
class NotificationController { class NotificationController {
@pragma('vm:entry-point')
static Future<void> onBackgroundMessageHandler(RemoteMessage message) async {
NotificationTasks.updateBadgeCount(message);
}
static Future<void> onForegroundMessageHandler( static Future<void> onForegroundMessageHandler(
RemoteMessage message, RemoteMessage message,
BuildContext context, BuildContext context,
) async { ) async {
final pushToken = _extractChatToken(message);
final chatBloc = context.read<ChatBloc>(); final chatBloc = context.read<ChatBloc>();
// hasOpenChat, not currentToken: currentToken sticks around after // hasOpenChat, not currentToken: currentToken sticks around after
// leaveChat so didPopNext can re-claim a stacked chat. // leaveChat so didPopNext can re-claim a stacked chat.
final activeToken = chatBloc.state.data?.currentToken ?? ''; final openChatToken = chatBloc.hasOpenChat
final chatIsOpen = ? (chatBloc.state.data?.currentToken ?? '')
chatBloc.hasOpenChat && : null;
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;
}
await PushMessageHandler().handle(
message,
foreground: true,
openChatToken: openChatToken,
);
await NotificationTasks.refreshBadge();
if (!context.mounted) return;
NotificationTasks.updateProviders(context); NotificationTasks.updateProviders(context);
} }
@@ -54,6 +46,7 @@ class NotificationController {
chatToken: _extractChatToken(message), chatToken: _extractChatToken(message),
); );
NotificationTasks.updateProviders(context); NotificationTasks.updateProviders(context);
unawaited(NotificationTasks.refreshBadge());
DebugTile(context).run(() { DebugTile(context).run(() {
InfoDialog.show( InfoDialog.show(
+28 -29
View File
@@ -1,5 +1,9 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; 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 { class NotificationService {
static final NotificationService _instance = NotificationService._internal(); static final NotificationService _instance = NotificationService._internal();
@@ -15,7 +19,27 @@ class NotificationService {
'@mipmap/ic_launcher', '@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( final initializationSettings = InitializationSettings(
android: androidSettings, android: androidSettings,
@@ -24,34 +48,9 @@ class NotificationService {
await flutterLocalNotificationsPlugin.initialize( await flutterLocalNotificationsPlugin.initialize(
settings: initializationSettings, settings: initializationSettings,
); onDidReceiveNotificationResponse: PushTapRouter.handleResponse,
} onDidReceiveBackgroundNotificationResponse:
PushActions.handleBackgroundResponse,
Future<void> 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,
); );
} }
} }
+12 -5
View File
@@ -1,7 +1,6 @@
import 'dart:developer'; import 'dart:developer';
import 'package:eraser/eraser.dart'; import 'package:eraser/eraser.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_app_badge/flutter_app_badge.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'; import 'notification_service.dart';
class NotificationTasks { class NotificationTasks {
static void updateBadgeCount(RemoteMessage notification) { /// Recomputes the app badge from the notifications currently in the tray.
FlutterAppBadge.count( /// Deterministic — no server-provided counter to drift out of sync — so the
int.parse((notification.data['unreadCount'] as String?) ?? '0'), /// badge always matches what the user actually sees. Called after rendering,
); /// cancelling, or opening the app.
static Future<void> 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 /// Per-chat tag scheme. MUST match the Notify backend, which sets this
-51
View File
@@ -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<void> 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(),
);
}
}
+88
View File
@@ -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<NextcloudPushRegistration> 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<bool> unregister() async {
final response = await _client.delete(
NextcloudOcs.uri(_path),
headers: NextcloudOcs.headers(),
);
return response.statusCode == 202;
}
}
+72
View File
@@ -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<String, dynamic> toJson() => {
'nid': nid,
'notificationId': notificationId,
'tag': tag,
if (chatToken != null) 'chatToken': chatToken,
};
factory NidEntry.fromJson(Map<String, dynamic> 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<void> put(NidEntry entry) =>
_db.collection(_collection).doc('${entry.nid}').set(entry.toJson());
Future<NidEntry?> get(int nid) async {
final data = await _db.collection(_collection).doc('$nid').get();
if (data == null) return null;
return NidEntry.fromJson(data);
}
Future<void> delete(int nid) =>
_db.collection(_collection).doc('$nid').delete();
Future<List<NidEntry>> all() async {
final docs = await _db.collection(_collection).get();
if (docs == null) return const [];
return docs.values
.map((e) => NidEntry.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<void> 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/<nid>).
final nid = int.tryParse(id.split('/').last);
if (nid != null) await delete(nid);
}
}
}
+101
View File
@@ -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<void> 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<void> sendReply(String chatToken, String message) async {
await _ocsPost(
'apps/spreed/api/v1/chat/$chatToken',
body: {'message': message},
);
}
static Future<void> markRead(String chatToken) async {
await _ocsPost('apps/spreed/api/v1/chat/$chatToken/read');
}
static Future<void> _ocsPost(String path, {Map<String, String>? 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<void> _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<String, dynamic>;
final value = map[key];
return value?.toString();
} on Object {
return null;
}
}
}
+77
View File
@@ -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<String, dynamic>;
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<pc.RSAPrivateKey>(
devicePrivateKey.asPointyCastle,
),
);
final out = cipher.process(data);
return utf8.decode(out);
} on Object {
return null;
}
}
}
+105
View File
@@ -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<PushKeypairPems> 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<RSAPrivateKey?> loadPrivateKey() async {
final pem = await _storage.read(key: _privateKeyKey);
if (pem == null || pem.isEmpty) return null;
return RSAPrivateKey.fromPEM(pem);
}
Future<String?> loadPublicKeyPem() => _storage.read(key: _publicKeyKey);
Future<void> clear() async {
await _storage.delete(key: _privateKeyKey);
await _storage.delete(key: _publicKeyKey);
}
Future<PushKeypairPems?> _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<String?> read({required String key});
Future<void> write({required String key, required String? value});
Future<void> delete({required String key});
}
class _DefaultStorage implements FlutterSecureStorageLike {
const _DefaultStorage();
@override
Future<String?> read({required String key}) =>
pushSecureStorage.read(key: key);
@override
Future<void> write({required String key, required String? value}) =>
pushSecureStorage.write(key: key, value: value);
@override
Future<void> delete({required String key}) =>
pushSecureStorage.delete(key: key);
}
+193
View File
@@ -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<String, dynamic> 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<void> 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<void> 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<void> _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<void> _handleNextcloud(
Map<String, dynamic> 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<void> _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 = <int>[
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<void> _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<RSAPublicKey?> _loadServerPublicKey() async {
final pem = await _registrationStore.serverPublicKeyPem();
if (pem == null || pem.isEmpty) return null;
try {
return RSAPublicKey.fromPEM(pem);
} on Object {
return null;
}
}
}
+251
View File
@@ -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<void> 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<void> 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<void> _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<void> 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<bool> 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<void> 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<bool> 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<bool> 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<void> 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<void> 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<void> logoutCleanup() async {
await unregister();
try {
await DeleteAppPassword().run();
} on Object catch (e) {
log('Push: delete app password failed: $e');
}
await AccountData().clearAppPassword();
}
}
+87
View File
@@ -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<void> 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<void> saveNativeAuthContext({
required String username,
required String baseUrl,
}) async {
await pushSecureStorage.write(key: _usernameKey, value: username);
await pushSecureStorage.write(key: _baseUrlKey, value: baseUrl);
}
Future<String?> deviceIdentifier() =>
pushSecureStorage.read(key: _deviceIdentifierKey);
Future<String?> serverPublicKeyPem() =>
pushSecureStorage.read(key: _serverPublicKeyKey);
Future<String?> registeredFcmToken() =>
pushSecureStorage.read(key: _registeredTokenKey);
/// Proxy-server URL the current registration was made with.
Future<String?> registeredProxyServer() =>
pushSecureStorage.read(key: _proxyServerKey);
/// Nextcloud base URL the current registration was made against.
Future<String?> 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<bool> isRegistered() async =>
(await registeredFcmToken())?.isNotEmpty ?? false;
Future<void> 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);
}
}
+196
View File
@@ -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<void> 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<void> render(PushSubject subject) async {
if (subject.isTalk) {
await _renderTalk(subject);
} else {
await _renderGeneric(subject);
}
}
Future<void> _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<void> _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<void> renderConnect({
required String title,
required String body,
Map<String, String>? 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;
}
}
+27
View File
@@ -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,
);
+66
View File
@@ -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<int> 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<String, dynamic> 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<Object>()
.map((e) => e is num ? e.toInt() : int.tryParse('$e'))
.whereType<int>()
.toList()
: const [],
);
}
}
+40
View File
@@ -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<String?> 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<String, dynamic>;
final token = map['chatToken'];
return token is String && token.isNotEmpty ? token : null;
} on Object {
return null;
}
}
}
@@ -13,6 +13,8 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
bool get canViewForeignTimetables => state.viewForeignTimetables; bool get canViewForeignTimetables => state.viewForeignTimetables;
bool get canReceivePushNotifications => state.pushNotifications;
/// Refreshes capabilities from the server. On any failure (endpoint not yet /// Refreshes capabilities from the server. On any failure (endpoint not yet
/// live, network error, 4xx) the previously hydrated flags are kept but the /// live, network error, 4xx) the previously hydrated flags are kept but the
/// state is marked `loaded` — a failed fetch never silently grants a /// state is marked `loaded` — a failed fetch never silently grants a
@@ -23,6 +25,7 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
emit( emit(
CapabilitiesState( CapabilitiesState(
viewForeignTimetables: response.viewForeignTimetables, viewForeignTimetables: response.viewForeignTimetables,
pushNotifications: response.pushNotifications,
loaded: true, loaded: true,
), ),
); );
@@ -7,6 +7,7 @@ part 'capabilities_state.g.dart';
abstract class CapabilitiesState with _$CapabilitiesState { abstract class CapabilitiesState with _$CapabilitiesState {
const factory CapabilitiesState({ const factory CapabilitiesState({
@Default(false) bool viewForeignTimetables, @Default(false) bool viewForeignTimetables,
@Default(false) bool pushNotifications,
// Whether a capability response (or a definitive failure) has been // Whether a capability response (or a definitive failure) has been
// observed at least once this session. Lets the UI distinguish "still // observed at least once this session. Lets the UI distinguish "still
// unknown" from "confirmed not allowed". // unknown" from "confirmed not allowed".
@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
/// @nodoc /// @nodoc
mixin _$CapabilitiesState { 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 // observed at least once this session. Lets the UI distinguish "still
// unknown" from "confirmed not allowed". // unknown" from "confirmed not allowed".
bool get loaded; bool get loaded;
@@ -31,16 +31,16 @@ $CapabilitiesStateCopyWith<CapabilitiesState> get copyWith => _$CapabilitiesStat
@override @override
bool operator ==(Object other) { 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) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,loaded); int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,loaded);
@override @override
String toString() { 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; factory $CapabilitiesStateCopyWith(CapabilitiesState value, $Res Function(CapabilitiesState) _then) = _$CapabilitiesStateCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
bool viewForeignTimetables, bool loaded bool viewForeignTimetables, bool pushNotifications, bool loaded
}); });
@@ -68,9 +68,10 @@ class _$CapabilitiesStateCopyWithImpl<$Res>
/// Create a copy of CapabilitiesState /// Create a copy of CapabilitiesState
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_self.copyWith(
viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable 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,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable
as bool, as bool,
)); ));
@@ -157,10 +158,10 @@ return $default(_that);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool loaded)? $default,{required TResult orElse(),}) {final _that = this; @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool pushNotifications, bool loaded)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) { switch (_that) {
case _CapabilitiesState() when $default != null: case _CapabilitiesState() when $default != null:
return $default(_that.viewForeignTimetables,_that.loaded);case _: return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded);case _:
return orElse(); return orElse();
} }
@@ -178,10 +179,10 @@ return $default(_that.viewForeignTimetables,_that.loaded);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool loaded) $default,) {final _that = this; @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool viewForeignTimetables, bool pushNotifications, bool loaded) $default,) {final _that = this;
switch (_that) { switch (_that) {
case _CapabilitiesState(): case _CapabilitiesState():
return $default(_that.viewForeignTimetables,_that.loaded);case _: return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded);case _:
throw StateError('Unexpected subclass'); throw StateError('Unexpected subclass');
} }
@@ -198,10 +199,10 @@ return $default(_that.viewForeignTimetables,_that.loaded);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool viewForeignTimetables, bool loaded)? $default,) {final _that = this; @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool viewForeignTimetables, bool pushNotifications, bool loaded)? $default,) {final _that = this;
switch (_that) { switch (_that) {
case _CapabilitiesState() when $default != null: case _CapabilitiesState() when $default != null:
return $default(_that.viewForeignTimetables,_that.loaded);case _: return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded);case _:
return null; return null;
} }
@@ -213,10 +214,11 @@ return $default(_that.viewForeignTimetables,_that.loaded);case _:
@JsonSerializable() @JsonSerializable()
class _CapabilitiesState implements CapabilitiesState { 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<String, dynamic> json) => _$CapabilitiesStateFromJson(json); factory _CapabilitiesState.fromJson(Map<String, dynamic> json) => _$CapabilitiesStateFromJson(json);
@override@JsonKey() final bool viewForeignTimetables; @override@JsonKey() final bool viewForeignTimetables;
@override@JsonKey() final bool pushNotifications;
// Whether a capability response (or a definitive failure) has been // Whether a capability response (or a definitive failure) has been
// observed at least once this session. Lets the UI distinguish "still // observed at least once this session. Lets the UI distinguish "still
// unknown" from "confirmed not allowed". // unknown" from "confirmed not allowed".
@@ -235,16 +237,16 @@ Map<String, dynamic> toJson() {
@override @override
bool operator ==(Object other) { 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) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,loaded); int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,loaded);
@override @override
String toString() { 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; factory _$CapabilitiesStateCopyWith(_CapabilitiesState value, $Res Function(_CapabilitiesState) _then) = __$CapabilitiesStateCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
bool viewForeignTimetables, bool loaded bool viewForeignTimetables, bool pushNotifications, bool loaded
}); });
@@ -272,9 +274,10 @@ class __$CapabilitiesStateCopyWithImpl<$Res>
/// Create a copy of CapabilitiesState /// Create a copy of CapabilitiesState
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_CapabilitiesState(
viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable 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,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable
as bool, as bool,
)); ));
@@ -9,11 +9,13 @@ part of 'capabilities_state.dart';
_CapabilitiesState _$CapabilitiesStateFromJson(Map<String, dynamic> json) => _CapabilitiesState _$CapabilitiesStateFromJson(Map<String, dynamic> json) =>
_CapabilitiesState( _CapabilitiesState(
viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false, viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false,
pushNotifications: json['pushNotifications'] as bool? ?? false,
loaded: json['loaded'] as bool? ?? false, loaded: json['loaded'] as bool? ?? false,
); );
Map<String, dynamic> _$CapabilitiesStateToJson(_CapabilitiesState instance) => Map<String, dynamic> _$CapabilitiesStateToJson(_CapabilitiesState instance) =>
<String, dynamic>{ <String, dynamic>{
'viewForeignTimetables': instance.viewForeignTimetables, 'viewForeignTimetables': instance.viewForeignTimetables,
'pushNotifications': instance.pushNotifications,
'loaded': instance.loaded, 'loaded': instance.loaded,
}; };
+5 -5
View File
@@ -4,13 +4,13 @@ part 'notification_settings.g.dart';
@JsonSerializable() @JsonSerializable()
class NotificationSettings { 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; bool enabled;
NotificationSettings({ NotificationSettings({this.enabled = true});
required this.askUsageDismissed,
required this.enabled,
});
factory NotificationSettings.fromJson(Map<String, dynamic> json) => factory NotificationSettings.fromJson(Map<String, dynamic> json) =>
_$NotificationSettingsFromJson(json); _$NotificationSettingsFromJson(json);
+2 -8
View File
@@ -8,14 +8,8 @@ part of 'notification_settings.dart';
NotificationSettings _$NotificationSettingsFromJson( NotificationSettings _$NotificationSettingsFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => NotificationSettings( ) => NotificationSettings(enabled: json['enabled'] as bool? ?? true);
askUsageDismissed: json['askUsageDismissed'] as bool,
enabled: json['enabled'] as bool,
);
Map<String, dynamic> _$NotificationSettingsToJson( Map<String, dynamic> _$NotificationSettingsToJson(
NotificationSettings instance, NotificationSettings instance,
) => <String, dynamic>{ ) => <String, dynamic>{'enabled': instance.enabled};
'askUsageDismissed': instance.askUsageDismissed,
'enabled': instance.enabled,
};
+6
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'package:flutter/foundation.dart'; 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/auth/token_storage.dart';
import '../../api/marianumconnect/queries/auth_login/auth_login.dart'; import '../../api/marianumconnect/queries/auth_login/auth_login.dart';
import '../../model/account_data.dart'; import '../../model/account_data.dart';
import '../../push/push_registration.dart';
import '../../widget_data/widget_sync.dart'; import '../../widget_data/widget_sync.dart';
/// Owns the login flow's transient state (loading, last error) so it can be /// 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(), tokenName: await DeviceTokenName.resolve(),
); );
await AccountData().setData(user, password); 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; _loading = false;
notifyListeners(); notifyListeners();
return true; return true;
@@ -67,10 +67,7 @@ class DefaultSettings {
showPastEvents: false, showPastEvents: false,
), ),
fileViewSettings: FileViewSettings(alwaysOpenExternally: Platform.isIOS), fileViewSettings: FileViewSettings(alwaysOpenExternally: Platform.isIOS),
notificationSettings: NotificationSettings( notificationSettings: NotificationSettings(enabled: true),
askUsageDismissed: false,
enabled: false,
),
devToolsSettings: DevToolsSettings( devToolsSettings: DevToolsSettings(
checkerboardOffscreenLayers: false, checkerboardOffscreenLayers: false,
checkerboardRasterCacheImages: false, checkerboardRasterCacheImages: false,
@@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../api/marianumcloud/cloud_users/cloud_users_actions.dart'; import '../../../../api/marianumcloud/cloud_users/cloud_users_actions.dart';
import '../../../../api/marianumconnect/queries/auth_logout/auth_logout.dart'; import '../../../../api/marianumconnect/queries/auth_logout/auth_logout.dart';
import '../../../../model/account_data.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_bloc.dart';
import '../../../../state/app/modules/account/bloc/account_state.dart'; import '../../../../state/app/modules/account/bloc/account_state.dart';
import '../../../../widget/app_progress_indicator.dart'; import '../../../../widget/app_progress_indicator.dart';
@@ -192,10 +193,12 @@ class _AccountSectionState extends State<AccountSection> {
context.read<AccountBloc>().setStatus(AccountStatus.loggedOut); context.read<AccountBloc>().setStatus(AccountStatus.loggedOut);
} }
// Best-effort revoke of the MC bearer token before we wipe local credentials. // Ordered teardown: unregister push at Nextcloud + proxy and revoke the app
// The token storage itself is cleared inside AuthLogout regardless of network // password (while Nextcloud credentials are still available), THEN revoke the
// success, so an offline logout still gets us into a clean local state. // MC bearer token, and finally wipe local credentials. Each step is
// best-effort so an offline logout still reaches a clean local state.
Future<void> _performLogout() async { Future<void> _performLogout() async {
await PushRegistration().logoutCleanup();
await AuthLogout().run(); await AuthLogout().run();
await AccountData().removeData(); await AccountData().removeData();
_cachedDisplayName = null; _cachedDisplayName = null;
@@ -1,12 +1,16 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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 '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../utils/haptics.dart'; import '../../../../utils/haptics.dart';
import '../../../../widget/centered_leading.dart'; import '../../../../widget/centered_leading.dart';
import '../../../../widget/info_dialog.dart';
class TalkSection extends StatelessWidget { class TalkSection extends StatelessWidget {
const TalkSection({super.key}); const TalkSection({super.key});
@@ -51,32 +55,134 @@ class TalkSection extends StatelessWidget {
leading: const CenteredLeading( leading: const CenteredLeading(
Icon(Icons.notifications_active_outlined), Icon(Icons.notifications_active_outlined),
), ),
title: const Text('Push-Benachrichtigungen aktivieren'), title: const Text('Push-Benachrichtigungen'),
subtitle: const Text('Lange tippen für mehr Informationen'), subtitle: const Text('Neue Talk-Nachrichten direkt aufs Gerät'),
trailing: Checkbox( trailing: Checkbox(
value: notificationSettings.enabled, value: notificationSettings.enabled,
onChanged: (e) { onChanged: (e) {
Haptics.selection(); Haptics.selection();
if (e!) { final enabled = e ?? false;
NotifyUpdater.enableAfterDisclaimer(settings).asDialog(context); 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 { } 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, /// "Send a test notification" action, shown only while push is enabled. The
"Aufgrund technischer Limitationen müssen Push-Nachrichten über einen externen Server - hier 'mhsl.eu' (Author dieser App) - erfolgen.\n\n" /// button stays disabled until a registration is confirmed present, then calls
'Wenn Push aktiviert wird, werden deine Zugangsdaten und ein Token verschlüsselt an den Betreiber gesendet und von ihm unverschlüsselt gespeichert.\n\n' /// the backend and reports the result via a SnackBar.
'Der extene Server verwendet die Zugangsdaten um sich maschinell in Talk anzumelden und via Websockets auf neue Nachrichten zu warten.\n\n' class _TestNotificationTile extends StatefulWidget {
'Wenn eine neue Nachricht eintrifft wird dein Telefon via FBC-Messaging (Google Firebase Push) vom externen Server benachrichtigt.\n\n' const _TestNotificationTile();
'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', @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<void> _loadState() async {
final registered = await const PushRegistrationStore().isRegistered();
final denied = await PushRegistration.isOsPermissionDenied();
if (!mounted) return;
setState(() {
_registered = registered;
_permissionDenied = denied;
});
}
Future<void> _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,
);
}
} }
@@ -1,7 +1,14 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../api/marianumconnect/auth/token_storage.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 '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../storage/dev_tools_settings.dart'; import '../../../../storage/dev_tools_settings.dart';
import '../../../../storage/settings.dart' as model; import '../../../../storage/settings.dart' as model;
@@ -35,6 +42,20 @@ class MarianumConnectEndpointPicker {
mutable.marianumConnectEndpoint = next; mutable.marianumConnectEndpoint = next;
if (custom != null) mutable.marianumConnectCustomUrl = custom; if (custom != null) mutable.marianumConnectCustomUrl = custom;
await const MarianumConnectTokenStorage().clear(); 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());
}, },
); );
}, },
-41
View File
@@ -1,9 +1,7 @@
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_split_view/flutter_split_view.dart'; import 'package:flutter_split_view/flutter_split_view.dart';
import '../../../notification/notify_updater.dart';
import '../../../routing/app_routes.dart'; import '../../../routing/app_routes.dart';
import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; import '../../../state/app/infrastructure/loadable_state/loadable_state.dart';
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.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/chat_list/bloc/chat_list_state.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../widget/confirm_dialog.dart'; import '../../../widget/confirm_dialog.dart';
import '../../../widget/info_dialog.dart';
import '../../../widget/placeholder_view.dart'; import '../../../widget/placeholder_view.dart';
import 'join_chat.dart'; import 'join_chat.dart';
import 'search_chat.dart'; import 'search_chat.dart';
@@ -46,7 +43,6 @@ class _ChatListViewState extends State<_ChatListView> {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
_maybeAskForNotificationPermission();
_maybeOpenPendingChat(); _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bloc = context.read<ChatListBloc>(); final bloc = context.read<ChatListBloc>();
+7 -1
View File
@@ -3,7 +3,7 @@ description: Mobile client for Webuntis and Nextcloud with Talk integration
publish_to: 'none' publish_to: 'none'
version: 1.2.2+57 version: 1.3.0+58
environment: environment:
sdk: ">=3.8.0 <4.0.0" sdk: ">=3.8.0 <4.0.0"
@@ -24,6 +24,12 @@ dependencies:
collection: ^1.19.0 collection: ^1.19.0
connectivity_plus: ^7.1.0 connectivity_plus: ^7.1.0
crypto: ^3.0.6 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 device_info_plus: ^12.4.0
dio: ^5.9.2 dio: ^5.9.2
emoji_picker_flutter: ^4.3.0 emoji_picker_flutter: ^4.3.0
+29
View File
@@ -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');
});
});
}
+92
View File
@@ -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<pc.RSAPublicKey>(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]);
});
});
}
+44
View File
@@ -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,
);
});
});
}
+39
View File
@@ -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);
});
});
}
+32
View File
@@ -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);
});
});
}
+34
View File
@@ -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));
});
});
}