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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user