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 { 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) } }