diff --git a/mobile/app/more/settings.tsx b/mobile/app/more/settings.tsx index e6f3382..c7fbc58 100644 --- a/mobile/app/more/settings.tsx +++ b/mobile/app/more/settings.tsx @@ -5,6 +5,7 @@ import { router } from 'expo-router'; import { DEFAULT_API_BASE_URL } from '@/src/config/env'; import { sendMobileTestPush } from '@/src/lib/api'; import { registerDeviceForPush } from '@/src/lib/push-registration'; +import { getSiriPreferredTenantId, setSiriPreferredTenantId } from '@/src/lib/siri-settings'; import { getApiBaseUrlSync, hydrateApiBaseUrl, @@ -21,13 +22,15 @@ function isValidServerUrl(value: string): boolean { } export default function SettingsScreen() { - const { logout, token } = useAuth(); + const { activeTenantId, logout, tenants, token } = useAuth(); const [serverUrl, setServerUrlInput] = useState(getApiBaseUrlSync()); const [savedUrl, setSavedUrl] = useState(getApiBaseUrlSync()); const [submitting, setSubmitting] = useState(false); const [pushSubmitting, setPushSubmitting] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); + const [siriTenantId, setSiriTenantId] = useState(null); + const [siriSubmitting, setSiriSubmitting] = useState(false); const loadConfig = useCallback(async () => { const current = await hydrateApiBaseUrl(); @@ -37,8 +40,26 @@ export default function SettingsScreen() { useEffect(() => { void loadConfig(); + void getSiriPreferredTenantId().then(setSiriTenantId); }, [loadConfig]); + async function onSelectSiriTenant(tenantId: number | null) { + setSiriSubmitting(true); + setError(null); + setSuccess(null); + try { + await setSiriPreferredTenantId(tenantId); + setSiriTenantId(tenantId); + setSuccess(tenantId === null + ? 'Siri fragt beim nächsten Befehl wieder nach dem Tenant.' + : 'Siri-Standardtenant gespeichert.'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Siri-Standardtenant konnte nicht gespeichert werden.'); + } finally { + setSiriSubmitting(false); + } + } + async function onSave() { setError(null); setSuccess(null); @@ -175,6 +196,43 @@ export default function SettingsScreen() { + + Siri & Kurzbefehle + + Der Siri-Standardtenant gilt nur für Sprachbefehle. Der aktuell in FEDEO geöffnete Tenant wird dadurch + nicht gewechselt. Ohne Auswahl fragt Siri beim nächsten Befehl nach. + + + {tenants.map((tenant) => { + const tenantId = Number(tenant.id); + const selected = tenantId === siriTenantId; + return ( + onSelectSiriTenant(tenantId)} + disabled={siriSubmitting}> + + {tenant.name} + {tenantId === activeTenantId ? Aktuell in FEDEO geöffnet : null} + + + {selected ? 'Siri-Standard' : 'Auswählen'} + + + ); + })} + + onSelectSiriTenant(null)} + disabled={siriSubmitting || siriTenantId === null}> + Bei nächstem Befehl nachfragen + + + Verfügbar: Todo erstellen · Offene Todos anzeigen · Todo erledigen + + Mobile Push @@ -247,6 +305,25 @@ const styles = StyleSheet.create({ color: '#6b7280', fontSize: 12, }, + tenantButton: { + minHeight: 52, + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 9, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 10, + backgroundColor: '#ffffff', + }, + tenantButtonSelected: { borderColor: PRIMARY, backgroundColor: '#eff9ea' }, + tenantTextWrap: { flex: 1 }, + tenantName: { color: '#111827', fontSize: 14, fontWeight: '600' }, + tenantNameSelected: { color: '#2f5f24' }, + tenantAction: { color: '#6b7280', fontSize: 12, fontWeight: '600' }, + tenantActionSelected: { color: '#3d7a30' }, actions: { gap: 8, marginTop: 6, diff --git a/mobile/plugins/ios/FEDEOSiriIntents.swift b/mobile/plugins/ios/FEDEOSiriIntents.swift new file mode 100644 index 0000000..b625275 --- /dev/null +++ b/mobile/plugins/ios/FEDEOSiriIntents.swift @@ -0,0 +1,403 @@ +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( + 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)", "Neues Todo in \(.applicationName)"], + shortTitle: "Todo erstellen", + systemImageName: "checklist" + ) + AppShortcut( + intent: FEDEOListOpenTodosIntent(), + phrases: ["Zeige meine offenen Todos in \(.applicationName)", "Meine Todos in \(.applicationName)"], + shortTitle: "Offene Todos", + systemImageName: "list.bullet" + ) + AppShortcut( + intent: FEDEOCompleteTodoIntent(), + phrases: ["Erledige ein Todo in \(.applicationName)", "Todo abschließen in \(.applicationName)"], + shortTitle: "Todo erledigen", + systemImageName: "checkmark.circle" + ) + } +} diff --git a/mobile/plugins/with-share-intent-multifile.js b/mobile/plugins/with-share-intent-multifile.js index da245c7..e6b7694 100644 --- a/mobile/plugins/with-share-intent-multifile.js +++ b/mobile/plugins/with-share-intent-multifile.js @@ -3,6 +3,7 @@ const path = require('node:path'); const { withXcodeProject } = require('@expo/config-plugins'); const SHARE_EXTENSION_DIRECTORY = 'InFEDEOhochladen'; +const SIRI_INTENTS_FILE = 'FEDEOSiriIntents.swift'; function replaceOnce(source, search, replacement, label) { if (!source.includes(search)) { @@ -13,6 +14,34 @@ function replaceOnce(source, search, replacement, label) { module.exports = function withShareIntentMultifile(config) { return withXcodeProject(config, async (modConfig) => { + const siriTemplatePath = path.join(__dirname, 'ios', SIRI_INTENTS_FILE); + const siriDestinationPath = path.join(modConfig.modRequest.platformProjectRoot, 'FEDEO', SIRI_INTENTS_FILE); + fs.copyFileSync(siriTemplatePath, siriDestinationPath); + + const project = modConfig.modResults; + const appTarget = project.pbxTargetByName('FEDEO'); + const appGroup = project.pbxGroupByName('FEDEO'); + const appTargetUUID = Object.entries(project.pbxNativeTargetSection()).find( + ([key, entry]) => !key.endsWith('_comment') && entry === appTarget + )?.[0]; + const appGroupUUID = Object.entries(project.hash.project.objects.PBXGroup).find( + ([key, entry]) => !key.endsWith('_comment') && entry === appGroup + )?.[0]; + const fileReferences = project.pbxFileReferenceSection(); + const siriFileExists = Object.values(fileReferences).some( + (entry) => entry && typeof entry === 'object' && String(entry.path || entry.name || '').includes(SIRI_INTENTS_FILE) + ); + if (!siriFileExists) { + if (!appTargetUUID || !appGroupUUID) { + throw new Error('FEDEO-App-Target für Siri App Intents wurde nicht gefunden.'); + } + project.addSourceFile( + `FEDEO/${SIRI_INTENTS_FILE}`, + { target: appTargetUUID }, + appGroupUUID + ); + } + const controllerPath = path.join( modConfig.modRequest.platformProjectRoot, SHARE_EXTENSION_DIRECTORY, diff --git a/mobile/src/lib/siri-settings.ts b/mobile/src/lib/siri-settings.ts new file mode 100644 index 0000000..c16b196 --- /dev/null +++ b/mobile/src/lib/siri-settings.ts @@ -0,0 +1,18 @@ +import * as SecureStore from 'expo-secure-store'; + +const SIRI_PREFERRED_TENANT_KEY = 'fedeo.mobile.siri.preferred-tenant'; + +export async function getSiriPreferredTenantId(): Promise { + const stored = await SecureStore.getItemAsync(SIRI_PREFERRED_TENANT_KEY); + if (!stored) return null; + const tenantId = Number(stored); + return Number.isFinite(tenantId) ? tenantId : null; +} + +export async function setSiriPreferredTenantId(tenantId: number | null): Promise { + if (tenantId === null) { + await SecureStore.deleteItemAsync(SIRI_PREFERRED_TENANT_KEY); + return; + } + await SecureStore.setItemAsync(SIRI_PREFERRED_TENANT_KEY, String(tenantId)); +} diff --git a/mobile/src/lib/token-storage.ts b/mobile/src/lib/token-storage.ts index 766142a..6e34e67 100644 --- a/mobile/src/lib/token-storage.ts +++ b/mobile/src/lib/token-storage.ts @@ -2,6 +2,7 @@ import * as SecureStore from 'expo-secure-store'; const TOKEN_KEY = 'fedeo.mobile.auth.token'; const REFRESH_TOKEN_KEY = 'fedeo.mobile.auth.refresh-token'; +const SIRI_PREFERRED_TENANT_KEY = 'fedeo.mobile.siri.preferred-tenant'; let memoryToken: string | null = null; let memoryRefreshToken: string | null = null; @@ -71,6 +72,7 @@ export async function clearStoredToken(): Promise { await Promise.all([ SecureStore.deleteItemAsync(TOKEN_KEY), SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY), + SecureStore.deleteItemAsync(SIRI_PREFERRED_TENANT_KEY), ]); } }