204 lines
7.0 KiB
Swift
204 lines
7.0 KiB
Swift
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)
|
|
}
|
|
}
|