((set, get) => ({
geladen: false,
initialisiere: async () => {
- const briefe = await ladeBriefe();
+ let briefe = await ladeBriefe();
+ // Auto-Löschen (Datensparsamkeit): abgelaufene Briefe beim Start entfernen
+ const aufbewahrungTage = await holeAutoLoeschTage();
+ const gefiltert = filtereAlteBriefe(briefe, aufbewahrungTage);
+ if (gefiltert.length !== briefe.length) {
+ briefe = gefiltert;
+ await speichereBriefe(briefe);
+ }
set({ briefe, geladen: true });
},
diff --git a/src/utils/__tests__/aufbewahrung.test.ts b/src/utils/__tests__/aufbewahrung.test.ts
new file mode 100644
index 0000000..c4bb998
--- /dev/null
+++ b/src/utils/__tests__/aufbewahrung.test.ts
@@ -0,0 +1,61 @@
+/**
+ * Tests fürs Auto-Löschen — löschen ist unumkehrbar, deshalb müssen die
+ * Grenzfälle sitzen (nichts zu früh löschen, "nie" respektieren).
+ */
+import { filtereAlteBriefe } from '../aufbewahrung';
+import { BriefEintrag } from '../../types';
+
+const JETZT = new Date('2026-07-15T12:00:00Z').getTime();
+const TAG = 86400000;
+
+function brief(id: string, alterInTagen: number): BriefEintrag {
+ return {
+ id,
+ erstelltAm: new Date(JETZT - alterInTagen * TAG).toISOString(),
+ analyse: {
+ brieftyp: 'Test',
+ absender: 'Testamt',
+ kernaussage: '',
+ erklaerung_einfach: '',
+ fachbegriffe: [],
+ frist: null,
+ termin: null,
+ checkliste: [],
+ antwort_noetig: false,
+ antwort_optionen: [],
+ },
+ uebersetzungen: {},
+ };
+}
+
+describe('filtereAlteBriefe', () => {
+ it('0 Tage = nie löschen, alles bleibt', () => {
+ const briefe = [brief('a', 1), brief('b', 500)];
+ expect(filtereAlteBriefe(briefe, 0, JETZT)).toHaveLength(2);
+ });
+
+ it('löscht Briefe, die älter als die Frist sind', () => {
+ const briefe = [brief('neu', 5), brief('alt', 40)];
+ const ergebnis = filtereAlteBriefe(briefe, 30, JETZT);
+ expect(ergebnis.map((b) => b.id)).toEqual(['neu']);
+ });
+
+ it('behält Briefe exakt am Grenzwert (30 Tage alt bei 30-Tage-Frist)', () => {
+ const briefe = [brief('grenze', 30)];
+ expect(filtereAlteBriefe(briefe, 30, JETZT)).toHaveLength(1);
+ });
+
+ it('löscht bei 90-Tage-Frist erst nach 90 Tagen', () => {
+ const briefe = [brief('a', 89), brief('b', 91)];
+ expect(filtereAlteBriefe(briefe, 90, JETZT).map((b) => b.id)).toEqual(['a']);
+ });
+
+ it('behält Briefe mit kaputtem Datum (lieber zu viel als zu wenig)', () => {
+ const kaputt = { ...brief('kaputt', 0), erstelltAm: 'kein-datum' };
+ expect(filtereAlteBriefe([kaputt], 30, JETZT)).toHaveLength(1);
+ });
+
+ it('leeres Archiv bleibt leer', () => {
+ expect(filtereAlteBriefe([], 30, JETZT)).toEqual([]);
+ });
+});
diff --git a/src/utils/__tests__/utf8.test.ts b/src/utils/__tests__/utf8.test.ts
new file mode 100644
index 0000000..353dd91
--- /dev/null
+++ b/src/utils/__tests__/utf8.test.ts
@@ -0,0 +1,42 @@
+/**
+ * Tests für die UTF-8-Helfer — kritisch für die Archiv-Verschlüsselung:
+ * Ein Kodierungsfehler würde gespeicherte Briefe unlesbar machen.
+ */
+import { utf8Kodieren, utf8Dekodieren } from '../utf8';
+
+describe('utf8Kodieren / utf8Dekodieren', () => {
+ const rundreise = (text: string) => utf8Dekodieren(utf8Kodieren(text));
+
+ it('einfaches ASCII', () => {
+ expect(rundreise('Hallo Welt 123')).toBe('Hallo Welt 123');
+ });
+
+ it('deutsche Umlaute und ß', () => {
+ expect(rundreise('Bescheid über Bürgergeld gemäß § 32 — Änderung')).toBe(
+ 'Bescheid über Bürgergeld gemäß § 32 — Änderung'
+ );
+ });
+
+ it('arabisch, ukrainisch, türkisch (Übersetzungssprachen)', () => {
+ const text = 'العربية · Українська · Türkçe İĞÜŞÖÇ';
+ expect(rundreise(text)).toBe(text);
+ });
+
+ it('Emoji (Surrogate-Paare)', () => {
+ expect(rundreise('Ampel: 🔴🟡🟢 📬')).toBe('Ampel: 🔴🟡🟢 📬');
+ });
+
+ it('JSON-Sonderzeichen', () => {
+ const json = JSON.stringify({ a: 'Zeile\nUmbruch "Anführung" \\ Slash' });
+ expect(rundreise(json)).toBe(json);
+ });
+
+ it('leerer String', () => {
+ expect(rundreise('')).toBe('');
+ });
+
+ it('kodiert Umlaute als Mehrbyte-Sequenzen (echtes UTF-8)', () => {
+ // "ü" ist in UTF-8 die Bytefolge 0xC3 0xBC
+ expect(Array.from(utf8Kodieren('ü'))).toEqual([0xc3, 0xbc]);
+ });
+});
diff --git a/src/utils/aufbewahrung.ts b/src/utils/aufbewahrung.ts
new file mode 100644
index 0000000..d996d4b
--- /dev/null
+++ b/src/utils/aufbewahrung.ts
@@ -0,0 +1,27 @@
+/**
+ * Auto-Löschen alter Briefe (Datensparsamkeit, DSGVO-Grundsatz).
+ * Reine Funktion — die Anwendung passiert beim App-Start im Store.
+ */
+import { BriefEintrag } from '../types';
+
+/**
+ * Filtert Briefe heraus, die älter als die Aufbewahrungsdauer sind.
+ *
+ * @param briefe - das aktuelle Archiv
+ * @param aufbewahrungTage - 0 = nie löschen (alles behalten)
+ * @param jetztMs - aktueller Zeitpunkt (injizierbar für Tests)
+ */
+export function filtereAlteBriefe(
+ briefe: BriefEintrag[],
+ aufbewahrungTage: number,
+ jetztMs: number = Date.now()
+): BriefEintrag[] {
+ if (aufbewahrungTage <= 0) return briefe;
+ const grenzeMs = jetztMs - aufbewahrungTage * 86400000;
+ return briefe.filter((brief) => {
+ const erstellt = new Date(brief.erstelltAm).getTime();
+ // Unlesbare Datumswerte lieber behalten als versehentlich löschen
+ if (!Number.isFinite(erstellt)) return true;
+ return erstellt >= grenzeMs;
+ });
+}
diff --git a/src/utils/utf8.ts b/src/utils/utf8.ts
new file mode 100644
index 0000000..1ef5963
--- /dev/null
+++ b/src/utils/utf8.ts
@@ -0,0 +1,33 @@
+/**
+ * UTF-8-Kodierung ohne Plattform-Abhängigkeiten.
+ *
+ * Warum selbst gebaut: expo-crypto akzeptiert Klartext nur als Uint8Array
+ * (oder base64-String), und TextEncoder/TextDecoder sind in Hermes nicht
+ * zuverlässig über alle Versionen verfügbar. Der encodeURIComponent-Trick
+ * ist korrekt für alle Unicode-Zeichen (Umlaute, Arabisch, Emoji —
+ * inklusive Surrogate-Paaren) und in Jest testbar.
+ */
+
+/** JavaScript-String -> UTF-8-Bytes. */
+export function utf8Kodieren(text: string): Uint8Array {
+ const prozentKodiert = encodeURIComponent(text);
+ const bytes: number[] = [];
+ for (let i = 0; i < prozentKodiert.length; i++) {
+ if (prozentKodiert[i] === '%') {
+ bytes.push(parseInt(prozentKodiert.slice(i + 1, i + 3), 16));
+ i += 2;
+ } else {
+ bytes.push(prozentKodiert.charCodeAt(i));
+ }
+ }
+ return new Uint8Array(bytes);
+}
+
+/** UTF-8-Bytes -> JavaScript-String. Wirft bei ungültigem UTF-8 (URIError). */
+export function utf8Dekodieren(bytes: Uint8Array): string {
+ let prozentKodiert = '';
+ for (const byte of bytes) {
+ prozentKodiert += '%' + byte.toString(16).padStart(2, '0');
+ }
+ return decodeURIComponent(prozentKodiert);
+}
diff --git a/webseite/datenschutz.html b/webseite/datenschutz.html
index c99edfa..e3bec15 100644
--- a/webseite/datenschutz.html
+++ b/webseite/datenschutz.html
@@ -76,8 +76,12 @@
b) Analyse-Ergebnisse, Übersetzungen, Antwort-Entwürfe
- Alle Ergebnisse werden ausschließlich lokal auf Ihrem Gerät gespeichert.
- Wir betreiben keine Nutzerkonten und keine Cloud-Speicherung Ihrer Briefe.
+ Alle Ergebnisse werden ausschließlich lokal auf Ihrem Gerät gespeichert —
+ zusätzlich verschlüsselt (AES-256-GCM; der Schlüssel liegt im
+ Sicherheitsspeicher Ihres Geräts). Wir betreiben keine Nutzerkonten und
+ keine Cloud-Speicherung Ihrer Briefe. Optional können Sie in den
+ Einstellungen eine App-Sperre (Face ID / Geräte-Code) aktivieren und
+ Briefe nach 30 oder 90 Tagen automatisch löschen lassen.
c) Anonyme Geräte-Kennung