diff --git a/AGENTS.md b/AGENTS.md index 1a7ab1d..a195af2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8338,3 +8338,78 @@ nativ. Steht zur Prüfung beim Eigentümer. `UIPasteboard` oder er verschwindet auf dem Telefon. - **Teilen legt keinen Tankvorgang an** (Abschnitt BQ) — die drei OTA-Teile und der native Umbau stehen weiter aus. + +--- + +## BR. Der Teilen-Weg, der nie ankam — und was daran alles hing (2026.9.2.24/.25) + +### Der Befund: falscher Behälter, falscher Schlüssel + +Gemeldet am 02.09.2026: „ich habe am iPhone einen Tankbeleg mit der App geteilt. Dabei öffnet die +App" — und danach passiert nichts, es wird kein Tankvorgang angelegt. + +Die Erweiterung legte den Beleg base64-kodiert unter `dm360_geteilter_beleg` in +`UserDefaults(suiteName: "group.app.datametric360")` ab. Die App holte ihn mit +`@capacitor/preferences` und `configure({ group })` — in der Annahme, das Plugin lese damit die +UserDefaults der Gruppe. **Das tut es nicht.** Sein iOS-Code (8.0.1, +`ios/Sources/PreferencesPlugin/Preferences.swift`): + +```swift +private var defaults: UserDefaults { return UserDefaults.standard } // immer +private var prefix: String { group + "." } // Gruppe = nur Präfix +``` + +`configure({group})` wechselt **nicht den Container**, es setzt ein Schlüssel-Präfix. Gesucht wurde +also `group.app.datametric360.dm360_geteilter_beleg` im eigenen Bereich der App, abgelegt war der +blanke Schlüssel im Gruppen-Container. Zwei Seiten, die sich nie treffen konnten — und weil ein +fehlender Eintrag der Normalfall ist (jeder gewöhnliche App-Start), meldete nichts einen Fehler. + +**Die Lehre:** Bei einem Plugin, das eine Systemgrenze überbrücken soll, den Quelltext lesen, nicht +die README. Zwei Zeilen hätten den ganzen Entwurf verhindert. + +### Der Umbau: Datei statt UserDefaults + +Die Erweiterung schreibt das PDF jetzt in den Container der App-Gruppe und gibt den Pfad im Aufruf +mit — `datametric360://beleg?pfad=file:///…`. Die App liest ihn über `@capacitor/filesystem`, das +volle `file://`-Pfade ausdrücklich unterstützt, und löscht die Datei danach. Abgeholt wird über +**`getLaunchUrl()` und `appUrlOpen`**: iOS benutzt beide, je nachdem ob die App lief. + +Zwei Altlasten fallen dabei weg: die Base64-Aufblähung um ein Drittel und die 4-MB-Grenze der +UserDefaults (jetzt 24 MB, eine reine Vernunftgrenze). + +### Zwei weitere Fehler auf demselben Weg + +**Ein Beleg zu einem bestehenden Tankvorgang lief in die Zeitüberschreitung.** Die Warteschleife in +`Tanken.tsx` wertete nur Ergebnisse mit `tank_id == null` aus; kam eine `tank_id` zurück, drehte sie +20 Sekunden weiter und meldete „Der Server hat den Beleg noch nicht zurückgemeldet", obwohl er +längst fertig war. + +**Beim Duplikat verriet das Backend nicht, welcher Vorgang gemeint ist.** `belege.py` meldete +`{"fehler": "Beleg bereits erfasst", "tank_id": }` — beim Teilen ist das `null`. Jetzt +kommt `vorhandener_tank_id` mit, und beide Oberflächen springen dorthin. Bewusst ein **eigenes +Feld**: `tank_id` ist die Eingabe des Aufrufs, und das Panel entscheidet daran, wo es den Fehler +anzeigt — eine Umdeutung hätte die Anzeige im Neuanlage-Formular stillschweigend zerstört. + +Dazu, auf Wunsch des Eigentümers: nach dem Speichern öffnet sich der neu angelegte Vorgang (die +Liste wird frisch gelesen und der Eintrag am `receipt_key` wiedererkannt — der Dienst gibt keine ID +zurück), und während der Server liest, steht „Tankbeleg wird verarbeitet …" mit Zapfsäule und +Kreisel im Formular. + +### Zwischenablage: dieselbe Grenze, dieselbe Lösung + +`navigator.clipboard.read()` gibt nur Text, HTML und Bilder heraus — ein kopiertes PDF ist dort +**nie** zu bekommen. Der Knopf „Aus Zwischenablage einfügen" endete auf dem Telefon zwangsläufig mit +„In der Zwischenablage liegt kein PDF". Keine Lücke im Code, sondern die Grenze der Schnittstelle. + +`UIPasteboard` kennt sie nicht. Ein kleines eigenes Plugin +(`native/BelegZwischenablage.swift`, 70 Zeilen) holt das PDF auf zwei Wegen: als Daten unter +`com.adobe.pdf`, und — für Mail, das Anhänge oft nur als Verweis kopiert — über `pasteboard.urls` +mit Sicherheitsbereich. `ios-teilen-einrichten.mjs` hängt die Datei bei jedem Bau an die +Sources-Phase des App-Ziels; das steht **vor** dem Ausstieg für „Erweiterung schon vorhanden", +weil `ios/` jederzeit neu erzeugt werden kann. + +### Was davon einen Mac-Lauf braucht + +Alles Native: der Umbau der Übergabe und das Zwischenablage-Plugin. Die drei Teile davor +(Backend-Feld, Warteschleife, Anzeige) laufen per OTA. **Rein per OTA ist die Brücke über die +App-Gruppe nicht zu reparieren** — egal welchen Weg man nimmt. diff --git a/companion-app/native/BelegZwischenablage.swift b/companion-app/native/BelegZwischenablage.swift new file mode 100644 index 0000000..ab79e03 --- /dev/null +++ b/companion-app/native/BelegZwischenablage.swift @@ -0,0 +1,74 @@ +// +// BelegZwischenablage.swift +// DataMetric360 +// +// Holt ein PDF aus der System-Zwischenablage — der Weg, den die Web-API +// nicht anbietet. +// +// WARUM ES DAS BRAUCHT +// -------------------- +// `navigator.clipboard.read()` gibt aus Datenschutzgründen nur Text, HTML +// und Bilder heraus; ein kopiertes PDF taucht dort NIE auf. Der Knopf „Aus +// Zwischenablage einfügen" endete deshalb auf dem Telefon zwangsläufig mit +// „In der Zwischenablage liegt kein PDF" (gemeldet vom Eigentümer, +// 02.09.2026). Das ist keine Lücke in unserem Code, sondern die Grenze der +// Schnittstelle. +// +// `UIPasteboard` kennt diese Grenze nicht: es gibt beliebige Typen heraus, +// auch `com.adobe.pdf`. Der Preis ist derselbe wie im Web — iOS zeigt beim +// Zugriff seine eigene Einfügen-Rückfrage, und das ist richtig so. +// +// ZWEI FORMEN, IN DENEN EIN PDF IN DER ZWISCHENABLAGE LIEGT +// --------------------------------------------------------- +// 1. Als Daten unter `com.adobe.pdf` — so legen es die meisten Apps ab. +// 2. Als Datei-URL — Mail kopiert einen Anhang oft nur als Verweis auf eine +// temporäre Kopie. Ohne den zweiten Weg bliebe genau der Fall übrig, für +// den der Knopf gedacht ist. +// +// DIESE DATEI IST VERSIONIERT, ios/ NICHT. +// `npx cap add ios` erzeugt den nativen Ordner neu und würde alles darin +// verlieren. scripts/ios-teilen-einrichten.mjs kopiert sie bei jedem Bau +// wieder hinein und hängt sie an die Sources-Phase des App-Ziels. +// + +import Capacitor +import Foundation +import UIKit +import UniformTypeIdentifiers + +@objc(BelegZwischenablagePlugin) +public class BelegZwischenablagePlugin: CAPPlugin, CAPBridgedPlugin { + public let identifier = "BelegZwischenablagePlugin" + public let jsName = "BelegZwischenablage" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "pdfHolen", returnType: CAPPluginReturnPromise) + ] + + /// Liefert `{ base64, name }`, wenn ein PDF in der Zwischenablage liegt — + /// sonst ein leeres Ergebnis. Bewusst kein Fehler: „nichts kopiert" ist + /// kein Fehlschlag, sondern eine Antwort, und die Oberfläche sagt dazu + /// ohnehin das Passende (zwischenablage.ts). + @objc func pdfHolen(_ call: CAPPluginCall) { + let ablage = UIPasteboard.general + + if let daten = ablage.data(forPasteboardType: UTType.pdf.identifier) { + return call.resolve(["base64": daten.base64EncodedString(), "name": "beleg.pdf"]) + } + + // Mail legt den Anhang oft nur als Verweis ab. + for url in ablage.urls ?? [] where url.pathExtension.lowercased() == "pdf" { + // Sicherheitsbereich anfordern: die URL zeigt auf eine Datei + // außerhalb des eigenen Sandkastens, sonst schlägt das Lesen fehl. + let erlaubt = url.startAccessingSecurityScopedResource() + defer { if erlaubt { url.stopAccessingSecurityScopedResource() } } + if let daten = try? Data(contentsOf: url) { + return call.resolve([ + "base64": daten.base64EncodedString(), + "name": url.lastPathComponent, + ]) + } + } + + call.resolve([:]) + } +} diff --git a/companion-app/native/teilen/ShareViewController.swift b/companion-app/native/teilen/ShareViewController.swift index ca2a488..aa2fb2b 100644 --- a/companion-app/native/teilen/ShareViewController.swift +++ b/companion-app/native/teilen/ShareViewController.swift @@ -11,24 +11,28 @@ // Sie kann der App nichts direkt geben; der einzige gemeinsame Boden ist // eine App-Gruppe. Der Weg hier: // -// 1. Das PDF wird gelesen und Base64-kodiert. -// 2. Es landet zusammen mit seinem Namen in den geteilten -// UserDefaults der Gruppe `group.app.datametric360`. -// 3. Die Erweiterung öffnet die App über ihr eigenes URL-Schema. -// 4. Die App liest den Eintrag beim Start bzw. beim Zurückkehren aus, -// löscht ihn und lädt den Beleg hoch (daten/geteilterBeleg.ts). +// 1. Das PDF wird in den Container der App-Gruppe geschrieben. +// 2. Die Erweiterung öffnet die App über ihr URL-Schema und gibt den +// Pfad mit: `datametric360://beleg?pfad=`. +// 3. Die App liest die Datei über @capacitor/filesystem (das volle +// file://-Pfade ausdrücklich unterstützt) und löscht sie danach. // -// Base64 in UserDefaults statt einer Datei im Gruppen-Container: die App -// liest UserDefaults ohnehin schon über @capacitor/preferences, das genau -// diese Gruppe kennt. Eine Datei im Container bräuchte dagegen eigenen -// nativen Code auf der App-Seite — ein zusätzliches Plugin für einen Weg, -// den es sonst nicht gäbe. +// DER ERSTE ENTWURF GING ÜBER USERDEFAULTS UND HAT NIE FUNKTIONIERT. +// Er legte das PDF base64-kodiert unter `dm360_geteilter_beleg` in +// `UserDefaults(suiteName: gruppe)` ab, weil die App UserDefaults über +// @capacitor/preferences ohnehin liest. Nur liest dieses Plugin (8.0.1, +// ios/Sources/PreferencesPlugin/Preferences.swift) IMMER +// `UserDefaults.standard` der App — `configure({ group })` wechselt nicht +// den Container, sondern setzt nur ein Schlüssel-Präfix. Die App suchte +// also `group.app.datametric360.dm360_geteilter_beleg` in ihrem eigenen +// Bereich, während hier der plain Schlüssel im Gruppen-Container lag: +// falscher Behälter und falscher Schlüssel. Gemeldet am 02.09.2026 +// („es wird kein Tankvorgang angelegt beim Teilen mit der App“). // -// Der Preis ist eine Größengrenze: UserDefaults ist für Einstellungen -// gedacht, nicht für Anhänge. GRENZE_BYTES hält das bei 4 MB. Ein -// Tankbeleg liegt bei einigen Dutzend Kilobyte, ein gescanntes Mehrseiten- -// PDF kann darüber liegen — dann sagt die Erweiterung das und der Weg über -// „Datei auswählen" in der App bleibt. +// Die Datei hat zwei weitere Vorteile: keine Base64-Aufblähung um ein +// Drittel, und keine 4-MB-Grenze — UserDefaults ist für Einstellungen +// gedacht, nicht für Anhänge. Geblieben ist eine Obergrenze, aber eine +// großzügige (GRENZE_BYTES). // // DIESE DATEI IST VERSIONIERT, ios/ NICHT. // `npx cap add ios` erzeugt den nativen Ordner neu und würde alles darin @@ -45,13 +49,13 @@ class ShareViewController: UIViewController { /// Muss zur App-Gruppe in beiden Entitlements-Dateien passen. private let gruppe = "group.app.datametric360" - /// Schlüssel, unter dem die App den Beleg abholt (geteilterBeleg.ts). - private let schluessel = "dm360_geteilter_beleg" + /// Unterordner im Gruppen-Container, in dem geteilte Belege liegen. + private let ordner = "geteilte-belege" - /// 4 MB. UserDefaults ist kein Dateispeicher — darüber wird abgelehnt, - /// statt das System mit einem Anhang zu belasten, für den es nicht - /// gedacht ist. - private let grenzeBytes = 4 * 1024 * 1024 + /// 24 MB. Kein technisches Limit mehr, sondern eine Vernunftgrenze: + /// ein Tankbeleg liegt bei einigen Dutzend Kilobyte, alles jenseits + /// davon ist kein Beleg, sondern ein Versehen. + private let grenzeBytes = 24 * 1024 * 1024 override func viewDidLoad() { super.viewDidLoad() @@ -102,26 +106,30 @@ class ShareViewController: UIViewController { } private func ablegen(daten: Data, name: String) { - guard let ablage = UserDefaults(suiteName: gruppe) else { + guard + let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: gruppe) + else { return melden("Die App-Gruppe ist nicht eingerichtet.") } - // Form wie in geteilterBeleg.ts erwartet. @capacitor/preferences legt - // Werte als String ab, deshalb JSON und kein Dictionary. - let inhalt: [String: Any] = [ - "name": name, - "base64": daten.base64EncodedString(), - "zeit": Int(Date().timeIntervalSince1970 * 1000), - ] - guard - let json = try? JSONSerialization.data(withJSONObject: inhalt), - let text = String(data: json, encoding: .utf8) - else { + + let ziel = container.appendingPathComponent(ordner, isDirectory: true) + // Ein Zeitstempel im Namen: zwei rasch nacheinander geteilte Belege + // sollen sich nicht gegenseitig überschreiben, und der Name bleibt + // für den Nutzer lesbar, falls er den Container je zu sehen bekommt. + let datei = ziel.appendingPathComponent( + "\(Int(Date().timeIntervalSince1970))-\(name)") + + do { + try FileManager.default.createDirectory( + at: ziel, withIntermediateDirectories: true) + try daten.write(to: datei, options: .atomic) + } catch { return melden("Der Beleg ließ sich nicht übergeben.") } - ablage.set(text, forKey: schluessel) DispatchQueue.main.async { [weak self] in - self?.appOeffnen() + self?.appOeffnen(pfad: datei) self?.abschliessen() } } @@ -129,8 +137,15 @@ class ShareViewController: UIViewController { /// Öffnet die Host-App. Eine Erweiterung darf `UIApplication.shared` nicht /// benutzen, deshalb der Umweg über die Responder-Kette — der einzige von /// Apple vorgesehene Weg aus einer Erweiterung heraus. - private func appOeffnen() { - guard let ziel = URL(string: "datametric360://beleg") else { return } + private func appOeffnen(pfad: URL) { + // Der Pfad wandert als Abfrageparameter mit. Er zeigt in den + // Gruppen-Container, den die App mit derselben Berechtigung lesen + // darf - kein zusätzliches Plugin nötig. + var bau = URLComponents() + bau.scheme = "datametric360" + bau.host = "beleg" + bau.queryItems = [URLQueryItem(name: "pfad", value: pfad.absoluteString)] + guard let ziel = bau.url else { return } var antwort: UIResponder? = self while let r = antwort { if let app = r as? UIApplication { diff --git a/companion-app/scripts/ios-teilen-einrichten.mjs b/companion-app/scripts/ios-teilen-einrichten.mjs index e4b05de..9a85546 100644 --- a/companion-app/scripts/ios-teilen-einrichten.mjs +++ b/companion-app/scripts/ios-teilen-einrichten.mjs @@ -87,6 +87,10 @@ for (const datei of ["ShareViewController.swift", "Info.plist", `${ZIEL_NAME}.en copyFileSync(join(quelleErw, datei), join(zielErw, datei)) } copyFileSync(join(APP, "native", "App.entitlements"), join(iosApp, "App", "App.entitlements")) +copyFileSync( + join(APP, "native", "BelegZwischenablage.swift"), + join(iosApp, "App", "BelegZwischenablage.swift"), +) melden(`Quellen nach ios/App/${ZIEL_NAME}/ kopiert`) /* ------------------------------------------------------------- URL-Schema */ @@ -125,6 +129,29 @@ if (!appPlist.includes("CFBundleURLTypes")) { const projekt = xcode.project(pbxPfad) projekt.parseSync() +/* ------------------------------------------- Zwischenablage-Plugin (App-Ziel) + + Steht VOR dem Ausstieg unten: die Erweiterung ist nach dem ersten Lauf + vorhanden, das Plugin muss trotzdem bei jedem Bau geprüft werden - ios/ + wird von `npx cap add ios` jederzeit neu erzeugt. + + `addSourceFile` ohne Gruppe hängt die Datei an die ERSTE Sources-Phase des + Projekts, und das ist die des App-Ziels - genau die richtige. (Mit Gruppe + landete die Datei nur im Navigator; daran ist am 02.09.2026 die Erweiterung + ohne Programm entstanden, siehe unten.) */ +const PLUGIN_DATEI = "BelegZwischenablage.swift" +const schonDrin = JSON.stringify(projekt.pbxSourcesBuildPhaseObj()).includes(PLUGIN_DATEI) +if (schonDrin) { + melden("Zwischenablage-Plugin war bereits im App-Ziel") +} else { + projekt.addSourceFile(`App/${PLUGIN_DATEI}`) + writeFileSync(pbxPfad, projekt.writeSync()) + melden("Zwischenablage-Plugin ins App-Ziel gehängt") + // Nach dem Schreiben neu einlesen: der Rest des Skripts arbeitet sonst auf + // einem Stand, der die eigene Änderung nicht kennt. + projekt.parseSync() +} + const vorhanden = Object.values(projekt.pbxNativeTargetSection()).some( (z) => z && typeof z === "object" && z.name === ZIEL_NAME, ) diff --git a/companion-app/src/App.tsx b/companion-app/src/App.tsx index ed67e95..3dacae3 100644 --- a/companion-app/src/App.tsx +++ b/companion-app/src/App.tsx @@ -4,7 +4,7 @@ import { App as CapacitorApp } from "@capacitor/app" import { DataMetricApi, zugangLesen } from "./api" import { DatenAnbieter, useDaten } from "./daten/DatenKontext" -import { geteiltenBelegAbholen, geteiltenBelegSetzen } from "./daten/geteilterBeleg" +import { geteiltenBelegLesen, geteiltenBelegSetzen } from "./daten/geteilterBeleg" import { startklarMelden } from "./daten/ota" import { Shell } from "./Shell" import type { Route, SeitenName } from "./navigation" @@ -91,16 +91,27 @@ function AngemeldeteApp({ beiAbmeldung }: { beiAbmeldung: () => void }) { useEffect(() => { let abgemeldet = false - const holen = async () => { - const datei = await geteiltenBelegAbholen() + /* Der Beleg kommt im Aufruf mit: die Teilen-Erweiterung schreibt ihn in + den Container der App-Gruppe und öffnet die App mit dem Pfad + (ShareViewController.swift). Zwei Wege hierher, weil iOS beide benutzt: + war die App zu, steht der Aufruf in getLaunchUrl(); lief sie schon, + kommt er als appUrlOpen. + + Bis zum 02.09.2026 stand hier "resume" plus ein Griff in die + UserDefaults - der konnte nie etwas finden, die Begründung steht in + geteilterBeleg.ts. */ + const holen = async (url: string | null | undefined) => { + const datei = await geteiltenBelegLesen(url) if (abgemeldet || !datei) return geteiltenBelegSetzen(datei) geheZu("fuel") } - void holen() + void CapacitorApp.getLaunchUrl().then((start) => holen(start?.url)) - const horcher = CapacitorApp.addListener("resume", () => void holen()) + const horcher = CapacitorApp.addListener("appUrlOpen", (ereignis) => { + void holen(ereignis.url) + }) return () => { abgemeldet = true void horcher.then((h) => h.remove()) diff --git a/companion-app/src/daten/geteilterBeleg.test.ts b/companion-app/src/daten/geteilterBeleg.test.ts index 12f9494..f9de4a3 100644 --- a/companion-app/src/daten/geteilterBeleg.test.ts +++ b/companion-app/src/daten/geteilterBeleg.test.ts @@ -1,95 +1,119 @@ /** * Die Auswertung eines geteilten Belegs — der Teil, der ohne Gerät prüfbar - * ist. Das Ablegen selbst passiert nativ (ShareViewController.swift) und - * kann hier nicht nachgestellt werden; geprüft wird, was die App mit dem - * abgelegten Eintrag macht. + * ist. Das Ablegen selbst passiert nativ (ShareViewController.swift) und kann + * hier nicht nachgestellt werden; geprüft wird, was die App mit dem Aufruf und + * der abgelegten Datei macht. * - * `@capacitor/core` und `@capacitor/preferences` werden ersetzt: das eine - * meldet sonst „kein natives Gerät" und die Abholung steigt sofort aus, das - * andere greift auf eine native Ablage zu, die es hier nicht gibt. + * `@capacitor/core` und `@capacitor/filesystem` werden ersetzt: das eine + * meldet sonst „kein natives Gerät" und das Lesen steigt sofort aus, das + * andere greift auf einen Dateibereich zu, den es hier nicht gibt. */ import { beforeEach, describe, expect, it, vi } from "vitest" -const ablage = new Map() +const dateien = new Map() vi.mock("@capacitor/core", () => ({ Capacitor: { isNativePlatform: () => true }, })) -vi.mock("@capacitor/preferences", () => ({ - Preferences: { - configure: vi.fn(async () => {}), - get: vi.fn(async ({ key }: { key: string }) => ({ value: ablage.get(key) ?? null })), - remove: vi.fn(async ({ key }: { key: string }) => { - ablage.delete(key) +vi.mock("@capacitor/filesystem", () => ({ + Filesystem: { + readFile: vi.fn(async ({ path }: { path: string }) => { + const inhalt = dateien.get(path) + if (inhalt === undefined) throw new Error("nicht gefunden") + return { data: inhalt } + }), + deleteFile: vi.fn(async ({ path }: { path: string }) => { + dateien.delete(path) }), }, })) -const SCHLUESSEL = "dm360_geteilter_beleg" - -/** Ein winziges, gültiges PDF als Base64 — Inhalt ist hier gleichgültig. */ +/** Ein winziges, gültiges PDF als Base64 — der Inhalt ist hier gleichgültig. */ const PDF_BASE64 = btoa("%PDF-1.4\n%%EOF\n") -function ablegen(eintrag: Record) { - ablage.set(SCHLUESSEL, JSON.stringify(eintrag)) -} +const PFAD = "file:///var/mobile/Shared/AppGroup/abc/geteilte-belege/1756832400-beleg.pdf" +const AUFRUF = `datametric360://beleg?pfad=${encodeURIComponent(PFAD)}` -describe("geteilten Beleg abholen", () => { +describe("Belegpfad aus dem Aufruf", () => { + it("erkennt den Pfad", async () => { + const { belegpfadAusUrl } = await import("./geteilterBeleg") + expect(belegpfadAusUrl(AUFRUF)).toBe(PFAD) + }) + + it("lässt fremde Aufrufe in Ruhe", async () => { + const { belegpfadAusUrl } = await import("./geteilterBeleg") + expect(belegpfadAusUrl("datametric360://einstellungen")).toBeNull() + expect(belegpfadAusUrl("https://example.org/?pfad=x")).toBeNull() + expect(belegpfadAusUrl(null)).toBeNull() + }) + + it("verträgt einen Aufruf ohne Pfad", async () => { + const { belegpfadAusUrl } = await import("./geteilterBeleg") + expect(belegpfadAusUrl("datametric360://beleg")).toBeNull() + expect(belegpfadAusUrl("datametric360://beleg?anderes=1")).toBeNull() + }) +}) + +describe("geteilten Beleg lesen", () => { beforeEach(() => { - ablage.clear() + dateien.clear() vi.resetModules() }) - it("liefert nichts, wenn keiner wartet", async () => { - const { geteiltenBelegAbholen } = await import("./geteilterBeleg") - expect(await geteiltenBelegAbholen()).toBeNull() + it("liefert nichts ohne Aufruf", async () => { + const { geteiltenBelegLesen } = await import("./geteilterBeleg") + expect(await geteiltenBelegLesen(null)).toBeNull() }) - it("baut aus dem Eintrag eine PDF-Datei", async () => { - ablegen({ name: "tankbeleg.pdf", base64: PDF_BASE64, zeit: Date.now() }) - const { geteiltenBelegAbholen } = await import("./geteilterBeleg") - - const datei = await geteiltenBelegAbholen() - + it("liest die Datei und gibt sie als PDF zurück", async () => { + dateien.set(PFAD, PDF_BASE64) + const { geteiltenBelegLesen } = await import("./geteilterBeleg") + const datei = await geteiltenBelegLesen(AUFRUF) expect(datei).not.toBeNull() - expect(datei!.name).toBe("tankbeleg.pdf") - expect(datei!.type).toBe("application/pdf") - expect(datei!.size).toBeGreaterThan(0) + expect(datei?.type).toBe("application/pdf") + expect(datei?.name).toBe("1756832400-beleg.pdf") + expect(datei?.size).toBeGreaterThan(0) }) - it("räumt den Eintrag weg, damit derselbe Beleg nicht zweimal ankommt", async () => { - ablegen({ name: "a.pdf", base64: PDF_BASE64, zeit: Date.now() }) - const { geteiltenBelegAbholen } = await import("./geteilterBeleg") - - expect(await geteiltenBelegAbholen()).not.toBeNull() - expect(await geteiltenBelegAbholen()).toBeNull() + /* Der Grund, warum überhaupt gelöscht wird: sonst käme derselbe Beleg bei + jedem Öffnen erneut, und weil der Upload im Backend einen Vorgang anlegt, + wären das echte Doppeleinträge. */ + it("räumt die Datei weg, holt sie also genau einmal", async () => { + dateien.set(PFAD, PDF_BASE64) + const { geteiltenBelegLesen } = await import("./geteilterBeleg") + expect(await geteiltenBelegLesen(AUFRUF)).not.toBeNull() + expect(dateien.has(PFAD)).toBe(false) + expect(await geteiltenBelegLesen(AUFRUF)).toBeNull() }) - it("verwirft einen liegengebliebenen Eintrag", async () => { - // Älter als zehn Minuten: das Öffnen der App aus der Erweiterung heraus - // ist damals fehlgeschlagen. Ihn jetzt als neuen Beleg zu behandeln wäre - // eine Überraschung, keine Hilfe. - ablegen({ name: "alt.pdf", base64: PDF_BASE64, zeit: Date.now() - 11 * 60 * 1000 }) - const { geteiltenBelegAbholen } = await import("./geteilterBeleg") - - expect(await geteiltenBelegAbholen()).toBeNull() - }) - - it("verwirft unlesbaren Inhalt, statt daran hängenzubleiben", async () => { - ablage.set(SCHLUESSEL, "{kein json") - const { geteiltenBelegAbholen } = await import("./geteilterBeleg") - - expect(await geteiltenBelegAbholen()).toBeNull() - // Auch der kaputte Eintrag ist weg - sonst liefe er bei jedem Start neu auf. - expect(ablage.has(SCHLUESSEL)).toBe(false) - }) - - it("nimmt einen Standardnamen, wenn keiner mitkam", async () => { - ablegen({ base64: PDF_BASE64, zeit: Date.now() }) - const { geteiltenBelegAbholen } = await import("./geteilterBeleg") - - expect((await geteiltenBelegAbholen())!.name).toBe("beleg.pdf") + it("verschluckt sich nicht an einer fehlenden Datei", async () => { + const { geteiltenBelegLesen } = await import("./geteilterBeleg") + expect(await geteiltenBelegLesen(AUFRUF)).toBeNull() + }) +}) + +describe("Übergabe an die Tanken-Seite", () => { + beforeEach(() => vi.resetModules()) + + it("meldet jeden Wechsel an die Horcher", async () => { + const { geteiltenBelegSetzen, geteiltenBelegStand, geteiltenBelegHorchen } = + await import("./geteilterBeleg") + let gerufen = 0 + const abmelden = geteiltenBelegHorchen(() => gerufen++) + const datei = new File([new Uint8Array([1])], "beleg.pdf", { type: "application/pdf" }) + + geteiltenBelegSetzen(datei) + expect(geteiltenBelegStand()).toBe(datei) + expect(gerufen).toBe(1) + + geteiltenBelegSetzen(null) + expect(geteiltenBelegStand()).toBeNull() + expect(gerufen).toBe(2) + + abmelden() + geteiltenBelegSetzen(datei) + expect(gerufen).toBe(2) }) }) diff --git a/companion-app/src/daten/geteilterBeleg.ts b/companion-app/src/daten/geteilterBeleg.ts index 32439b0..e82d6e0 100644 --- a/companion-app/src/daten/geteilterBeleg.ts +++ b/companion-app/src/daten/geteilterBeleg.ts @@ -1,54 +1,44 @@ /** * Belege, die über das iOS-Teilen-Blatt hereinkommen. * - * Die Gegenseite ist `native/teilen/ShareViewController.swift`: sie legt das - * geteilte PDF Base64-kodiert in den UserDefaults der App-Gruppe - * `group.app.datametric360` ab und öffnet danach die App über - * `datametric360://beleg`. + * Die Gegenseite ist `native/teilen/ShareViewController.swift`: sie schreibt + * das geteilte PDF in den Container der App-Gruppe `group.app.datametric360` + * und öffnet danach die App mit dem Pfad im Aufruf: * - * `@capacitor/preferences` liest genau diese UserDefaults, sobald man ihm die - * Gruppe nennt — deshalb braucht es hier kein eigenes Plugin. Ohne die Gruppe - * läse es den privaten Bereich der App und fände nie etwas. + * datametric360://beleg?pfad=file:///…/geteilte-belege/1756832400-beleg.pdf + * + * Diese Datei liest ihn über `@capacitor/filesystem`, das volle `file://`-Pfade + * ausdrücklich unterstützt (README: „Simply leave out the `directory` param to + * use a full file path"), und löscht die Datei danach. + * + * DER ERSTE ENTWURF GING ÜBER USERDEFAULTS UND HAT NIE FUNKTIONIERT + * ---------------------------------------------------------------- + * Er legte das PDF base64-kodiert unter `dm360_geteilter_beleg` in + * `UserDefaults(suiteName: gruppe)` ab und holte es hier mit + * `@capacitor/preferences` und `configure({ group })` wieder heraus — in der + * Annahme, das Plugin lese damit die UserDefaults der Gruppe. Das tut es + * nicht. Sein iOS-Code (8.0.1, `ios/Sources/PreferencesPlugin/Preferences.swift`) + * liest **immer** `UserDefaults.standard` der App; `group` setzt lediglich ein + * Schlüssel-Präfix. Gesucht wurde also + * `group.app.datametric360.dm360_geteilter_beleg` im eigenen Bereich der App, + * während der Eintrag unter dem blanken Schlüssel im Gruppen-Container lag: + * falscher Behälter und falscher Schlüssel. + * + * Sichtbar war davon nur, dass die App aufging und nichts passierte — gemeldet + * am 02.09.2026 („es wird kein Tankvorgang angelegt beim Teilen mit der App"). * * ABGEHOLT WIRD GENAU EINMAL * -------------------------- - * Der Eintrag wird nach dem Lesen gelöscht. Bliebe er stehen, käme derselbe - * Beleg bei jedem Zurückkehren in die App erneut — und weil der Upload beim - * Backend einen Vorgang anlegt, wären das echte Doppeleinträge, keine bloße - * Anzeigewiederholung. - * - * Der Zeitstempel ist die zweite Sicherung: ein Eintrag, der älter ist als - * HOECHSTALTER_MS, wird verworfen statt verarbeitet. Er kann nur entstehen, - * wenn das Öffnen der App aus der Erweiterung heraus fehlgeschlagen ist; ihn - * Tage später beim nächsten Start als „neuen" Beleg zu behandeln, wäre eine - * Überraschung, keine Hilfe. + * Die Datei wird nach dem Lesen gelöscht. Bliebe sie liegen, käme derselbe + * Beleg bei jedem Öffnen erneut — und weil der Upload beim Backend einen + * Vorgang anlegt, wären das echte Doppeleinträge. */ import { Capacitor } from "@capacitor/core" -import { Preferences } from "@capacitor/preferences" +import { Filesystem } from "@capacitor/filesystem" -/** Muss zu `gruppe` in ShareViewController.swift passen. */ -const GRUPPE = "group.app.datametric360" - -/** Muss zu `schluessel` in ShareViewController.swift passen. */ -const SCHLUESSEL = "dm360_geteilter_beleg" - -/** Zehn Minuten. Länger her heißt: das Öffnen der App hat nicht geklappt. */ -const HOECHSTALTER_MS = 10 * 60 * 1000 - -let eingerichtet = false - -async function gruppeEinrichten(): Promise { - if (eingerichtet) return - eingerichtet = true - try { - await Preferences.configure({ group: GRUPPE }) - } catch (fehler) { - // Auf Android und im Browser gibt es keine App-Gruppe; das ist kein - // Fehler, dort kommt über diesen Weg nur nie etwas an. - console.warn("Geteilter Beleg: Gruppe nicht einrichtbar", fehler) - } -} +/** Muss zum Schema in ShareViewController.swift passen. */ +const SCHEMA = "datametric360://beleg" function base64ZuDatei(base64: string, name: string): File { const roh = atob(base64) @@ -58,46 +48,58 @@ function base64ZuDatei(base64: string, name: string): File { } /** - * Holt einen wartenden Beleg ab und räumt ihn weg. `null`, wenn keiner da ist - * — der Normalfall bei jedem gewöhnlichen App-Start. + * Zieht den Belegpfad aus einem Aufruf der App. `null`, wenn der Aufruf ein + * anderer war (die App kann über dasselbe Schema auch anders geöffnet werden). */ -export async function geteiltenBelegAbholen(): Promise { - if (!Capacitor.isNativePlatform()) return null - await gruppeEinrichten() - - let roh: string | null = null +export function belegpfadAusUrl(url: string | null | undefined): string | null { + if (!url || !url.startsWith(SCHEMA)) return null try { - roh = (await Preferences.get({ key: SCHLUESSEL })).value - } catch (fehler) { - console.warn("Geteilter Beleg: nicht lesbar", fehler) - return null - } - if (!roh) return null - - // Erst wegräumen, dann auswerten: ein unlesbarer Eintrag darf nicht bei - // jedem Start erneut auflaufen. - try { - await Preferences.remove({ key: SCHLUESSEL }) + const frage = url.indexOf("?") + if (frage < 0) return null + const pfad = new URLSearchParams(url.slice(frage + 1)).get("pfad") + return pfad || null } catch { - // Bleibt er stehen, fängt ihn beim nächsten Mal die Altersprüfung ab. - } - - try { - const eintrag = JSON.parse(roh) as { name?: string; base64?: string; zeit?: number } - if (!eintrag.base64) return null - if (typeof eintrag.zeit === "number" && Date.now() - eintrag.zeit > HOECHSTALTER_MS) { - return null - } - return base64ZuDatei(eintrag.base64, eintrag.name || "beleg.pdf") - } catch (fehler) { - console.warn("Geteilter Beleg: unlesbarer Eintrag verworfen", fehler) return null } } +/** + * Liest den geteilten Beleg und räumt ihn weg. `null`, wenn der Aufruf keinen + * Pfad trug oder die Datei nicht lesbar war. + */ +export async function geteiltenBelegLesen(url: string | null | undefined): Promise { + if (!Capacitor.isNativePlatform()) return null + const pfad = belegpfadAusUrl(url) + if (!pfad) return null + + let base64: string + try { + const gelesen = await Filesystem.readFile({ path: pfad }) + base64 = typeof gelesen.data === "string" ? gelesen.data : "" + } catch (fehler) { + console.warn("Geteilter Beleg: Datei nicht lesbar", fehler) + return null + } + if (!base64) return null + + // Erst wegräumen, dann zurückgeben: schlägt das Hochladen später fehl, ist + // die Datei zwar weg - aber ein liegengebliebener Beleg, der bei jedem + // Öffnen erneut hochlädt, wäre der schlimmere Fehler. + try { + await Filesystem.deleteFile({ path: pfad }) + } catch { + // Halb so wild: der Name trägt einen Zeitstempel, ein Rest überschreibt + // nichts und wird nie wieder gelesen - die App kommt nur über einen + // frischen Aufruf an einen Pfad. + } + + const name = decodeURIComponent(pfad.split("/").pop() || "beleg.pdf") + return base64ZuDatei(base64, name) +} + /* ------------------------------------------------------------- Übergabe - Zwischen dem Abholen (App.tsx, beim Start und bei jeder Rückkehr) und dem + Zwischen dem Abholen (App.tsx, beim Start und bei jedem Aufruf) und dem Verarbeiten (Tanken.tsx) liegt ein Bildschirmwechsel. Ein kleiner geteilter Speicher nach dem Muster von theme.ts überbrückt ihn — ein Zustand, der außerhalb des Komponentenbaums lebt und den beide Seiten sehen. */ diff --git a/companion-app/src/daten/zwischenablage.ts b/companion-app/src/daten/zwischenablage.ts index dc1c50e..fe49bbc 100644 --- a/companion-app/src/daten/zwischenablage.ts +++ b/companion-app/src/daten/zwischenablage.ts @@ -15,10 +15,19 @@ * immer fehlschlägt, ist schlimmer als kein Knopf. Dort bleiben Ziehen und * Dateiauswahl. * - * Auf iOS ist es umgekehrt der Hauptweg: das PDF kommt aus Mail, wird dort - * kopiert, und das System zeigt beim Lesen seine eigene Einfügen-Abfrage. + * AUF DEM TELEFON LÄUFT ER SEIT DEM 02.09.2026 NATIV + * -------------------------------------------------- + * Dieselbe Grenze der Web-Schnittstelle traf nämlich auch iOS: ein aus Mail + * kopiertes PDF ist über `navigator.clipboard.read()` NIE zu bekommen, der + * Knopf endete dort zwangsläufig mit „In der Zwischenablage liegt kein PDF" + * (gemeldet vom Eigentümer). `UIPasteboard` kennt die Grenze nicht — deshalb + * geht die Anfrage auf dem Gerät über ein kleines eigenes Plugin + * (native/BelegZwischenablage.swift). Die Einfügen-Rückfrage des Systems + * erscheint dabei genauso wie im Web. */ +import { Capacitor, registerPlugin } from "@capacitor/core" + /** Nicht nur auf den MIME-Typ verlassen: eine aus dem Explorer/Finder kopierte oder gezogene Datei kommt je nach Browser und Quelle mit leerem `type` an. */ export function istPdf(datei: File | null | undefined): boolean { @@ -33,6 +42,21 @@ export function pdfAusDateiliste(dateien: FileList | File[] | null | undefined): export class ZwischenablageFehler extends Error {} +interface BelegZwischenablage { + /** Leeres Ergebnis heißt „nichts Passendes kopiert" - kein Fehler. */ + pdfHolen(): Promise<{ base64?: string; name?: string }> +} + +/** Muss zu `jsName` in BelegZwischenablage.swift passen. */ +const Nativ = registerPlugin("BelegZwischenablage") + +function base64ZuDatei(base64: string, name: string): File { + const roh = atob(base64) + const bytes = new Uint8Array(roh.length) + for (let i = 0; i < roh.length; i++) bytes[i] = roh.charCodeAt(i) + return new File([bytes], name, { type: "application/pdf" }) +} + /** * Holt ein PDF aus der Zwischenablage. * @@ -42,6 +66,25 @@ export class ZwischenablageFehler extends Error {} * nicht herausgibt. */ export async function pdfAusZwischenablage(): Promise { + if (Capacitor.isNativePlatform()) { + let ergebnis: { base64?: string; name?: string } + try { + ergebnis = await Nativ.pdfHolen() + } catch { + // Abgelehnte Einfügen-Rückfrage oder ein Gerät ohne das Plugin (eine + // .ipa von vor dem 02.09.2026). + throw new ZwischenablageFehler( + 'Kein Zugriff auf die Zwischenablage - bitte "Datei auswählen".', + ) + } + if (!ergebnis?.base64) { + throw new ZwischenablageFehler( + 'In der Zwischenablage liegt kein PDF - bitte den Beleg erneut kopieren oder "Datei auswählen".', + ) + } + return base64ZuDatei(ergebnis.base64, ergebnis.name || "beleg.pdf") + } + if (!navigator.clipboard?.read) { throw new ZwischenablageFehler( 'Dieser Browser gibt die Zwischenablage nicht heraus - bitte "Datei auswählen".', diff --git a/companion-app/src/screens/Tanken.tsx b/companion-app/src/screens/Tanken.tsx index 9329694..b17d9d5 100644 --- a/companion-app/src/screens/Tanken.tsx +++ b/companion-app/src/screens/Tanken.tsx @@ -15,6 +15,7 @@ import { useDaten } from "../daten/DatenKontext" import { nachJahrUndMonat } from "../daten/statistik" import { datum, de, eur, schnittpreis, summe } from "../format" import type { SeitenName } from "../navigation" +import { SymbolTanken } from "../symbole" import { geteiltenBelegHorchen, geteiltenBelegSetzen, @@ -139,7 +140,9 @@ export function Tanken({ geheZu }: { geheZu: (name: SeitenName, id?: string) => )} - {formularOffen && setzeFormularOffen(false)} />} + {formularOffen && ( + setzeFormularOffen(false)} geheZu={geheZu} /> + )} void }) { +function TankFormular({ + beiFertig, + geheZu, +}: { + beiFertig: () => void + geheZu: (name: SeitenName, id?: string) => void +}) { const { api, fahrzeug } = useDaten() const [zeitpunkt, setzeZeitpunkt] = useState("") const [liter, setzeLiter] = useState("") @@ -236,9 +245,28 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) { while (Date.now() < frist) { await new Promise((weiter) => setTimeout(weiter, 1200)) try { - const e = await api.rest.datenLesen( - ENTITAETEN.belegErgebnis, - ) + const e = await api.rest.datenLesen< + BelegEntwurf & { + fehler?: string + tank_id?: string | null + vorhandener_tank_id?: string | null + } + >(ENTITAETEN.belegErgebnis) + /* Der Beleg gehoert schon zu einem Tankvorgang (belege.py meldet ihn + als vorhandener_tank_id): dorthin springen. "Bereits erfasst" ohne + Ziel beantwortet die Frage nicht, die man in dem Moment hat. */ + if (e?.vorhandener_tank_id) { + geheZu("fill", e.vorhandener_tank_id) + return + } + /* In einen bestehenden Vorgang nachgetragen - dort steht das + Ergebnis. Vorher lief dieser Fall in die 20-Sekunden-Frist und + endete mit "Der Server hat den Beleg noch nicht zurueckgemeldet", + obwohl er laengst fertig war. */ + if (e?.tank_id) { + geheZu("fill", e.tank_id) + return + } if (e && e.tank_id == null) { if (e.fehler) { setzeFehler(e.fehler) @@ -296,7 +324,22 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) { ? { receipt_key: beleg.receipt_key ?? null, receipt_file: beleg.receipt_file ?? null } : {}), }) + /* Kam der Vorgang aus einem Beleg, gehoert der Nutzer danach in genau + diesen Vorgang - so hat er es sich gewuenscht (02.09.2026). Der Dienst + liefert keine ID zurueck, deshalb die frisch geschriebene Liste lesen + und den Eintrag am receipt_key wiedererkennen. Findet sich nichts, + bleibt es beim bisherigen Verhalten: Formular zu, Liste sichtbar. */ + const schluessel = mitBeleg && beleg ? beleg.receipt_key : null beiFertig() + if (schluessel) { + try { + const liste = await api.tankvorgaengeLesen() + const treffer = liste.find((t) => t.receipt_key === schluessel) + if (treffer) geheZu("fill", treffer.tank_id) + } catch { + // Die Liste ist gleich ohnehin da - dann eben ohne Sprung. + } + } } catch (ursache) { setzeFehler(ursache instanceof Error ? ursache.message : String(ursache)) } finally { @@ -307,6 +350,23 @@ function TankFormular({ beiFertig }: { beiFertig: () => void }) { return ( Neuer Tankvorgang + {/* Solange der Server den Beleg liest, steht hier, was passiert. + + Beim Teilen aus Mail springt die App von selbst hierher, und ohne + diese Zeile stand der Nutzer vor einem leeren Formular und wusste + nicht, ob etwas laeuft (Ansage des Eigentuemers, 02.09.2026: + Zapfsaeule, Kreisel, "Tankbeleg wird verarbeitet ..."). Das Lesen + dauert ueblicherweise ein bis drei Sekunden - lang genug, um ohne + Anzeige wie ein Haenger zu wirken. + + Nur in der App: das Teilen-Blatt gibt es im Panel nicht. */} + {belegLaeuft && ( +
+ +
+ )} None: vorhandener = await k.ablage.tankvorgang_nach_receipt_key(receipt_key) if vorhandener is not None and vorhandener.get("tank_id") != tank_id: _LOGGER.info("Beleg %s bereits vorhanden, übersprungen", receipt_key) + # vorhandener_tank_id zeigt auf den Tankvorgang, der den Beleg schon + # trägt. Die Oberflächen springen damit dorthin, statt nur "bereits + # erfasst" zu melden - beim Teilen aus Mail ist genau das die Frage, + # die der Nutzer hat: welcher Vorgang ist das denn? + # + # Bewusst ein EIGENES Feld statt tank_id umzudeuten: tank_id ist die + # Eingabe des Aufrufs (leer beim Neuanlegen, gesetzt beim Nachtragen), + # und das Panel entscheidet daran, wo es den Fehler anzeigt. await k.beleg_ergebnis_veroeffentlichen( - {"fehler": "Beleg bereits erfasst", "tank_id": tank_id} + { + "fehler": "Beleg bereits erfasst", + "tank_id": tank_id, + "vorhandener_tank_id": vorhandener.get("tank_id"), + } ) return diff --git a/custom_components/audi_dashboard/frontend/app/bundle.json b/custom_components/audi_dashboard/frontend/app/bundle.json index 962aa06..6bc3abd 100644 --- a/custom_components/audi_dashboard/frontend/app/bundle.json +++ b/custom_components/audi_dashboard/frontend/app/bundle.json @@ -1 +1 @@ -{"version":"2026.9.2.23","sha256":"a4b55f9576980ea8f52a5c7782bc0879ff7e4c6561e19f844c44dad903bb87c5","bytes":266680,"gebaut":"2026-09-02T19:03:48Z"} \ No newline at end of file +{"version":"2026.9.2.25","sha256":"106acbff40d3a14b59a1759f422e0d4346f29f196d0c3196c80289f6815d6804","bytes":266091,"gebaut":"2026-09-02T20:11:42Z"} \ No newline at end of file diff --git a/custom_components/audi_dashboard/frontend/app/bundle.zip b/custom_components/audi_dashboard/frontend/app/bundle.zip index 92049e4..89fda9a 100644 Binary files a/custom_components/audi_dashboard/frontend/app/bundle.zip and b/custom_components/audi_dashboard/frontend/app/bundle.zip differ diff --git a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js index f2f9e28..cf0835b 100644 --- a/custom_components/audi_dashboard/frontend/audi-dashboard-app.js +++ b/custom_components/audi_dashboard/frontend/audi-dashboard-app.js @@ -6072,6 +6072,15 @@ function belegErgebnisVerarbeiten(daten) { const kontext = daten.tank_id || belegZielPraefix; belegFehler = { context: kontext, meldung: daten.fehler }; if (!daten.tank_id) belegEntwurf = null; + /* Der Beleg gehoert schon zu einem Tankvorgang: dorthin springen, statt + den Nutzer mit "bereits erfasst" stehen zu lassen. Die Frage, die er + in dem Moment hat, ist ja "zu welchem denn?" - siehe belege.py. */ + if (daten.vorhandener_tank_id) { + belegFehler = { context: daten.vorhandener_tank_id, meldung: daten.fehler }; + belegEntwurf = null; + go("fill", daten.vorhandener_tank_id); + return; + } } else if (daten.tank_id) { // Direkt übernommener Beleg (Beleg nachtragen/ersetzen in der // Einzelbeleg-Ansicht) - die neuen Werte kommen bereits über diff --git a/custom_components/audi_dashboard/manifest.json b/custom_components/audi_dashboard/manifest.json index 07c3e2f..fa98a54 100644 --- a/custom_components/audi_dashboard/manifest.json +++ b/custom_components/audi_dashboard/manifest.json @@ -1,7 +1,7 @@ { "domain": "audi_dashboard", "name": "Audi Dashboard", - "version": "2026.9.2.23", + "version": "2026.9.2.25", "documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md", "issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues", "codeowners": ["@paul"],