Files
FEDEO/mobile/plugins/ios/FEDEOSiriIntents.swift

422 lines
16 KiB
Swift

import AppIntents
import Foundation
import Security
private enum FEDEOSiriError: LocalizedError {
case notSignedIn
case noTenant
case invalidResponse
case server(String)
var errorDescription: String? {
switch self {
case .notSignedIn: return "Bitte öffne FEDEO und melde dich zuerst an."
case .noTenant: return "Für dieses Konto ist kein Tenant verfügbar."
case .invalidResponse: return "Der FEDEO-Server hat eine ungültige Antwort geliefert."
case .server(let message): return message
}
}
}
private struct FEDEOMeResponse: Decodable {
struct User: Decodable { let id: String }
let user: User
let tenants: [FEDEOTenant]
let activeTenant: Int?
enum CodingKeys: String, CodingKey {
case user, tenants, activeTenant
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
user = try container.decode(User.self, forKey: .user)
tenants = try container.decodeIfPresent([FEDEOTenant].self, forKey: .tenants) ?? []
if let number = try? container.decode(Int.self, forKey: .activeTenant) {
activeTenant = number
} else if let text = try? container.decode(String.self, forKey: .activeTenant) {
activeTenant = Int(text)
} else {
activeTenant = nil
}
}
}
private struct FEDEOTenant: Codable, Sendable {
let id: Int
let name: String
let short: String?
}
private struct FEDEOTask: Codable, Sendable {
let id: Int
let name: String
let categorie: String?
let archived: Bool?
let userId: String?
let user_id: String?
let profile: String?
}
private struct FEDEOTokenResponse: Decodable { let token: String }
private actor FEDEOSiriClient {
static let shared = FEDEOSiriClient()
private let tokenKey = "fedeo.mobile.auth.token"
private let serverKey = "fedeo.mobile.server.base"
private let preferredTenantKey = "fedeo.mobile.siri.preferred-tenant"
private let defaultServer = "https://app.fedeo.de/backend"
func loadContext() async throws -> (me: FEDEOMeResponse, token: String) {
guard let token = keychainValue(for: tokenKey), !token.isEmpty else {
throw FEDEOSiriError.notSignedIn
}
let me: FEDEOMeResponse = try await request(path: "/api/me", token: token)
guard !me.tenants.isEmpty else { throw FEDEOSiriError.noTenant }
return (me, token)
}
func preferredTenantID() -> Int? {
keychainValue(for: preferredTenantKey).flatMap(Int.init)
}
func setPreferredTenantID(_ tenantID: Int) {
setKeychainValue(String(tenantID), for: preferredTenantKey)
}
func accessToken(for tenantID: Int, context: (me: FEDEOMeResponse, token: String)) async throws -> String {
if context.me.activeTenant == tenantID { return context.token }
let response: FEDEOTokenResponse = try await request(
path: "/api/tenant/switch",
method: "POST",
token: context.token,
body: ["tenant_id": String(tenantID)]
)
return response.token
}
func openTasks(tenantID: Int, context: (me: FEDEOMeResponse, token: String)? = nil) async throws -> [FEDEOTask] {
let loadedContext: (me: FEDEOMeResponse, token: String)
if let context {
loadedContext = context
} else {
loadedContext = try await loadContext()
}
let token = try await accessToken(for: tenantID, context: loadedContext)
let tasks: [FEDEOTask] = try await request(path: "/api/resource/tasks", token: token)
return tasks.filter { task in
guard task.archived != true, task.categorie != "Abgeschlossen" else { return false }
let assignedUser = task.userId ?? task.user_id ?? task.profile
return assignedUser == nil || assignedUser == loadedContext.me.user.id
}
}
func createTask(name: String, tenantID: Int, context: (me: FEDEOMeResponse, token: String)) async throws -> FEDEOTask {
let token = try await accessToken(for: tenantID, context: context)
return try await request(
path: "/api/resource/tasks",
method: "POST",
token: token,
body: ["name": name, "categorie": "Offen", "userId": context.me.user.id]
)
}
@available(iOS 16.0, *)
func completeTask(_ task: FEDEOTodoEntity, context: (me: FEDEOMeResponse, token: String)) async throws {
let token = try await accessToken(for: task.tenantID, context: context)
let _: FEDEOTask = try await request(
path: "/api/resource/tasks/\(task.taskID)",
method: "PUT",
token: token,
body: ["categorie": "Abgeschlossen"]
)
}
private func request<T: Decodable>(
path: String,
method: String = "GET",
token: String,
body: [String: String]? = nil
) async throws -> T {
let base = (keychainValue(for: serverKey) ?? defaultServer).trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard let url = URL(string: base + path) else { throw FEDEOSiriError.invalidResponse }
var request = URLRequest(url: url)
request.httpMethod = method
request.timeoutInterval = 20
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let body {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
}
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else { throw FEDEOSiriError.invalidResponse }
guard (200..<300).contains(httpResponse.statusCode) else {
let payload = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
let message = payload?["message"] as? String ?? payload?["error"] as? String ?? "FEDEO-Anfrage fehlgeschlagen."
throw FEDEOSiriError.server(message)
}
return try JSONDecoder().decode(T.self, from: data)
}
private func keychainValue(for key: String) -> String? {
let keyData = Data(key.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "app:no-auth",
kSecAttrAccount as String: keyData,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
private func setKeychainValue(_ value: String, for key: String) {
let keyData = Data(key.utf8)
let baseQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "app:no-auth",
kSecAttrAccount as String: keyData,
]
let valueData = Data(value.utf8)
if SecItemUpdate(baseQuery as CFDictionary, [kSecValueData as String: valueData] as CFDictionary) == errSecItemNotFound {
var insert = baseQuery
insert[kSecAttrGeneric as String] = keyData
insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
insert[kSecValueData as String] = valueData
SecItemAdd(insert as CFDictionary, nil)
}
}
}
@available(iOS 16.0, *)
struct FEDEOTenantEntity: AppEntity {
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "FEDEO-Tenant")
static var defaultQuery = FEDEOTenantQuery()
let id: Int
let name: String
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(name)")
}
}
@available(iOS 16.0, *)
struct FEDEOTenantQuery: EntityStringQuery {
func entities(for identifiers: [Int]) async throws -> [FEDEOTenantEntity] {
let context = try await FEDEOSiriClient.shared.loadContext()
return context.me.tenants.filter { identifiers.contains($0.id) }.map { .init(id: $0.id, name: $0.name) }
}
func suggestedEntities() async throws -> [FEDEOTenantEntity] {
let context = try await FEDEOSiriClient.shared.loadContext()
let preferred = await FEDEOSiriClient.shared.preferredTenantID() ?? context.me.activeTenant
return context.me.tenants
.sorted { ($0.id == preferred ? 0 : 1, $0.name) < ($1.id == preferred ? 0 : 1, $1.name) }
.map { .init(id: $0.id, name: $0.name) }
}
func entities(matching string: String) async throws -> [FEDEOTenantEntity] {
let terms = string.lowercased().split(separator: " ")
return try await suggestedEntities().filter { tenant in
let name = tenant.name.lowercased()
return terms.allSatisfy(name.contains)
}
}
}
@available(iOS 16.0, *)
struct FEDEOTodoEntity: AppEntity {
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "FEDEO-Todo")
static var defaultQuery = FEDEOTodoQuery()
let id: String
let taskID: Int
let tenantID: Int
let name: String
let tenantName: String
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(name)", subtitle: "\(tenantName)")
}
}
@available(iOS 16.0, *)
struct FEDEOTodoQuery: EntityStringQuery {
func entities(for identifiers: [String]) async throws -> [FEDEOTodoEntity] {
let context = try await FEDEOSiriClient.shared.loadContext()
var result: [FEDEOTodoEntity] = []
for tenant in context.me.tenants {
let requested = identifiers.filter { $0.hasPrefix("\(tenant.id):") }
guard !requested.isEmpty else { continue }
let tasks = try await FEDEOSiriClient.shared.openTasks(tenantID: tenant.id, context: context)
result += tasks.filter { requested.contains("\(tenant.id):\($0.id)") }.map {
.init(id: "\(tenant.id):\($0.id)", taskID: $0.id, tenantID: tenant.id, name: $0.name, tenantName: tenant.name)
}
}
return result
}
func suggestedEntities() async throws -> [FEDEOTodoEntity] {
let context = try await FEDEOSiriClient.shared.loadContext()
let preferredID = await FEDEOSiriClient.shared.preferredTenantID() ?? context.me.activeTenant
guard let tenant = context.me.tenants.first(where: { $0.id == preferredID }) ?? context.me.tenants.first else { return [] }
let tasks = try await FEDEOSiriClient.shared.openTasks(tenantID: tenant.id, context: context)
return tasks.map {
.init(id: "\(tenant.id):\($0.id)", taskID: $0.id, tenantID: tenant.id, name: $0.name, tenantName: tenant.name)
}
}
func entities(matching string: String) async throws -> [FEDEOTodoEntity] {
let terms = string.lowercased().split(separator: " ")
return try await suggestedEntities().filter { todo in
let name = todo.name.lowercased()
return terms.allSatisfy(name.contains)
}
}
}
@available(iOS 16.0, *)
private protocol FEDEOTenantResolving {}
@available(iOS 16.0, *)
extension FEDEOTenantResolving {
func availableTenants(context: (me: FEDEOMeResponse, token: String)) -> [FEDEOTenantEntity] {
context.me.tenants.map { .init(id: $0.id, name: $0.name) }
}
}
@available(iOS 16.0, *)
struct FEDEOCreateTodoIntent: AppIntent, FEDEOTenantResolving {
static var title: LocalizedStringResource = "Todo in FEDEO erstellen"
static var description = IntentDescription("Erstellt ein neues Todo im ausgewählten FEDEO-Tenant.")
@Parameter(title: "Todo") var name: String
@Parameter(title: "Tenant") var tenant: FEDEOTenantEntity?
static var parameterSummary: some ParameterSummary {
Summary("Todo \(\.$name) in \(\.$tenant) erstellen")
}
func perform() async throws -> some IntentResult & ProvidesDialog {
let context = try await FEDEOSiriClient.shared.loadContext()
let selected = try await resolveTenant(context: context)
let task = try await FEDEOSiriClient.shared.createTask(name: name, tenantID: selected.id, context: context)
await FEDEOSiriClient.shared.setPreferredTenantID(selected.id)
return .result(dialog: "Todo „\(task.name)“ wurde bei \(selected.name) erstellt.")
}
private func resolveTenant(context: (me: FEDEOMeResponse, token: String)) async throws -> FEDEOTenantEntity {
if let tenant { return tenant }
if let preferredID = await FEDEOSiriClient.shared.preferredTenantID(),
let preferred = context.me.tenants.first(where: { $0.id == preferredID }) {
return .init(id: preferred.id, name: preferred.name)
}
let currentName = context.me.tenants.first { $0.id == context.me.activeTenant }?.name ?? context.me.tenants[0].name
return try await $tenant.requestDisambiguation(
among: availableTenants(context: context),
dialog: "Soll das Todo bei \(currentName) erstellt werden oder möchtest du den Tenant wechseln?"
)
}
}
@available(iOS 16.0, *)
struct FEDEOListOpenTodosIntent: AppIntent, FEDEOTenantResolving {
static var title: LocalizedStringResource = "Offene FEDEO-Todos anzeigen"
static var description = IntentDescription("Zeigt deine offenen Todos in einem FEDEO-Tenant.")
@Parameter(title: "Tenant") var tenant: FEDEOTenantEntity?
func perform() async throws -> some IntentResult & ReturnsValue<[FEDEOTodoEntity]> & ProvidesDialog {
let context = try await FEDEOSiriClient.shared.loadContext()
let selected = try await resolveTenant(context: context)
let tasks = try await FEDEOSiriClient.shared.openTasks(tenantID: selected.id, context: context)
await FEDEOSiriClient.shared.setPreferredTenantID(selected.id)
let todos = tasks.map {
FEDEOTodoEntity(id: "\(selected.id):\($0.id)", taskID: $0.id, tenantID: selected.id, name: $0.name, tenantName: selected.name)
}
let names = todos.prefix(5).map(\.name).joined(separator: ", ")
let dialog = todos.isEmpty
? "Du hast bei \(selected.name) keine offenen Todos."
: "Bei \(selected.name) sind \(todos.count) Todos offen: \(names)."
return .result(value: todos, dialog: IntentDialog(stringLiteral: dialog))
}
private func resolveTenant(context: (me: FEDEOMeResponse, token: String)) async throws -> FEDEOTenantEntity {
if let tenant { return tenant }
if let preferredID = await FEDEOSiriClient.shared.preferredTenantID(),
let preferred = context.me.tenants.first(where: { $0.id == preferredID }) {
return .init(id: preferred.id, name: preferred.name)
}
return try await $tenant.requestDisambiguation(among: availableTenants(context: context), dialog: "Für welchen Tenant soll ich die offenen Todos anzeigen?")
}
}
@available(iOS 16.0, *)
struct FEDEOCompleteTodoIntent: AppIntent {
static var title: LocalizedStringResource = "FEDEO-Todo erledigen"
static var description = IntentDescription("Markiert ein offenes FEDEO-Todo als abgeschlossen.")
@Parameter(title: "Todo") var todo: FEDEOTodoEntity
static var parameterSummary: some ParameterSummary {
Summary("\(\.$todo) erledigen")
}
func perform() async throws -> some IntentResult & ProvidesDialog {
let context = try await FEDEOSiriClient.shared.loadContext()
try await FEDEOSiriClient.shared.completeTask(todo, context: context)
await FEDEOSiriClient.shared.setPreferredTenantID(todo.tenantID)
return .result(dialog: "Todo „\(todo.name)“ wurde bei \(todo.tenantName) erledigt.")
}
}
@available(iOS 16.0, *)
struct FEDEOAppShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: FEDEOCreateTodoIntent(),
phrases: [
"Erstelle ein Todo in \(.applicationName)",
"Erstelle ein Todo mit \(.applicationName)",
"Neues Todo in \(.applicationName)",
"Neues Todo mit \(.applicationName)",
],
shortTitle: "Todo erstellen",
systemImageName: "checklist"
)
AppShortcut(
intent: FEDEOListOpenTodosIntent(),
phrases: [
"Zeige meine offenen Todos in \(.applicationName)",
"Zeige meine offenen Todos mit \(.applicationName)",
"Meine Todos in \(.applicationName)",
"Meine Todos bei \(.applicationName)",
"Was sind meine offenen Todos in \(.applicationName)",
],
shortTitle: "Offene Todos",
systemImageName: "list.bullet"
)
AppShortcut(
intent: FEDEOCompleteTodoIntent(),
phrases: [
"Erledige ein Todo in \(.applicationName)",
"Erledige ein Todo mit \(.applicationName)",
"Todo abschließen in \(.applicationName)",
"Todo abschließen mit \(.applicationName)",
"Erledige \(\.$todo) in \(.applicationName)",
"Schließe \(\.$todo) in \(.applicationName) ab",
],
shortTitle: "Todo erledigen",
systemImageName: "checkmark.circle"
)
}
}