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:
@@ -104,6 +104,10 @@
|
||||
</intent>
|
||||
</queries>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- Android 13+ runtime notification permission. Requested at login via
|
||||
FirebaseMessaging.requestPermission(); without this declaration the
|
||||
locally rendered push notifications are silently dropped. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<!-- Workmanager periodic widget refresh needs to reschedule after device
|
||||
reboot, otherwise the widget freezes until the user opens the app. -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -1,16 +1,203 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
|
||||
// Kept in sync with lib/push/push_actions.dart (kTalkReplyActionId /
|
||||
// kTalkMarkReadActionId) and lib/push/push_renderer.dart (iosTalkCategory).
|
||||
private let talkCategoryId = "TALK_MESSAGE"
|
||||
private let replyActionId = "TALK_REPLY"
|
||||
private let markReadActionId = "TALK_MARK_READ"
|
||||
|
||||
// Shared (App Group) keychain — same group as the NSE and the Dart side.
|
||||
private let keychainAccessGroup = "group.eu.mhsl.marianum.mobile.client.widget"
|
||||
private let usernameAccount = "nextcloud_username"
|
||||
private let appPasswordAccount = "nextcloud_app_password"
|
||||
private let baseUrlAccount = "nextcloud_base_url"
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
let result = super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
registerTalkCategory()
|
||||
// FlutterAppDelegate conforms to UNUserNotificationCenterDelegate and
|
||||
// forwards these callbacks to the plugins (firebase_messaging,
|
||||
// flutter_local_notifications). We route Talk actions natively here — the
|
||||
// Flutter engine is not guaranteed to run for a background action — and
|
||||
// forward everything else to `super` so plugin behaviour is preserved.
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
return result
|
||||
}
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
}
|
||||
|
||||
private func registerTalkCategory() {
|
||||
let reply = UNTextInputNotificationAction(
|
||||
identifier: replyActionId,
|
||||
title: "Antworten",
|
||||
options: [],
|
||||
textInputButtonTitle: "Senden",
|
||||
textInputPlaceholder: "Nachricht"
|
||||
)
|
||||
let markRead = UNNotificationAction(
|
||||
identifier: markReadActionId,
|
||||
title: "Als gelesen markieren",
|
||||
options: []
|
||||
)
|
||||
let category = UNNotificationCategory(
|
||||
identifier: talkCategoryId,
|
||||
actions: [reply, markRead],
|
||||
intentIdentifiers: [],
|
||||
options: []
|
||||
)
|
||||
UNUserNotificationCenter.current().setNotificationCategories([category])
|
||||
}
|
||||
|
||||
// MARK: - UNUserNotificationCenterDelegate
|
||||
|
||||
override func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void
|
||||
) {
|
||||
let actionId = response.actionIdentifier
|
||||
if actionId == replyActionId || actionId == markReadActionId {
|
||||
// Handled natively; deliberately NOT forwarded to super so the plugins
|
||||
// don't also process it (which would double-send the reply).
|
||||
handleTalkAction(response, completion: completionHandler)
|
||||
return
|
||||
}
|
||||
super.userNotificationCenter(
|
||||
center, didReceive: response, withCompletionHandler: completionHandler)
|
||||
}
|
||||
|
||||
// MARK: - Native Talk action handling
|
||||
|
||||
private func handleTalkAction(
|
||||
_ response: UNNotificationResponse,
|
||||
completion: @escaping () -> Void
|
||||
) {
|
||||
guard
|
||||
let token = chatToken(from: response),
|
||||
let credentials = loadCredentials()
|
||||
else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
|
||||
let group = DispatchGroup()
|
||||
|
||||
if response.actionIdentifier == replyActionId,
|
||||
let text = (response as? UNTextInputNotificationResponse)?.userText
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!text.isEmpty {
|
||||
group.enter()
|
||||
ocsPost(
|
||||
credentials: credentials,
|
||||
path: "apps/spreed/api/v1/chat/\(token)",
|
||||
form: ["message": text]
|
||||
) { group.leave() }
|
||||
}
|
||||
|
||||
// Both reply and mark-read clear the unread marker on the chat.
|
||||
group.enter()
|
||||
ocsPost(
|
||||
credentials: credentials,
|
||||
path: "apps/spreed/api/v1/chat/\(token)/read",
|
||||
form: nil
|
||||
) { group.leave() }
|
||||
|
||||
group.notify(queue: .main) { completion() }
|
||||
}
|
||||
|
||||
private func chatToken(from response: UNNotificationResponse) -> String? {
|
||||
let userInfo = response.notification.request.content.userInfo
|
||||
if let token = userInfo["chatToken"] as? String, !token.isEmpty {
|
||||
return token
|
||||
}
|
||||
let thread = response.notification.request.content.threadIdentifier
|
||||
if thread.hasPrefix("talk_") {
|
||||
let token = String(thread.dropFirst("talk_".count))
|
||||
return token.isEmpty ? nil : token
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private struct Credentials {
|
||||
let baseUrl: String
|
||||
let authorization: String
|
||||
}
|
||||
|
||||
private func loadCredentials() -> Credentials? {
|
||||
guard
|
||||
let username = keychainString(usernameAccount),
|
||||
let appPassword = keychainString(appPasswordAccount),
|
||||
let baseUrl = keychainString(baseUrlAccount),
|
||||
let token = "\(username):\(appPassword)".data(using: .utf8)
|
||||
else { return nil }
|
||||
let authorization = "Basic \(token.base64EncodedString())"
|
||||
let trimmed = baseUrl.hasSuffix("/") ? String(baseUrl.dropLast()) : baseUrl
|
||||
return Credentials(baseUrl: trimmed, authorization: authorization)
|
||||
}
|
||||
|
||||
private func ocsPost(
|
||||
credentials: Credentials,
|
||||
path: String,
|
||||
form: [String: String]?,
|
||||
completion: @escaping () -> Void
|
||||
) {
|
||||
guard let url = URL(string: "\(credentials.baseUrl)/ocs/v2.php/\(path)") else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue(credentials.authorization, forHTTPHeaderField: "Authorization")
|
||||
request.setValue("true", forHTTPHeaderField: "OCS-APIRequest")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
if let form = form {
|
||||
request.setValue(
|
||||
"application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = form
|
||||
.map { "\($0.key)=\(formEncode($0.value))" }
|
||||
.joined(separator: "&")
|
||||
.data(using: .utf8)
|
||||
}
|
||||
URLSession.shared.dataTask(with: request) { _, response, error in
|
||||
if let error = error {
|
||||
NSLog("[Talk action] \(path) failed: \(error.localizedDescription)")
|
||||
} else if let http = response as? HTTPURLResponse,
|
||||
!(200..<300).contains(http.statusCode) {
|
||||
NSLog("[Talk action] \(path) -> HTTP \(http.statusCode)")
|
||||
}
|
||||
completion()
|
||||
}.resume()
|
||||
}
|
||||
|
||||
private func formEncode(_ value: String) -> String {
|
||||
var allowed = CharacterSet.alphanumerics
|
||||
allowed.insert(charactersIn: "-._~")
|
||||
return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
|
||||
}
|
||||
|
||||
// MARK: - Keychain (shared App Group)
|
||||
|
||||
private func keychainString(_ account: String) -> String? {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrAccount: account,
|
||||
kSecAttrAccessGroup: keychainAccessGroup,
|
||||
kSecReturnData: true,
|
||||
kSecMatchLimit: kSecMatchLimitOne,
|
||||
]
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess, let data = result as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,5 +9,9 @@
|
||||
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
|
||||
<string>group.eu.mhsl.marianum.mobile.client.share</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
|
||||
</array>
|
||||
</dict>
|
||||
</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)
|
||||
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) =>
|
||||
_$CapabilitiesResponseFromJson(json);
|
||||
|
||||
@@ -10,8 +10,12 @@ CapabilitiesResponse _$CapabilitiesResponseFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => CapabilitiesResponse(
|
||||
viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false,
|
||||
pushNotifications: json['pushNotifications'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$CapabilitiesResponseToJson(
|
||||
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,
|
||||
};
|
||||
+27
-8
@@ -12,7 +12,8 @@ import 'main.dart';
|
||||
import 'model/data_cleaner.dart';
|
||||
import 'notification/notification_controller.dart';
|
||||
import 'notification/notification_tasks.dart';
|
||||
import 'notification/notify_updater.dart';
|
||||
import 'push/push_registration.dart';
|
||||
import 'push/push_tap_router.dart';
|
||||
import 'routing/app_routes.dart';
|
||||
import 'share_intent/share_intent_listener.dart';
|
||||
import 'state/app/modules/app_modules.dart';
|
||||
@@ -85,6 +86,13 @@ class _AppState extends State<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 {
|
||||
final pending = await WidgetNavigation.consumePendingTimetableTap();
|
||||
if (!pending || !mounted) return;
|
||||
@@ -165,22 +173,32 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
|
||||
UpdateUserIndex.index();
|
||||
|
||||
// A refreshed FCM token invalidates the existing push subscription — the
|
||||
// NC device identifier stays stable, so we simply re-register (NC first,
|
||||
// then the proxy). Debounced so a burst of refreshes triggers one call.
|
||||
if (context.read<SettingsCubit>().val().notificationSettings.enabled) {
|
||||
void update() => NotifyUpdater.registerToServer();
|
||||
_fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen(
|
||||
(_) => update(),
|
||||
_fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen((
|
||||
_,
|
||||
) {
|
||||
Debouncer.debounce(
|
||||
'pushTokenRefresh',
|
||||
const Duration(seconds: 3),
|
||||
() => unawaited(PushRegistration().onTokenRefresh()),
|
||||
);
|
||||
update();
|
||||
});
|
||||
}
|
||||
|
||||
// Android renders pushes locally, so a tap arrives via the local
|
||||
// notifications callback (PushTapRouter) rather than onMessageOpenedApp.
|
||||
PushTapRouter.pendingChatToken.addListener(_onPushTapPending);
|
||||
|
||||
_onMessageSub = FirebaseMessaging.onMessage.listen((message) {
|
||||
if (!mounted) return;
|
||||
NotificationController.onForegroundMessageHandler(message, context);
|
||||
});
|
||||
FirebaseMessaging.onBackgroundMessage(
|
||||
NotificationController.onBackgroundMessageHandler,
|
||||
);
|
||||
|
||||
// iOS delivers alert pushes (Connect direct pushes, and NC pushes rendered
|
||||
// by the NSE) natively; a tap surfaces here.
|
||||
_onMessageOpenedAppSub = FirebaseMessaging.onMessageOpenedApp.listen((
|
||||
message,
|
||||
) {
|
||||
@@ -202,6 +220,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
||||
_onMessageSub?.cancel();
|
||||
_onMessageOpenedAppSub?.cancel();
|
||||
_fcmTokenRefreshSub?.cancel();
|
||||
PushTapRouter.pendingChatToken.removeListener(_onPushTapPending);
|
||||
ShareIntentListener.pending.removeListener(_handlePendingShare);
|
||||
ShareIntentListener.instance.detach();
|
||||
Main.bottomNavigator.removeListener(_onTabControllerChanged);
|
||||
|
||||
+30
-2
@@ -25,6 +25,10 @@ import 'app.dart';
|
||||
import 'background/widget_background_task.dart';
|
||||
import 'firebase_options.dart';
|
||||
import 'model/account_data.dart';
|
||||
import 'notification/notification_service.dart';
|
||||
import 'push/push_message_handler.dart';
|
||||
import 'push/push_registration.dart';
|
||||
import 'push/push_renderer.dart';
|
||||
import 'routing/app_routes.dart';
|
||||
import 'share_intent/share_intent_listener.dart';
|
||||
import 'state/app/modules/account/bloc/account_bloc.dart';
|
||||
@@ -87,6 +91,13 @@ Future<void> main() async {
|
||||
await Future.wait(initialisationTasks);
|
||||
log('app initialisation done!');
|
||||
|
||||
// Local notifications: init the plugin (with tap/action callbacks) and the
|
||||
// Android channels, then register the FCM background isolate handler that
|
||||
// decrypts and renders Nextcloud pushes while the app is not in foreground.
|
||||
await NotificationService().initializeNotifications();
|
||||
await PushRenderer.ensureChannels();
|
||||
FirebaseMessaging.onBackgroundMessage(PushMessageHandler.onBackgroundMessage);
|
||||
|
||||
// Wire up the home-screen widget bridge before runApp so any widget render
|
||||
// triggered during startup hits initialised native storage.
|
||||
await WidgetSync.ensureInitialized();
|
||||
@@ -207,8 +218,14 @@ class _MainState extends State<Main> {
|
||||
_scheduleSessionValidation(accountBloc);
|
||||
// Cold start while already logged in: the account status doesn't
|
||||
// change, so the loggedIn listener below never fires — refresh
|
||||
// capabilities here.
|
||||
unawaited(context.read<CapabilitiesCubit>().load());
|
||||
// capabilities here, then self-heal the push registration.
|
||||
final settingsCubit = context.read<SettingsCubit>();
|
||||
unawaited(
|
||||
context.read<CapabilitiesCubit>().load().then((_) {
|
||||
if (!mounted) return;
|
||||
_syncPush(settingsCubit, context.read<CapabilitiesCubit>());
|
||||
}),
|
||||
);
|
||||
unawaited(context.read<NextcloudCapabilitiesCubit>().load());
|
||||
}
|
||||
});
|
||||
@@ -225,6 +242,17 @@ class _MainState extends State<Main> {
|
||||
unawaited(ListFilesCache.prefetchRootListing());
|
||||
}
|
||||
|
||||
/// Registers/self-heals the push subscription when push is user-enabled and
|
||||
/// the backend advertises the capability. Fire-and-forget.
|
||||
void _syncPush(SettingsCubit settings, CapabilitiesCubit capabilities) {
|
||||
unawaited(
|
||||
PushRegistration.syncSubscription(
|
||||
enabled: settings.val().notificationSettings.enabled,
|
||||
capable: capabilities.canReceivePushNotifications,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _scheduleSessionValidation(AccountBloc accountBloc) {
|
||||
unawaited(
|
||||
SessionValidator.probeStored(
|
||||
|
||||
@@ -6,9 +6,14 @@ import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../push/push_secure_storage.dart';
|
||||
|
||||
class AccountData {
|
||||
static const _usernameField = 'username';
|
||||
static const _passwordField = 'password';
|
||||
// App password lives in the push-shared (group-scoped) keystore so the iOS
|
||||
// Notification Service Extension can authenticate Nextcloud calls too.
|
||||
static const _appPasswordField = 'nextcloud_app_password';
|
||||
|
||||
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage();
|
||||
|
||||
@@ -23,6 +28,7 @@ class AccountData {
|
||||
|
||||
String? _username;
|
||||
String? _password;
|
||||
String? _appPassword;
|
||||
|
||||
String getUsername() {
|
||||
if (_username == null) throw Exception('Username not initialized');
|
||||
@@ -58,14 +64,49 @@ class AccountData {
|
||||
_populated = Completer();
|
||||
_username = null;
|
||||
_password = null;
|
||||
_appPassword = null;
|
||||
await _secureStorage.delete(key: _usernameField);
|
||||
await _secureStorage.delete(key: _passwordField);
|
||||
await _clearAppPasswordStorage();
|
||||
}
|
||||
|
||||
/// Persists a freshly minted Nextcloud app password. After this every
|
||||
/// [getBasicAuthHeader] call authenticates with the app password instead of
|
||||
/// the real password.
|
||||
Future<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 {
|
||||
await _migrateFromLegacyStorage();
|
||||
_username = await _secureStorage.read(key: _usernameField);
|
||||
_password = await _secureStorage.read(key: _passwordField);
|
||||
try {
|
||||
_appPassword = await pushSecureStorage.read(key: _appPasswordField);
|
||||
} on Object {
|
||||
_appPassword = null;
|
||||
}
|
||||
if (!_populated.isCompleted) _populated.complete();
|
||||
}
|
||||
|
||||
@@ -97,6 +138,21 @@ class AccountData {
|
||||
/// Prefer this over embedding credentials in URLs — error logs and crash
|
||||
/// reports often capture the URL but not headers.
|
||||
String getBasicAuthHeader() {
|
||||
if (!isPopulated()) {
|
||||
throw Exception(
|
||||
'AccountData (e.g. username or password) is not initialized!',
|
||||
);
|
||||
}
|
||||
// Prefer the scoped app password once available; it survives real-password
|
||||
// rotation and is what the push-v2 registration is bound to.
|
||||
final secret = _appPassword ?? _password;
|
||||
return 'Basic ${base64Encode(utf8.encode('$_username:$secret'))}';
|
||||
}
|
||||
|
||||
/// Basic-auth header that always uses the real password. Needed exactly once,
|
||||
/// to mint the app password via `core/getapppassword` (an app password cannot
|
||||
/// mint another).
|
||||
String getRealPasswordBasicAuthHeader() {
|
||||
if (!isPopulated()) {
|
||||
throw Exception(
|
||||
'AccountData (e.g. username or password) is not initialized!',
|
||||
|
||||
@@ -4,44 +4,36 @@ import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../push/push_message_handler.dart';
|
||||
import '../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||
import '../widget/debug/debug_tile.dart';
|
||||
import '../widget/debug/json_viewer.dart';
|
||||
import '../widget/info_dialog.dart';
|
||||
import 'notification_tasks.dart';
|
||||
|
||||
// `vm:entry-point` keeps this alive through AOT tree-shaking — the FCM
|
||||
// background isolate looks the class up by name from native code.
|
||||
@pragma('vm:entry-point')
|
||||
/// Bridges FCM lifecycle callbacks to the push pipeline. Background messages are
|
||||
/// handled directly by [PushMessageHandler.onBackgroundMessage]; this class
|
||||
/// covers the foreground and app-opened paths where a [BuildContext] is
|
||||
/// available.
|
||||
class NotificationController {
|
||||
@pragma('vm:entry-point')
|
||||
static Future<void> onBackgroundMessageHandler(RemoteMessage message) async {
|
||||
NotificationTasks.updateBadgeCount(message);
|
||||
}
|
||||
|
||||
static Future<void> onForegroundMessageHandler(
|
||||
RemoteMessage message,
|
||||
BuildContext context,
|
||||
) async {
|
||||
final pushToken = _extractChatToken(message);
|
||||
final chatBloc = context.read<ChatBloc>();
|
||||
// hasOpenChat, not currentToken: currentToken sticks around after
|
||||
// leaveChat so didPopNext can re-claim a stacked chat.
|
||||
final activeToken = chatBloc.state.data?.currentToken ?? '';
|
||||
final chatIsOpen =
|
||||
chatBloc.hasOpenChat &&
|
||||
pushToken != null &&
|
||||
pushToken.isNotEmpty &&
|
||||
pushToken == activeToken;
|
||||
|
||||
NotificationTasks.updateBadgeCount(message);
|
||||
|
||||
if (chatIsOpen) {
|
||||
// Long-poll handles the message; just dismiss any stray tray entry.
|
||||
unawaited(NotificationTasks.clearNotificationsForChat(pushToken));
|
||||
return;
|
||||
}
|
||||
final openChatToken = chatBloc.hasOpenChat
|
||||
? (chatBloc.state.data?.currentToken ?? '')
|
||||
: null;
|
||||
|
||||
await PushMessageHandler().handle(
|
||||
message,
|
||||
foreground: true,
|
||||
openChatToken: openChatToken,
|
||||
);
|
||||
await NotificationTasks.refreshBadge();
|
||||
if (!context.mounted) return;
|
||||
NotificationTasks.updateProviders(context);
|
||||
}
|
||||
|
||||
@@ -54,6 +46,7 @@ class NotificationController {
|
||||
chatToken: _extractChatToken(message),
|
||||
);
|
||||
NotificationTasks.updateProviders(context);
|
||||
unawaited(NotificationTasks.refreshBadge());
|
||||
|
||||
DebugTile(context).run(() {
|
||||
InfoDialog.show(
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
import '../push/push_actions.dart';
|
||||
import '../push/push_renderer.dart';
|
||||
import '../push/push_tap_router.dart';
|
||||
|
||||
class NotificationService {
|
||||
static final NotificationService _instance = NotificationService._internal();
|
||||
|
||||
@@ -15,7 +19,27 @@ class NotificationService {
|
||||
'@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
final iosSettings = DarwinInitializationSettings();
|
||||
// iOS Talk category mirrors the Android inline reply + mark-as-read actions
|
||||
// so both platforms expose the same quick actions. The actual delivery of
|
||||
// these while the app is terminated is handled by the (Phase 3) NSE.
|
||||
final iosSettings = DarwinInitializationSettings(
|
||||
notificationCategories: [
|
||||
DarwinNotificationCategory(
|
||||
PushRenderer.iosTalkCategory,
|
||||
actions: [
|
||||
DarwinNotificationAction.text(
|
||||
kTalkReplyActionId,
|
||||
'Antworten',
|
||||
buttonTitle: 'Senden',
|
||||
options: const {
|
||||
DarwinNotificationActionOption.authenticationRequired,
|
||||
},
|
||||
),
|
||||
DarwinNotificationAction.plain(kTalkMarkReadActionId, 'Gelesen'),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final initializationSettings = InitializationSettings(
|
||||
android: androidSettings,
|
||||
@@ -24,34 +48,9 @@ class NotificationService {
|
||||
|
||||
await flutterLocalNotificationsPlugin.initialize(
|
||||
settings: initializationSettings,
|
||||
);
|
||||
}
|
||||
|
||||
Future<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,
|
||||
onDidReceiveNotificationResponse: PushTapRouter.handleResponse,
|
||||
onDidReceiveBackgroundNotificationResponse:
|
||||
PushActions.handleBackgroundResponse,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:eraser/eraser.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_app_badge/flutter_app_badge.dart';
|
||||
@@ -12,10 +11,18 @@ import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||
import 'notification_service.dart';
|
||||
|
||||
class NotificationTasks {
|
||||
static void updateBadgeCount(RemoteMessage notification) {
|
||||
FlutterAppBadge.count(
|
||||
int.parse((notification.data['unreadCount'] as String?) ?? '0'),
|
||||
);
|
||||
/// Recomputes the app badge from the notifications currently in the tray.
|
||||
/// Deterministic — no server-provided counter to drift out of sync — so the
|
||||
/// badge always matches what the user actually sees. Called after rendering,
|
||||
/// cancelling, or opening the app.
|
||||
static Future<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
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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 [],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 canReceivePushNotifications => state.pushNotifications;
|
||||
|
||||
/// Refreshes capabilities from the server. On any failure (endpoint not yet
|
||||
/// live, network error, 4xx) the previously hydrated flags are kept but the
|
||||
/// state is marked `loaded` — a failed fetch never silently grants a
|
||||
@@ -23,6 +25,7 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
|
||||
emit(
|
||||
CapabilitiesState(
|
||||
viewForeignTimetables: response.viewForeignTimetables,
|
||||
pushNotifications: response.pushNotifications,
|
||||
loaded: true,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ part 'capabilities_state.g.dart';
|
||||
abstract class CapabilitiesState with _$CapabilitiesState {
|
||||
const factory CapabilitiesState({
|
||||
@Default(false) bool viewForeignTimetables,
|
||||
@Default(false) bool pushNotifications,
|
||||
// Whether a capability response (or a definitive failure) has been
|
||||
// observed at least once this session. Lets the UI distinguish "still
|
||||
// unknown" from "confirmed not allowed".
|
||||
|
||||
@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$CapabilitiesState {
|
||||
|
||||
bool get viewForeignTimetables;// Whether a capability response (or a definitive failure) has been
|
||||
bool get viewForeignTimetables; bool get pushNotifications;// Whether a capability response (or a definitive failure) has been
|
||||
// observed at least once this session. Lets the UI distinguish "still
|
||||
// unknown" from "confirmed not allowed".
|
||||
bool get loaded;
|
||||
@@ -31,16 +31,16 @@ $CapabilitiesStateCopyWith<CapabilitiesState> get copyWith => _$CapabilitiesStat
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.loaded, loaded) || other.loaded == loaded));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.loaded, loaded) || other.loaded == loaded));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,loaded);
|
||||
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,loaded);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, loaded: $loaded)';
|
||||
return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, loaded: $loaded)';
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ abstract mixin class $CapabilitiesStateCopyWith<$Res> {
|
||||
factory $CapabilitiesStateCopyWith(CapabilitiesState value, $Res Function(CapabilitiesState) _then) = _$CapabilitiesStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
bool viewForeignTimetables, bool loaded
|
||||
bool viewForeignTimetables, bool pushNotifications, bool loaded
|
||||
});
|
||||
|
||||
|
||||
@@ -68,9 +68,10 @@ class _$CapabilitiesStateCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of CapabilitiesState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? viewForeignTimetables = null,Object? loaded = null,}) {
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? loaded = null,}) {
|
||||
return _then(_self.copyWith(
|
||||
viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable
|
||||
as bool,pushNotifications: null == pushNotifications ? _self.pushNotifications : pushNotifications // ignore: cast_nullable_to_non_nullable
|
||||
as bool,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
@@ -157,10 +158,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult 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) {
|
||||
case _CapabilitiesState() when $default != null:
|
||||
return $default(_that.viewForeignTimetables,_that.loaded);case _:
|
||||
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
@@ -178,10 +179,10 @@ return $default(_that.viewForeignTimetables,_that.loaded);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult 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) {
|
||||
case _CapabilitiesState():
|
||||
return $default(_that.viewForeignTimetables,_that.loaded);case _:
|
||||
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded);case _:
|
||||
throw StateError('Unexpected subclass');
|
||||
|
||||
}
|
||||
@@ -198,10 +199,10 @@ return $default(_that.viewForeignTimetables,_that.loaded);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult 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) {
|
||||
case _CapabilitiesState() when $default != null:
|
||||
return $default(_that.viewForeignTimetables,_that.loaded);case _:
|
||||
return $default(_that.viewForeignTimetables,_that.pushNotifications,_that.loaded);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
@@ -213,10 +214,11 @@ return $default(_that.viewForeignTimetables,_that.loaded);case _:
|
||||
@JsonSerializable()
|
||||
|
||||
class _CapabilitiesState implements CapabilitiesState {
|
||||
const _CapabilitiesState({this.viewForeignTimetables = false, this.loaded = false});
|
||||
const _CapabilitiesState({this.viewForeignTimetables = false, this.pushNotifications = false, this.loaded = false});
|
||||
factory _CapabilitiesState.fromJson(Map<String, dynamic> json) => _$CapabilitiesStateFromJson(json);
|
||||
|
||||
@override@JsonKey() final bool viewForeignTimetables;
|
||||
@override@JsonKey() final bool pushNotifications;
|
||||
// Whether a capability response (or a definitive failure) has been
|
||||
// observed at least once this session. Lets the UI distinguish "still
|
||||
// unknown" from "confirmed not allowed".
|
||||
@@ -235,16 +237,16 @@ Map<String, dynamic> toJson() {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.loaded, loaded) || other.loaded == loaded));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _CapabilitiesState&&(identical(other.viewForeignTimetables, viewForeignTimetables) || other.viewForeignTimetables == viewForeignTimetables)&&(identical(other.pushNotifications, pushNotifications) || other.pushNotifications == pushNotifications)&&(identical(other.loaded, loaded) || other.loaded == loaded));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,loaded);
|
||||
int get hashCode => Object.hash(runtimeType,viewForeignTimetables,pushNotifications,loaded);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, loaded: $loaded)';
|
||||
return 'CapabilitiesState(viewForeignTimetables: $viewForeignTimetables, pushNotifications: $pushNotifications, loaded: $loaded)';
|
||||
}
|
||||
|
||||
|
||||
@@ -255,7 +257,7 @@ abstract mixin class _$CapabilitiesStateCopyWith<$Res> implements $CapabilitiesS
|
||||
factory _$CapabilitiesStateCopyWith(_CapabilitiesState value, $Res Function(_CapabilitiesState) _then) = __$CapabilitiesStateCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
bool viewForeignTimetables, bool loaded
|
||||
bool viewForeignTimetables, bool pushNotifications, bool loaded
|
||||
});
|
||||
|
||||
|
||||
@@ -272,9 +274,10 @@ class __$CapabilitiesStateCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of CapabilitiesState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? viewForeignTimetables = null,Object? loaded = null,}) {
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? viewForeignTimetables = null,Object? pushNotifications = null,Object? loaded = null,}) {
|
||||
return _then(_CapabilitiesState(
|
||||
viewForeignTimetables: null == viewForeignTimetables ? _self.viewForeignTimetables : viewForeignTimetables // ignore: cast_nullable_to_non_nullable
|
||||
as bool,pushNotifications: null == pushNotifications ? _self.pushNotifications : pushNotifications // ignore: cast_nullable_to_non_nullable
|
||||
as bool,loaded: null == loaded ? _self.loaded : loaded // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
|
||||
@@ -9,11 +9,13 @@ part of 'capabilities_state.dart';
|
||||
_CapabilitiesState _$CapabilitiesStateFromJson(Map<String, dynamic> json) =>
|
||||
_CapabilitiesState(
|
||||
viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false,
|
||||
pushNotifications: json['pushNotifications'] as bool? ?? false,
|
||||
loaded: json['loaded'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$CapabilitiesStateToJson(_CapabilitiesState instance) =>
|
||||
<String, dynamic>{
|
||||
'viewForeignTimetables': instance.viewForeignTimetables,
|
||||
'pushNotifications': instance.pushNotifications,
|
||||
'loaded': instance.loaded,
|
||||
};
|
||||
|
||||
@@ -4,13 +4,13 @@ part 'notification_settings.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class NotificationSettings {
|
||||
bool askUsageDismissed;
|
||||
/// Whether push notifications are enabled. Defaults to `true` — the OS
|
||||
/// permission prompt at login is now the gate, so there is no separate
|
||||
/// in-app opt-in step anymore.
|
||||
@JsonKey(defaultValue: true)
|
||||
bool enabled;
|
||||
|
||||
NotificationSettings({
|
||||
required this.askUsageDismissed,
|
||||
required this.enabled,
|
||||
});
|
||||
NotificationSettings({this.enabled = true});
|
||||
|
||||
factory NotificationSettings.fromJson(Map<String, dynamic> json) =>
|
||||
_$NotificationSettingsFromJson(json);
|
||||
|
||||
@@ -8,14 +8,8 @@ part of 'notification_settings.dart';
|
||||
|
||||
NotificationSettings _$NotificationSettingsFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => NotificationSettings(
|
||||
askUsageDismissed: json['askUsageDismissed'] as bool,
|
||||
enabled: json['enabled'] as bool,
|
||||
);
|
||||
) => NotificationSettings(enabled: json['enabled'] as bool? ?? true);
|
||||
|
||||
Map<String, dynamic> _$NotificationSettingsToJson(
|
||||
NotificationSettings instance,
|
||||
) => <String, dynamic>{
|
||||
'askUsageDismissed': instance.askUsageDismissed,
|
||||
'enabled': instance.enabled,
|
||||
};
|
||||
) => <String, dynamic>{'enabled': instance.enabled};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -8,6 +9,7 @@ import '../../api/marianumconnect/auth/device_token_name.dart';
|
||||
import '../../api/marianumconnect/auth/token_storage.dart';
|
||||
import '../../api/marianumconnect/queries/auth_login/auth_login.dart';
|
||||
import '../../model/account_data.dart';
|
||||
import '../../push/push_registration.dart';
|
||||
import '../../widget_data/widget_sync.dart';
|
||||
|
||||
/// Owns the login flow's transient state (loading, last error) so it can be
|
||||
@@ -48,6 +50,10 @@ class LoginController extends ChangeNotifier {
|
||||
tokenName: await DeviceTokenName.resolve(),
|
||||
);
|
||||
await AccountData().setData(user, password);
|
||||
// Mint the Nextcloud app password now so it's ready for the push
|
||||
// registration and subsequent NC calls. Non-blocking: on failure push
|
||||
// stays off and retries on the next start.
|
||||
unawaited(PushRegistration().ensureAppPassword());
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
return true;
|
||||
|
||||
@@ -67,10 +67,7 @@ class DefaultSettings {
|
||||
showPastEvents: false,
|
||||
),
|
||||
fileViewSettings: FileViewSettings(alwaysOpenExternally: Platform.isIOS),
|
||||
notificationSettings: NotificationSettings(
|
||||
askUsageDismissed: false,
|
||||
enabled: false,
|
||||
),
|
||||
notificationSettings: NotificationSettings(enabled: true),
|
||||
devToolsSettings: DevToolsSettings(
|
||||
checkerboardOffscreenLayers: false,
|
||||
checkerboardRasterCacheImages: false,
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../../api/marianumcloud/cloud_users/cloud_users_actions.dart';
|
||||
import '../../../../api/marianumconnect/queries/auth_logout/auth_logout.dart';
|
||||
import '../../../../model/account_data.dart';
|
||||
import '../../../../push/push_registration.dart';
|
||||
import '../../../../state/app/modules/account/bloc/account_bloc.dart';
|
||||
import '../../../../state/app/modules/account/bloc/account_state.dart';
|
||||
import '../../../../widget/app_progress_indicator.dart';
|
||||
@@ -192,10 +193,12 @@ class _AccountSectionState extends State<AccountSection> {
|
||||
context.read<AccountBloc>().setStatus(AccountStatus.loggedOut);
|
||||
}
|
||||
|
||||
// Best-effort revoke of the MC bearer token before we wipe local credentials.
|
||||
// The token storage itself is cleared inside AuthLogout regardless of network
|
||||
// success, so an offline logout still gets us into a clean local state.
|
||||
// Ordered teardown: unregister push at Nextcloud + proxy and revoke the app
|
||||
// password (while Nextcloud credentials are still available), THEN revoke the
|
||||
// MC bearer token, and finally wipe local credentials. Each step is
|
||||
// best-effort so an offline logout still reaches a clean local state.
|
||||
Future<void> _performLogout() async {
|
||||
await PushRegistration().logoutCleanup();
|
||||
await AuthLogout().run();
|
||||
await AccountData().removeData();
|
||||
_cachedDisplayName = null;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../notification/notify_updater.dart';
|
||||
import '../../../../api/errors/error_mapper.dart';
|
||||
import '../../../../api/marianumconnect/queries/push_device_test/push_device_test.dart';
|
||||
import '../../../../push/push_registration.dart';
|
||||
import '../../../../push/push_registration_store.dart';
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../../utils/haptics.dart';
|
||||
import '../../../../widget/centered_leading.dart';
|
||||
import '../../../../widget/info_dialog.dart';
|
||||
|
||||
class TalkSection extends StatelessWidget {
|
||||
const TalkSection({super.key});
|
||||
@@ -51,32 +55,134 @@ class TalkSection extends StatelessWidget {
|
||||
leading: const CenteredLeading(
|
||||
Icon(Icons.notifications_active_outlined),
|
||||
),
|
||||
title: const Text('Push-Benachrichtigungen aktivieren'),
|
||||
subtitle: const Text('Lange tippen für mehr Informationen'),
|
||||
title: const Text('Push-Benachrichtigungen'),
|
||||
subtitle: const Text('Neue Talk-Nachrichten direkt aufs Gerät'),
|
||||
trailing: Checkbox(
|
||||
value: notificationSettings.enabled,
|
||||
onChanged: (e) {
|
||||
Haptics.selection();
|
||||
if (e!) {
|
||||
NotifyUpdater.enableAfterDisclaimer(settings).asDialog(context);
|
||||
final enabled = e ?? false;
|
||||
settings.val(write: true).notificationSettings.enabled = enabled;
|
||||
if (enabled) {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
unawaited(() async {
|
||||
// Only register when the OS permission isn't explicitly
|
||||
// denied — otherwise NC + proxy would push into the void.
|
||||
if (await PushRegistration.requestOsPermission()) {
|
||||
await PushRegistration().register();
|
||||
} else {
|
||||
settings.val(write: true).notificationSettings.enabled = e;
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Benachrichtigungen sind in den Systemeinstellungen '
|
||||
'deaktiviert — bitte dort erlauben.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}());
|
||||
} else {
|
||||
unawaited(PushRegistration().unregister());
|
||||
}
|
||||
},
|
||||
),
|
||||
onLongPress: () => _showInfoDialog(context),
|
||||
),
|
||||
if (notificationSettings.enabled) const _TestNotificationTile(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showInfoDialog(BuildContext context) => InfoDialog.show(
|
||||
context,
|
||||
"Aufgrund technischer Limitationen müssen Push-Nachrichten über einen externen Server - hier 'mhsl.eu' (Author dieser App) - erfolgen.\n\n"
|
||||
'Wenn Push aktiviert wird, werden deine Zugangsdaten und ein Token verschlüsselt an den Betreiber gesendet und von ihm unverschlüsselt gespeichert.\n\n'
|
||||
'Der extene Server verwendet die Zugangsdaten um sich maschinell in Talk anzumelden und via Websockets auf neue Nachrichten zu warten.\n\n'
|
||||
'Wenn eine neue Nachricht eintrifft wird dein Telefon via FBC-Messaging (Google Firebase Push) vom externen Server benachrichtigt.\n\n'
|
||||
'Behalte im Hinterkopf, dass deine Zugangsdaten auf einem externen Server gespeichert werden und dies trotz bester Absichten ein Sicherheitsrisiko sein kann!',
|
||||
title: 'Info über Push',
|
||||
/// "Send a test notification" action, shown only while push is enabled. The
|
||||
/// button stays disabled until a registration is confirmed present, then calls
|
||||
/// the backend and reports the result via a SnackBar.
|
||||
class _TestNotificationTile extends StatefulWidget {
|
||||
const _TestNotificationTile();
|
||||
|
||||
@override
|
||||
State<_TestNotificationTile> createState() => _TestNotificationTileState();
|
||||
}
|
||||
|
||||
class _TestNotificationTileState extends State<_TestNotificationTile> {
|
||||
static const _permissionDeniedHint =
|
||||
'Benachrichtigungen sind in den Systemeinstellungen deaktiviert';
|
||||
|
||||
bool _registered = false;
|
||||
bool _permissionDenied = false;
|
||||
bool _sending = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadState();
|
||||
}
|
||||
|
||||
Future<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_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../api/marianumconnect/auth/token_storage.dart';
|
||||
// Prefixed: the dio endpoint singleton shares its name with the enum from
|
||||
// dev_tools_settings.dart imported below.
|
||||
import '../../../../api/marianumconnect/marianumconnect_endpoint.dart'
|
||||
as mc_api;
|
||||
import '../../../../push/push_registration.dart';
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../../storage/dev_tools_settings.dart';
|
||||
import '../../../../storage/settings.dart' as model;
|
||||
@@ -35,6 +42,20 @@ class MarianumConnectEndpointPicker {
|
||||
mutable.marianumConnectEndpoint = next;
|
||||
if (custom != null) mutable.marianumConnectCustomUrl = custom;
|
||||
await const MarianumConnectTokenStorage().clear();
|
||||
// main.dart's BlocBuilder syncs the dio endpoint singleton on
|
||||
// its next rebuild, but the push re-registration below must
|
||||
// see the new base URL right now — update it here first
|
||||
// (idempotent, same value the rebuild would set).
|
||||
mc_api.MarianumConnectEndpoint.update(
|
||||
settings
|
||||
.val()
|
||||
.devToolsSettings
|
||||
.resolveMarianumConnectBaseUrl(),
|
||||
);
|
||||
// A push registration bound to the old proxy would keep
|
||||
// routing pushes there; no-op when not registered or the
|
||||
// endpoints are unchanged.
|
||||
unawaited(PushRegistration().reRegisterIfEndpointChanged());
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_split_view/flutter_split_view.dart';
|
||||
|
||||
import '../../../notification/notify_updater.dart';
|
||||
import '../../../routing/app_routes.dart';
|
||||
import '../../../state/app/infrastructure/loadable_state/loadable_state.dart';
|
||||
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
|
||||
@@ -11,7 +9,6 @@ import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||
import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart';
|
||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../widget/confirm_dialog.dart';
|
||||
import '../../../widget/info_dialog.dart';
|
||||
import '../../../widget/placeholder_view.dart';
|
||||
import 'join_chat.dart';
|
||||
import 'search_chat.dart';
|
||||
@@ -46,7 +43,6 @@ class _ChatListViewState extends State<_ChatListView> {
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_maybeAskForNotificationPermission();
|
||||
_maybeOpenPendingChat();
|
||||
});
|
||||
}
|
||||
@@ -71,43 +67,6 @@ class _ChatListViewState extends State<_ChatListView> {
|
||||
);
|
||||
}
|
||||
|
||||
void _maybeAskForNotificationPermission() {
|
||||
final notificationSettings = _settings.val().notificationSettings;
|
||||
if (notificationSettings.enabled ||
|
||||
notificationSettings.askUsageDismissed) {
|
||||
return;
|
||||
}
|
||||
|
||||
_settings.val(write: true).notificationSettings.askUsageDismissed = true;
|
||||
ConfirmDialog(
|
||||
icon: Icons.notifications_active_outlined,
|
||||
title: 'Benachrichtigungen aktivieren',
|
||||
content:
|
||||
'Auf wunsch kannst du Push-Benachrichtigungen aktivieren. Deine Einstellungen kannst du jederzeit ändern.',
|
||||
confirmButton: 'Weiter',
|
||||
onConfirm: () {
|
||||
FirebaseMessaging.instance.requestPermission(provisional: false).then((
|
||||
value,
|
||||
) {
|
||||
if (!mounted) return;
|
||||
switch (value.authorizationStatus) {
|
||||
case AuthorizationStatus.authorized:
|
||||
NotifyUpdater.enableAfterDisclaimer(_settings).asDialog(context);
|
||||
break;
|
||||
case AuthorizationStatus.denied:
|
||||
InfoDialog.show(
|
||||
context,
|
||||
'Du kannst die Benachrichtigungen später jederzeit in den App-Einstellungen aktivieren.',
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
},
|
||||
).asDialog(context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bloc = context.read<ChatListBloc>();
|
||||
|
||||
+7
-1
@@ -3,7 +3,7 @@ description: Mobile client for Webuntis and Nextcloud with Talk integration
|
||||
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.2.2+57
|
||||
version: 1.3.0+58
|
||||
environment:
|
||||
sdk: ">=3.8.0 <4.0.0"
|
||||
|
||||
@@ -24,6 +24,12 @@ dependencies:
|
||||
collection: ^1.19.0
|
||||
connectivity_plus: ^7.1.0
|
||||
crypto: ^3.0.6
|
||||
# Push (Nextcloud push-v2): RSA-2048 keypair + SPKI-PEM export. Already
|
||||
# present transitively via the nextcloud-neon fork; pinned as a direct
|
||||
# dependency because lib/push/ uses it directly.
|
||||
crypton: ^2.2.1
|
||||
# OAEP-SHA1 decryption of the encrypted push subject (with PKCS1 fallback).
|
||||
pointycastle: ^3.9.1
|
||||
device_info_plus: ^12.4.0
|
||||
dio: ^5.9.2
|
||||
emoji_picker_flutter: ^4.3.0
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user