Compare commits

...

34 Commits

Author SHA1 Message Date
niklas.ortmann dff64be964 Merge pull request 'Inhalte4' (#22) from dev-Inhalte4 into dev
Reviewed-on: #22
2026-10-17 15:56:58 +02:00
niklas.ortmann 7d48b4c838 Inhalte4
Änderungswünsche
2026-10-17 15:56:03 +02:00
niklas.ortmann af8fa9bf9e README.md aktualisiert 2026-10-15 17:54:19 +02:00
niklas.ortmann 58f1e6fb82 Merge pull request 'QA006' (#20) from dev-QA006 into dev
Reviewed-on: #20
2026-10-15 17:41:47 +02:00
NOrtmann1 58430a0903 QA006
Datenschutz:
Überprüfung der Anwendung hinsichtlich Datenschutz und Datensicherheit.

    Keine unnötigen Berechtigungen
    Keine sensiblen Logs
    Sichere Dateispeicherung
    Backup-Verhalten geprüft
    Datenschutz-Dokumentation erstellt
    usw.
2026-10-15 17:40:35 +02:00
niklas.ortmann 742ef39f05 Merge pull request 'Fehlerbehandlung' (#19) from dev-QA004 into dev
Reviewed-on: #19
2026-10-14 17:14:48 +02:00
NOrtmann1 489c4fa070 Fehlerbehandlung
Robuste Behandlung aller bekannten Fehlerfälle.
- Speichermangel behandelt
- Bildfehler behandelt
- PDF-Fehler behandelt
- Datenverlust verhindert
2026-10-14 17:14:05 +02:00
niklas.ortmann e9403801d6 Merge pull request 'Kamera-Button + neue Inhalte' (#17) from dev-Inhalte3 into dev
Reviewed-on: #17
2026-10-12 01:58:20 +02:00
NOrtmann1 18a7af800f Kamera-Button + neue Inhalte 2026-10-12 01:57:56 +02:00
niklas.ortmann 51370a9172 Merge pull request 'QA-001' (#15) from dev-QA001 into dev
Reviewed-on: #15
2026-10-09 10:02:08 +02:00
niklas.ortmann 26cc81388e Update PatientRepositoryTest.kt 2026-10-09 10:01:32 +02:00
niklas.ortmann dcfa11bef9 QA-001
- [x] Dateinamen-Tests vorhanden
- [x] Fortsetzen-Tests vorhanden
- [x] Blockstatus-Tests vorhanden
- [x] Repository-Tests vorhanden
2026-10-08 12:15:31 +02:00
niklas.ortmann e6bc99d144 Update DocumentationEditScreen.kt 2026-10-08 00:22:06 +02:00
niklas.ortmann 2bc0d959e1 README.md aktualisiert 2026-10-07 11:49:41 +02:00
niklas.ortmann b77f7f0f20 README.md aktualisiert 2026-10-07 11:48:14 +02:00
niklas.ortmann 70bdc84cfa README.md aktualisiert 2026-10-07 11:47:54 +02:00
niklas.ortmann cefecb86dd README.md aktualisiert 2026-10-07 11:44:59 +02:00
niklas.ortmann 1fdcceb149 Merge pull request 'neue Boxinhalte nach Kundenwunsch' (#10) from dev-Inhalte2 into dev
Reviewed-on: #10
2026-10-03 00:35:27 +02:00
niklas.ortmann 8a430d5089 neue Boxinhalte nach Kundenwunsch 2026-10-03 00:34:59 +02:00
niklas.ortmann 0c7eaabbb7 Merge pull request 'MEDIA-005' (#9) from dev-MEDIA-005 into dev
Reviewed-on: #9
2026-09-27 17:28:09 +02:00
niklas.ortmann 63bc93ebd2 MEDIA-005
Export fertig
2026-09-27 17:27:14 +02:00
niklas.ortmann 0f4ce2d2e1 Merge pull request 'MEDIA-005' (#8) from dev-MEDIA-005 into dev
Reviewed-on: #8
2026-09-24 16:49:06 +02:00
niklas.ortmann 08091a15a2 MEDIA-005
JPG-Export
2026-09-24 13:28:22 +02:00
niklas.ortmann 4a62d195ac Merge pull request 'Box-Inhalte' (#7) from dev-Inhalte into dev
Reviewed-on: #7
2026-09-24 13:13:23 +02:00
niklas.ortmann 075905c6e0 Update DocumentationCatalog.kt 2026-09-16 10:08:29 +02:00
niklas.ortmann 7948d2c3c6 Box-Inhalte
(müssen mit Kunden besprochen werden)
2026-09-09 08:44:07 +02:00
niklas.ortmann 2ddc1d960a Merge pull request 'Kundenwuensche' (#6) from dev-klock into dev
Reviewed-on: #6
2026-09-07 11:42:06 +02:00
niklas.ortmann 32d23f5d52 Kundenwuensche
Nur angehakte Checkboxen im PDF
Fotos in Originalgröße per Antippen
Freitext zuerst in jedem Block
Akkordeon-Umbau (angefangen)
2026-09-07 09:46:22 +02:00
niklas.ortmann cda0b4d3fe Merge pull request 'PDF/MEDIA' (#5) from dev-PDF into dev
Reviewed-on: #5
2026-09-06 21:20:29 +02:00
niklas.ortmann c52229b7f7 PDF/MEDIA
MEDIA-003
MEDIA-004
PDF-003
2026-09-06 21:19:58 +02:00
niklas.ortmann 91e68c827a PDF-004 2026-08-27 08:10:18 +02:00
niklas.ortmann 8c05a513fe PDF-005 2026-08-25 09:50:31 +02:00
niklas.ortmann ebf85df188 PDF-001 & PDF-002 (ungetestet) 2026-08-25 01:27:29 +02:00
niklas.ortmann 81178cbbc2 Merge pull request 'MEDIA-001 & MEDIA-002' (#4) from dev-MEDIA-001 into dev
Reviewed-on: #4
2026-08-23 23:00:00 +02:00
77 changed files with 13515 additions and 322 deletions
+42 -82
View File
@@ -7,7 +7,7 @@ Die App ermöglicht die Verwaltung von Patienten und Wunddokumentationen,
unterstützt Fotos und digitale Unterschriften und erzeugt aus unterstützt Fotos und digitale Unterschriften und erzeugt aus
abgeschlossenen Dokumentationen PDF-Dateien. abgeschlossenen Dokumentationen PDF-Dateien.
> **Projektstatus:** In Entwicklung > **Projektstatus:** beta1.4
------------------------------------------------------------------------ ------------------------------------------------------------------------
@@ -344,32 +344,6 @@ werden kann.
- JDK über Android Studio - JDK über Android Studio
- Android-Gerät oder Android Emulator - Android-Gerät oder Android Emulator
### Projekt öffnen
Repository klonen:
``` bash
git clone <repository-url>
cd VulneraDoc
```
Anschließend den Projektordner in Android Studio öffnen.
Android Studio führt beim ersten Öffnen einen Gradle-Sync durch und lädt
die benötigten Dependencies.
Danach:
``` text
Gradle Sync
↓
Build / Make Project
↓
Android-Gerät oder Emulator auswählen
↓
Run
```
------------------------------------------------------------------------ ------------------------------------------------------------------------
## Projektphasen ## Projektphasen
@@ -387,73 +361,59 @@ Run
### Phase 2 -- Projektgrundgerüst ### Phase 2 -- Projektgrundgerüst
- [x] Android-Projekt - [x] Android-Projekt
- [ ] Room-Datenbank - [x] Room-Datenbank
- [ ] Entities - [x] Entities
- [ ] DAOs - [x] DAOs
- [ ] Repositories - [x] Repositories
- [ ] Navigation - [x] Navigation
- [ ] ViewModels - [x] ViewModels
### Phase 3 -- Kernfunktionalität ### Phase 3 -- Kernfunktionalität
- [ ] Patientenverwaltung - [x] Patientenverwaltung
- [ ] Patientenliste - [x] Patientenliste
- [ ] Wunddokumentationsliste - [x] Wunddokumentationsliste
- [ ] Dokumentation erstellen - [x] Dokumentation erstellen
- [ ] Dokumentationsblöcke - [x] Dokumentationsblöcke
- [ ] Blocknavigation - [x] Blocknavigation
- [ ] Autosave - [x] Autosave
- [ ] Dokumentation bearbeiten - [x] Dokumentation bearbeiten
- [ ] Dokumentation fortsetzen - [x] Dokumentation fortsetzen
### Phase 4 -- Medien ### Phase 4 -- Medien
- [ ] Fotoimport - [x] Fotoimport
- [ ] Bildverwaltung - [x] Bildverwaltung
- [ ] Digitale Signatur - [x] Digitale Signatur
- [ ] Mehrere Signaturen - [x] Mehrere Signaturen
### Phase 5 -- PDF ### Phase 5 -- PDF
- [ ] PDF-Exporter - [x] PDF-Exporter
- [ ] Dokumentationsblöcke - [x] Dokumentationsblöcke
- [ ] Signaturen - [x] Signaturen
- [ ] Fotoanhang - [x] Fotoanhang
- [ ] PDF anzeigen - [x] PDF anzeigen
- [ ] PDF teilen - [x] PDF teilen
### Phase 6 -- Qualität und Stabilität ### Phase 6 -- Qualität und Stabilität
- [ ] Unit Tests - [x] Unit Tests
- [ ] Room Tests - [x] Room Tests
- [ ] UI Tests - [x] UI Tests
- [ ] Fehlerbehandlung - [x] Fehlerbehandlung
- [ ] Tablet-Optimierung - [ ] Tablet-Optimierung
- [ ] Datenschutz-Review - [x] Datenschutz-Review
------------------------------------------------------------------------ ### Phase 7 -- Multi-Device-Synchronisation
## Dokumentation - [ ] Geräteidentität und Pairing
- [ ] Automatische Geräteerkennung im WLAN
Die ausführliche technische Architektur befindet sich in der - [ ] Differenzabgleich / Sync-Protokoll
Projektdokumentation: - [ ] Patienten synchronisieren
- [ ] Wunddokumentationen synchronisieren
``` text - [ ] Fotos synchronisieren
docs/ - [ ] Konflikterkennung und Konfliktauflösung
└── Architekturplanung.md - [ ] Automatische Synchronisation
``` - [ ] Sync-Status und manueller Fallback
- [ ] Multi-Device- und Recovery-Tests
Dort werden unter anderem Datenmodell, Datenbankschema,
Navigationskonzept, Autosave, PDF-Erzeugung und Medienverwaltung
detailliert beschrieben.
------------------------------------------------------------------------
## Status
**VulneraDoc befindet sich aktuell in Entwicklung und ist nicht für den
produktiven Einsatz freigegeben.**
Insbesondere bei der Verarbeitung realer Patientendaten müssen vor einem
Produktiveinsatz die rechtlichen, datenschutzrechtlichen und
sicherheitstechnischen Anforderungen abschließend geprüft werden.
@@ -0,0 +1,585 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "cd000019287c8c50d6b8d1b239aa12f0",
"entities": [
{
"tableName": "patients",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `birthDate` TEXT NOT NULL, `street` TEXT NOT NULL, `postalCode` TEXT NOT NULL, `city` TEXT NOT NULL, `phone1` TEXT NOT NULL, `phone2` TEXT, `phone3` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "firstName",
"columnName": "firstName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastName",
"columnName": "lastName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "birthDate",
"columnName": "birthDate",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "street",
"columnName": "street",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "postalCode",
"columnName": "postalCode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "city",
"columnName": "city",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "phone1",
"columnName": "phone1",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "phone2",
"columnName": "phone2",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "phone3",
"columnName": "phone3",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_patients_lastName_firstName",
"unique": false,
"columnNames": [
"lastName",
"firstName"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_lastName_firstName` ON `${TABLE_NAME}` (`lastName`, `firstName`)"
},
{
"name": "index_patients_birthDate",
"unique": false,
"columnNames": [
"birthDate"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_birthDate` ON `${TABLE_NAME}` (`birthDate`)"
}
],
"foreignKeys": []
},
{
"tableName": "documentations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `patientId` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `continuedFromDocumentationId` TEXT, `pdfLastGeneratedAt` INTEGER, `catalogVersion` INTEGER NOT NULL, `patientFirstNameSnapshot` TEXT NOT NULL, `patientLastNameSnapshot` TEXT NOT NULL, `patientBirthDateSnapshot` TEXT NOT NULL, `patientStreetSnapshot` TEXT NOT NULL, `patientPostalCodeSnapshot` TEXT NOT NULL, `patientCitySnapshot` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`patientId`) REFERENCES `patients`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT , FOREIGN KEY(`continuedFromDocumentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientId",
"columnName": "patientId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "continuedFromDocumentationId",
"columnName": "continuedFromDocumentationId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pdfLastGeneratedAt",
"columnName": "pdfLastGeneratedAt",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "catalogVersion",
"columnName": "catalogVersion",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "patientFirstNameSnapshot",
"columnName": "patientFirstNameSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientLastNameSnapshot",
"columnName": "patientLastNameSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientBirthDateSnapshot",
"columnName": "patientBirthDateSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientStreetSnapshot",
"columnName": "patientStreetSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientPostalCodeSnapshot",
"columnName": "patientPostalCodeSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientCitySnapshot",
"columnName": "patientCitySnapshot",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_documentations_patientId_createdAt",
"unique": false,
"columnNames": [
"patientId",
"createdAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_patientId_createdAt` ON `${TABLE_NAME}` (`patientId`, `createdAt`)"
},
{
"name": "index_documentations_status",
"unique": false,
"columnNames": [
"status"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_status` ON `${TABLE_NAME}` (`status`)"
},
{
"name": "index_documentations_continuedFromDocumentationId",
"unique": false,
"columnNames": [
"continuedFromDocumentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_continuedFromDocumentationId` ON `${TABLE_NAME}` (`continuedFromDocumentationId`)"
}
],
"foreignKeys": [
{
"table": "patients",
"onDelete": "RESTRICT",
"onUpdate": "NO ACTION",
"columns": [
"patientId"
],
"referencedColumns": [
"id"
]
},
{
"table": "documentations",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"continuedFromDocumentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "block_states",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `blockDefinitionId` TEXT NOT NULL, `titleSnapshot` TEXT NOT NULL, `blockOrder` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockDefinitionId",
"columnName": "blockDefinitionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "titleSnapshot",
"columnName": "titleSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockOrder",
"columnName": "blockOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_block_states_documentationId",
"unique": false,
"columnNames": [
"documentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_block_states_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
},
{
"name": "index_block_states_documentationId_blockDefinitionId",
"unique": true,
"columnNames": [
"documentationId",
"blockDefinitionId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_block_states_documentationId_blockDefinitionId` ON `${TABLE_NAME}` (`documentationId`, `blockDefinitionId`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "check_item_states",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blockStateId` TEXT NOT NULL, `checkItemDefinitionId` TEXT NOT NULL, `labelSnapshot` TEXT NOT NULL, `itemOrder` INTEGER NOT NULL, `checked` INTEGER NOT NULL, `isFreeText` INTEGER NOT NULL, `freeTextValue` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`blockStateId`) REFERENCES `block_states`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockStateId",
"columnName": "blockStateId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "checkItemDefinitionId",
"columnName": "checkItemDefinitionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "labelSnapshot",
"columnName": "labelSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemOrder",
"columnName": "itemOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "checked",
"columnName": "checked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isFreeText",
"columnName": "isFreeText",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "freeTextValue",
"columnName": "freeTextValue",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_check_item_states_blockStateId",
"unique": false,
"columnNames": [
"blockStateId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_check_item_states_blockStateId` ON `${TABLE_NAME}` (`blockStateId`)"
},
{
"name": "index_check_item_states_blockStateId_checkItemDefinitionId",
"unique": true,
"columnNames": [
"blockStateId",
"checkItemDefinitionId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_check_item_states_blockStateId_checkItemDefinitionId` ON `${TABLE_NAME}` (`blockStateId`, `checkItemDefinitionId`)"
}
],
"foreignKeys": [
{
"table": "block_states",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"blockStateId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "photos",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileName",
"columnName": "fileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "orderIndex",
"columnName": "orderIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_photos_documentationId_orderIndex",
"unique": false,
"columnNames": [
"documentationId",
"orderIndex"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_photos_documentationId_orderIndex` ON `${TABLE_NAME}` (`documentationId`, `orderIndex`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "signatures",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `signerName` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signerName",
"columnName": "signerName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileName",
"columnName": "fileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "orderIndex",
"columnName": "orderIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_signatures_documentationId",
"unique": false,
"columnNames": [
"documentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cd000019287c8c50d6b8d1b239aa12f0')"
]
}
}
@@ -0,0 +1,623 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "182fcff50c0191c9414187dd7b7cacd2",
"entities": [
{
"tableName": "patients",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `birthDate` TEXT NOT NULL, `street` TEXT NOT NULL, `postalCode` TEXT NOT NULL, `city` TEXT NOT NULL, `phone1` TEXT NOT NULL, `phone2` TEXT, `phone3` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "firstName",
"columnName": "firstName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastName",
"columnName": "lastName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "birthDate",
"columnName": "birthDate",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "street",
"columnName": "street",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "postalCode",
"columnName": "postalCode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "city",
"columnName": "city",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "phone1",
"columnName": "phone1",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "phone2",
"columnName": "phone2",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "phone3",
"columnName": "phone3",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_patients_lastName_firstName",
"unique": false,
"columnNames": [
"lastName",
"firstName"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_lastName_firstName` ON `${TABLE_NAME}` (`lastName`, `firstName`)"
},
{
"name": "index_patients_birthDate",
"unique": false,
"columnNames": [
"birthDate"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_birthDate` ON `${TABLE_NAME}` (`birthDate`)"
}
],
"foreignKeys": []
},
{
"tableName": "documentations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `patientId` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `continuedFromDocumentationId` TEXT, `pdfLastGeneratedAt` INTEGER, `catalogVersion` INTEGER NOT NULL, `patientFirstNameSnapshot` TEXT NOT NULL, `patientLastNameSnapshot` TEXT NOT NULL, `patientBirthDateSnapshot` TEXT NOT NULL, `patientStreetSnapshot` TEXT NOT NULL, `patientPostalCodeSnapshot` TEXT NOT NULL, `patientCitySnapshot` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`patientId`) REFERENCES `patients`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT , FOREIGN KEY(`continuedFromDocumentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientId",
"columnName": "patientId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "continuedFromDocumentationId",
"columnName": "continuedFromDocumentationId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pdfLastGeneratedAt",
"columnName": "pdfLastGeneratedAt",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "catalogVersion",
"columnName": "catalogVersion",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "patientFirstNameSnapshot",
"columnName": "patientFirstNameSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientLastNameSnapshot",
"columnName": "patientLastNameSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientBirthDateSnapshot",
"columnName": "patientBirthDateSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientStreetSnapshot",
"columnName": "patientStreetSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientPostalCodeSnapshot",
"columnName": "patientPostalCodeSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientCitySnapshot",
"columnName": "patientCitySnapshot",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_documentations_patientId_createdAt",
"unique": false,
"columnNames": [
"patientId",
"createdAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_patientId_createdAt` ON `${TABLE_NAME}` (`patientId`, `createdAt`)"
},
{
"name": "index_documentations_status",
"unique": false,
"columnNames": [
"status"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_status` ON `${TABLE_NAME}` (`status`)"
},
{
"name": "index_documentations_continuedFromDocumentationId",
"unique": false,
"columnNames": [
"continuedFromDocumentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_continuedFromDocumentationId` ON `${TABLE_NAME}` (`continuedFromDocumentationId`)"
}
],
"foreignKeys": [
{
"table": "patients",
"onDelete": "RESTRICT",
"onUpdate": "NO ACTION",
"columns": [
"patientId"
],
"referencedColumns": [
"id"
]
},
{
"table": "documentations",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"continuedFromDocumentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "block_states",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `blockDefinitionId` TEXT NOT NULL, `titleSnapshot` TEXT NOT NULL, `blockOrder` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockDefinitionId",
"columnName": "blockDefinitionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "titleSnapshot",
"columnName": "titleSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockOrder",
"columnName": "blockOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_block_states_documentationId",
"unique": false,
"columnNames": [
"documentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_block_states_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
},
{
"name": "index_block_states_documentationId_blockDefinitionId",
"unique": true,
"columnNames": [
"documentationId",
"blockDefinitionId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_block_states_documentationId_blockDefinitionId` ON `${TABLE_NAME}` (`documentationId`, `blockDefinitionId`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "check_item_states",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blockStateId` TEXT NOT NULL, `checkItemDefinitionId` TEXT NOT NULL, `labelSnapshot` TEXT NOT NULL, `itemOrder` INTEGER NOT NULL, `checked` INTEGER NOT NULL, `isFreeText` INTEGER NOT NULL, `freeTextValue` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`blockStateId`) REFERENCES `block_states`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockStateId",
"columnName": "blockStateId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "checkItemDefinitionId",
"columnName": "checkItemDefinitionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "labelSnapshot",
"columnName": "labelSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemOrder",
"columnName": "itemOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "checked",
"columnName": "checked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isFreeText",
"columnName": "isFreeText",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "freeTextValue",
"columnName": "freeTextValue",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_check_item_states_blockStateId",
"unique": false,
"columnNames": [
"blockStateId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_check_item_states_blockStateId` ON `${TABLE_NAME}` (`blockStateId`)"
},
{
"name": "index_check_item_states_blockStateId_checkItemDefinitionId",
"unique": true,
"columnNames": [
"blockStateId",
"checkItemDefinitionId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_check_item_states_blockStateId_checkItemDefinitionId` ON `${TABLE_NAME}` (`blockStateId`, `checkItemDefinitionId`)"
}
],
"foreignKeys": [
{
"table": "block_states",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"blockStateId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "photos",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileName",
"columnName": "fileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "orderIndex",
"columnName": "orderIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_photos_documentationId_orderIndex",
"unique": false,
"columnNames": [
"documentationId",
"orderIndex"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_photos_documentationId_orderIndex` ON `${TABLE_NAME}` (`documentationId`, `orderIndex`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "signatures",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `signerName` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signerName",
"columnName": "signerName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileName",
"columnName": "fileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "orderIndex",
"columnName": "orderIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_signatures_documentationId",
"unique": false,
"columnNames": [
"documentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "wound_carers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `signatureFileName` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signatureFileName",
"columnName": "signatureFileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '182fcff50c0191c9414187dd7b7cacd2')"
]
}
}
@@ -0,0 +1,629 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "12448571f5ffcf00398fa1d8260c9b2b",
"entities": [
{
"tableName": "patients",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `birthDate` TEXT NOT NULL, `street` TEXT NOT NULL, `postalCode` TEXT NOT NULL, `city` TEXT NOT NULL, `phone1` TEXT NOT NULL, `phone2` TEXT, `phone3` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "firstName",
"columnName": "firstName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastName",
"columnName": "lastName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "birthDate",
"columnName": "birthDate",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "street",
"columnName": "street",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "postalCode",
"columnName": "postalCode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "city",
"columnName": "city",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "phone1",
"columnName": "phone1",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "phone2",
"columnName": "phone2",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "phone3",
"columnName": "phone3",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_patients_lastName_firstName",
"unique": false,
"columnNames": [
"lastName",
"firstName"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_lastName_firstName` ON `${TABLE_NAME}` (`lastName`, `firstName`)"
},
{
"name": "index_patients_birthDate",
"unique": false,
"columnNames": [
"birthDate"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_birthDate` ON `${TABLE_NAME}` (`birthDate`)"
}
],
"foreignKeys": []
},
{
"tableName": "documentations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `patientId` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `continuedFromDocumentationId` TEXT, `pdfLastGeneratedAt` INTEGER, `catalogVersion` INTEGER NOT NULL, `patientFirstNameSnapshot` TEXT NOT NULL, `patientLastNameSnapshot` TEXT NOT NULL, `patientBirthDateSnapshot` TEXT NOT NULL, `patientStreetSnapshot` TEXT NOT NULL, `patientPostalCodeSnapshot` TEXT NOT NULL, `patientCitySnapshot` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`patientId`) REFERENCES `patients`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT , FOREIGN KEY(`continuedFromDocumentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientId",
"columnName": "patientId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "continuedFromDocumentationId",
"columnName": "continuedFromDocumentationId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pdfLastGeneratedAt",
"columnName": "pdfLastGeneratedAt",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "catalogVersion",
"columnName": "catalogVersion",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "patientFirstNameSnapshot",
"columnName": "patientFirstNameSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientLastNameSnapshot",
"columnName": "patientLastNameSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientBirthDateSnapshot",
"columnName": "patientBirthDateSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientStreetSnapshot",
"columnName": "patientStreetSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientPostalCodeSnapshot",
"columnName": "patientPostalCodeSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientCitySnapshot",
"columnName": "patientCitySnapshot",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_documentations_patientId_createdAt",
"unique": false,
"columnNames": [
"patientId",
"createdAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_patientId_createdAt` ON `${TABLE_NAME}` (`patientId`, `createdAt`)"
},
{
"name": "index_documentations_status",
"unique": false,
"columnNames": [
"status"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_status` ON `${TABLE_NAME}` (`status`)"
},
{
"name": "index_documentations_continuedFromDocumentationId",
"unique": false,
"columnNames": [
"continuedFromDocumentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_continuedFromDocumentationId` ON `${TABLE_NAME}` (`continuedFromDocumentationId`)"
}
],
"foreignKeys": [
{
"table": "patients",
"onDelete": "RESTRICT",
"onUpdate": "NO ACTION",
"columns": [
"patientId"
],
"referencedColumns": [
"id"
]
},
{
"table": "documentations",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"continuedFromDocumentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "block_states",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `blockDefinitionId` TEXT NOT NULL, `titleSnapshot` TEXT NOT NULL, `blockOrder` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockDefinitionId",
"columnName": "blockDefinitionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "titleSnapshot",
"columnName": "titleSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockOrder",
"columnName": "blockOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_block_states_documentationId",
"unique": false,
"columnNames": [
"documentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_block_states_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
},
{
"name": "index_block_states_documentationId_blockDefinitionId",
"unique": true,
"columnNames": [
"documentationId",
"blockDefinitionId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_block_states_documentationId_blockDefinitionId` ON `${TABLE_NAME}` (`documentationId`, `blockDefinitionId`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "check_item_states",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blockStateId` TEXT NOT NULL, `checkItemDefinitionId` TEXT NOT NULL, `labelSnapshot` TEXT NOT NULL, `itemOrder` INTEGER NOT NULL, `checked` INTEGER NOT NULL, `isFreeText` INTEGER NOT NULL, `freeTextValue` TEXT NOT NULL, `isStaticHint` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`blockStateId`) REFERENCES `block_states`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockStateId",
"columnName": "blockStateId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "checkItemDefinitionId",
"columnName": "checkItemDefinitionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "labelSnapshot",
"columnName": "labelSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemOrder",
"columnName": "itemOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "checked",
"columnName": "checked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isFreeText",
"columnName": "isFreeText",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "freeTextValue",
"columnName": "freeTextValue",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isStaticHint",
"columnName": "isStaticHint",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_check_item_states_blockStateId",
"unique": false,
"columnNames": [
"blockStateId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_check_item_states_blockStateId` ON `${TABLE_NAME}` (`blockStateId`)"
},
{
"name": "index_check_item_states_blockStateId_checkItemDefinitionId",
"unique": true,
"columnNames": [
"blockStateId",
"checkItemDefinitionId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_check_item_states_blockStateId_checkItemDefinitionId` ON `${TABLE_NAME}` (`blockStateId`, `checkItemDefinitionId`)"
}
],
"foreignKeys": [
{
"table": "block_states",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"blockStateId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "photos",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileName",
"columnName": "fileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "orderIndex",
"columnName": "orderIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_photos_documentationId_orderIndex",
"unique": false,
"columnNames": [
"documentationId",
"orderIndex"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_photos_documentationId_orderIndex` ON `${TABLE_NAME}` (`documentationId`, `orderIndex`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "signatures",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `signerName` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signerName",
"columnName": "signerName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileName",
"columnName": "fileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "orderIndex",
"columnName": "orderIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_signatures_documentationId",
"unique": false,
"columnNames": [
"documentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "wound_carers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `signatureFileName` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signatureFileName",
"columnName": "signatureFileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '12448571f5ffcf00398fa1d8260c9b2b')"
]
}
}
@@ -0,0 +1,635 @@
{
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "5e5d96efeef00d1ff7d404166a900974",
"entities": [
{
"tableName": "patients",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `firstName` TEXT NOT NULL, `lastName` TEXT NOT NULL, `birthDate` TEXT NOT NULL, `street` TEXT NOT NULL, `postalCode` TEXT NOT NULL, `city` TEXT NOT NULL, `phone1` TEXT NOT NULL, `phone2` TEXT, `phone3` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "firstName",
"columnName": "firstName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastName",
"columnName": "lastName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "birthDate",
"columnName": "birthDate",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "street",
"columnName": "street",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "postalCode",
"columnName": "postalCode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "city",
"columnName": "city",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "phone1",
"columnName": "phone1",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "phone2",
"columnName": "phone2",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "phone3",
"columnName": "phone3",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_patients_lastName_firstName",
"unique": false,
"columnNames": [
"lastName",
"firstName"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_lastName_firstName` ON `${TABLE_NAME}` (`lastName`, `firstName`)"
},
{
"name": "index_patients_birthDate",
"unique": false,
"columnNames": [
"birthDate"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_patients_birthDate` ON `${TABLE_NAME}` (`birthDate`)"
}
],
"foreignKeys": []
},
{
"tableName": "documentations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `patientId` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `continuedFromDocumentationId` TEXT, `pdfLastGeneratedAt` INTEGER, `catalogVersion` INTEGER NOT NULL, `patientFirstNameSnapshot` TEXT NOT NULL, `patientLastNameSnapshot` TEXT NOT NULL, `patientBirthDateSnapshot` TEXT NOT NULL, `patientStreetSnapshot` TEXT NOT NULL, `patientPostalCodeSnapshot` TEXT NOT NULL, `patientCitySnapshot` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`patientId`) REFERENCES `patients`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT , FOREIGN KEY(`continuedFromDocumentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientId",
"columnName": "patientId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "continuedFromDocumentationId",
"columnName": "continuedFromDocumentationId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "pdfLastGeneratedAt",
"columnName": "pdfLastGeneratedAt",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "catalogVersion",
"columnName": "catalogVersion",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "patientFirstNameSnapshot",
"columnName": "patientFirstNameSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientLastNameSnapshot",
"columnName": "patientLastNameSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientBirthDateSnapshot",
"columnName": "patientBirthDateSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientStreetSnapshot",
"columnName": "patientStreetSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientPostalCodeSnapshot",
"columnName": "patientPostalCodeSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "patientCitySnapshot",
"columnName": "patientCitySnapshot",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_documentations_patientId_createdAt",
"unique": false,
"columnNames": [
"patientId",
"createdAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_patientId_createdAt` ON `${TABLE_NAME}` (`patientId`, `createdAt`)"
},
{
"name": "index_documentations_status",
"unique": false,
"columnNames": [
"status"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_status` ON `${TABLE_NAME}` (`status`)"
},
{
"name": "index_documentations_continuedFromDocumentationId",
"unique": false,
"columnNames": [
"continuedFromDocumentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_documentations_continuedFromDocumentationId` ON `${TABLE_NAME}` (`continuedFromDocumentationId`)"
}
],
"foreignKeys": [
{
"table": "patients",
"onDelete": "RESTRICT",
"onUpdate": "NO ACTION",
"columns": [
"patientId"
],
"referencedColumns": [
"id"
]
},
{
"table": "documentations",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"continuedFromDocumentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "block_states",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `blockDefinitionId` TEXT NOT NULL, `titleSnapshot` TEXT NOT NULL, `blockOrder` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockDefinitionId",
"columnName": "blockDefinitionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "titleSnapshot",
"columnName": "titleSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockOrder",
"columnName": "blockOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_block_states_documentationId",
"unique": false,
"columnNames": [
"documentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_block_states_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
},
{
"name": "index_block_states_documentationId_blockDefinitionId",
"unique": true,
"columnNames": [
"documentationId",
"blockDefinitionId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_block_states_documentationId_blockDefinitionId` ON `${TABLE_NAME}` (`documentationId`, `blockDefinitionId`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "check_item_states",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blockStateId` TEXT NOT NULL, `checkItemDefinitionId` TEXT NOT NULL, `labelSnapshot` TEXT NOT NULL, `itemOrder` INTEGER NOT NULL, `checked` INTEGER NOT NULL, `isFreeText` INTEGER NOT NULL, `freeTextValue` TEXT NOT NULL, `isStaticHint` INTEGER NOT NULL, `inputSpec` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`blockStateId`) REFERENCES `block_states`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "blockStateId",
"columnName": "blockStateId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "checkItemDefinitionId",
"columnName": "checkItemDefinitionId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "labelSnapshot",
"columnName": "labelSnapshot",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemOrder",
"columnName": "itemOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "checked",
"columnName": "checked",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isFreeText",
"columnName": "isFreeText",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "freeTextValue",
"columnName": "freeTextValue",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isStaticHint",
"columnName": "isStaticHint",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "inputSpec",
"columnName": "inputSpec",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_check_item_states_blockStateId",
"unique": false,
"columnNames": [
"blockStateId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_check_item_states_blockStateId` ON `${TABLE_NAME}` (`blockStateId`)"
},
{
"name": "index_check_item_states_blockStateId_checkItemDefinitionId",
"unique": true,
"columnNames": [
"blockStateId",
"checkItemDefinitionId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_check_item_states_blockStateId_checkItemDefinitionId` ON `${TABLE_NAME}` (`blockStateId`, `checkItemDefinitionId`)"
}
],
"foreignKeys": [
{
"table": "block_states",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"blockStateId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "photos",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileName",
"columnName": "fileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "orderIndex",
"columnName": "orderIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_photos_documentationId_orderIndex",
"unique": false,
"columnNames": [
"documentationId",
"orderIndex"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_photos_documentationId_orderIndex` ON `${TABLE_NAME}` (`documentationId`, `orderIndex`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "signatures",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `documentationId` TEXT NOT NULL, `signerName` TEXT NOT NULL, `fileName` TEXT NOT NULL, `orderIndex` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "documentationId",
"columnName": "documentationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signerName",
"columnName": "signerName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileName",
"columnName": "fileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "orderIndex",
"columnName": "orderIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_signatures_documentationId",
"unique": false,
"columnNames": [
"documentationId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_documentationId` ON `${TABLE_NAME}` (`documentationId`)"
}
],
"foreignKeys": [
{
"table": "documentations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"documentationId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "wound_carers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `signatureFileName` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signatureFileName",
"columnName": "signatureFileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5e5d96efeef00d1ff7d404166a900974')"
]
}
}
@@ -0,0 +1,378 @@
package de.medapp.wunddoku.data.repository
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.data.entity.BlockStateEntity
import de.medapp.wunddoku.data.entity.CheckItemStateEntity
import de.medapp.wunddoku.data.entity.DocumentationEntity
import de.medapp.wunddoku.domain.catalog.DocumentationCatalog
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.DocumentationStatus
import de.medapp.wunddoku.testutil.DuplicateCall
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.firstPlainItem
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.IOException
import java.time.LocalDate
/**
* Fortsetzen-Tests: "Dokumentation fortsetzen" erzeugt aus dem Stand einer vorhandenen
* Dokumentation eine NEUE; die Quelle bleibt unverändert.
*/
@RunWith(AndroidJUnit4::class)
class ContinueDocumentationTest {
private lateinit var env: TestEnvironment
private val repo get() = env.documentationRepository
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
/** Alle Items eines Dokuments als Map definitionId (inkl. Blockbezug) -> Item. */
private fun Documentation.itemsByKey(): Map<String, CheckItem> =
blocks.flatMap { block -> block.items.map { "${block.definitionId}/${it.definitionId}" to it } }.toMap()
/** Bereitet eine Quelle mit einigen Eingaben vor und liefert sie frisch geladen zurück. */
private suspend fun prepareSourceWithInput(): Documentation {
env.insertPatient()
val created = repo.createEmpty("patient-1")
val firstBlock = created.blocks.first()
repo.setChecked(firstBlock.firstPlainItem().checkItemStateId, true)
val freeText = firstBlock.items.last()
repo.setChecked(freeText.checkItemStateId, true)
repo.updateFreeText(freeText.checkItemStateId, "Rötung am Wundrand")
return repo.getFullDocumentation(created.id)!!
}
// --- Inhalt wird übernommen ----------------------------------------------------------
@Test
fun fortsetzen_uebernimmt_bloecke_und_optionen_inklusive_haken_und_freitext() = runDbTest {
val source = prepareSourceWithInput()
val copy = repo.continueDocumentation(source.id)
assertEquals(source.blocks.size, copy.blocks.size)
for (sourceBlock in source.blocks) {
val copyBlock = copy.blocks.single { it.definitionId == sourceBlock.definitionId }
assertEquals(sourceBlock.title, copyBlock.title)
assertEquals(sourceBlock.order, copyBlock.order)
assertEquals(sourceBlock.isEdited, copyBlock.isEdited)
}
// Option für Option: gleiche Inhalte (Label, Reihenfolge, Haken, Freitext, Hinweis, strukturierte Eingabe).
val sourceItems = source.itemsByKey()
val copyItems = copy.itemsByKey()
assertEquals(sourceItems.keys, copyItems.keys)
for ((key, sourceItem) in sourceItems) {
val copyItem = copyItems.getValue(key)
assertEquals(key, sourceItem.label, copyItem.label)
assertEquals(key, sourceItem.order, copyItem.order)
assertEquals(key, sourceItem.checked, copyItem.checked)
assertEquals(key, sourceItem.isFreeText, copyItem.isFreeText)
assertEquals(key, sourceItem.freeTextValue, copyItem.freeTextValue)
assertEquals(key, sourceItem.isStaticHint, copyItem.isStaticHint)
assertEquals(key, sourceItem.inputSpec, copyItem.inputSpec)
}
assertEquals(1, copy.blocks.count { it.isEdited })
assertEquals("Rötung am Wundrand", copy.blocks.first().items.last().freeTextValue)
}
@Test
fun fortsetzen_erzeugt_ausschliesslich_neue_ids() = runDbTest {
val source = prepareSourceWithInput()
val copy = repo.continueDocumentation(source.id)
assertNotEquals(source.id, copy.id)
val sourceBlockIds = source.blocks.map { it.blockStateId }.toSet()
val sourceItemIds = source.blocks.flatMap { b -> b.items.map { it.checkItemStateId } }.toSet()
assertTrue(copy.blocks.none { it.blockStateId in sourceBlockIds })
assertTrue(copy.blocks.flatMap { b -> b.items.map { it.checkItemStateId } }.none { it in sourceItemIds })
}
@Test
fun fortsetzen_setzt_metadaten_der_neuen_dokumentation_korrekt() = runDbTest {
val source = prepareSourceWithInput()
env.setStatus(source.id, DocumentationStatus.COMPLETED)
val copy = repo.continueDocumentation(source.id)
assertEquals(DocumentationStatus.IN_PROGRESS, copy.status)
assertEquals(source.id, copy.continuedFromDocumentationId)
assertEquals(source.patientId, copy.patientId)
assertEquals(copy.createdAt, copy.updatedAt)
assertEquals(DocumentationStatus.COMPLETED, repo.getFullDocumentation(source.id)!!.status)
assertEquals(2, env.db.documentationDao().countForPatient("patient-1"))
assertTrue(env.patientRepository.hasDocumentations("patient-1"))
}
// --- Quelle bleibt unverändert / Unabhängigkeit --------------------------------------
@Test
fun fortsetzen_laesst_die_quelldokumentation_unveraendert() = runDbTest {
val source = prepareSourceWithInput()
repo.continueDocumentation(source.id)
val sourceAfter = repo.getFullDocumentation(source.id)!!
assertEquals(source.status, sourceAfter.status)
assertEquals(source.createdAt, sourceAfter.createdAt)
assertEquals(source.updatedAt, sourceAfter.updatedAt)
assertNull(sourceAfter.continuedFromDocumentationId)
assertEquals(source.blocks, sourceAfter.blocks)
assertEquals(source.photos, sourceAfter.photos)
assertEquals(source.signatures, sourceAfter.signatures)
}
@Test
fun aenderungen_an_der_kopie_veraendern_die_quelle_nicht_und_umgekehrt() = runDbTest {
val source = prepareSourceWithInput()
val copy = repo.continueDocumentation(source.id)
// Kopie: den in der Quelle gesetzten Haken entfernen und Freitext ändern.
val copyFirstBlock = copy.blocks.first()
repo.setChecked(copyFirstBlock.firstPlainItem().checkItemStateId, false)
repo.updateFreeText(copyFirstBlock.items.last().checkItemStateId, "Neuer Text")
val sourceAfter = repo.getFullDocumentation(source.id)!!
assertTrue(sourceAfter.blocks.first().firstPlainItem().checked)
assertEquals("Rötung am Wundrand", sourceAfter.blocks.first().items.last().freeTextValue)
// Quelle: weiteren Haken in einem anderen Block setzen.
val otherSourceBlock = source.blocks[1]
repo.setChecked(otherSourceBlock.firstPlainItem().checkItemStateId, true)
val copyAfter = repo.getFullDocumentation(copy.id)!!
assertFalse(copyAfter.blocks.single { it.definitionId == otherSourceBlock.definitionId }.isEdited)
assertEquals("Neuer Text", copyAfter.blocks.first().items.last().freeTextValue)
}
// --- Patienten-Snapshot --------------------------------------------------------------
@Test
fun fortsetzen_bildet_den_patienten_snapshot_frisch_aus_dem_aktuellen_patienten() = runDbTest {
val source = prepareSourceWithInput()
val patientDao = env.db.patientDao()
patientDao.update(
patientDao.getById("patient-1")!!.copy(
lastName = "Musterfrau", street = "Neue Straße 7", postalCode = "99999", city = "Neustadt",
)
)
val copy = repo.continueDocumentation(source.id)
assertEquals("Musterfrau", copy.patientSnapshot.lastName)
assertEquals("Neue Straße 7", copy.patientSnapshot.street)
assertEquals("99999", copy.patientSnapshot.postalCode)
assertEquals("Neustadt", copy.patientSnapshot.city)
assertEquals(LocalDate.of(1980, 3, 14), copy.patientSnapshot.birthDate)
// Die Quelle behält ihren damaligen Stand.
val sourceSnapshot = repo.getFullDocumentation(source.id)!!.patientSnapshot
assertEquals("Mustermann", sourceSnapshot.lastName)
assertEquals("Musterstraße 1", sourceSnapshot.street)
assertEquals("Musterstadt", sourceSnapshot.city)
}
// --- Historische Snapshots / Katalogversion ------------------------------------------
@Test
fun fortsetzen_bewahrt_historische_titel_labels_und_katalogversion() = runDbTest {
env.insertPatient()
val now = 5_000L
val historicDocId = "historic-doc"
env.db.documentationDao().insert(
DocumentationEntity(
id = historicDocId, patientId = "patient-1", status = DocumentationStatus.COMPLETED,
createdAt = now, updatedAt = now, continuedFromDocumentationId = null, pdfLastGeneratedAt = null,
catalogVersion = 1,
patientFirstNameSnapshot = "Max", patientLastNameSnapshot = "Mustermann",
patientBirthDateSnapshot = "1980-03-14", patientStreetSnapshot = "Musterstraße 1",
patientPostalCodeSnapshot = "12345", patientCitySnapshot = "Musterstadt",
)
)
env.db.blockStateDao().insertAll(
listOf(BlockStateEntity("hist-block", historicDocId, "gibt_es_nicht_mehr", "Alter Titel", 1, now))
)
env.db.checkItemStateDao().insertAll(
listOf(
CheckItemStateEntity("hist-item", "hist-block", "alter_eintrag", "Alter Eintrag", 1, checked = true),
)
)
assertNotEquals("Voraussetzung: aktuelle Katalogversion weicht ab", 1, DocumentationCatalog.CATALOG_VERSION)
val copy = repo.continueDocumentation(historicDocId)
assertEquals(1, copy.catalogVersion)
val block = copy.blocks.single()
assertEquals("gibt_es_nicht_mehr", block.definitionId)
assertEquals("Alter Titel", block.title)
assertEquals("Alter Eintrag", block.items.single().label)
assertTrue(block.items.single().checked)
}
// --- Unterschriften ------------------------------------------------------------------
@Test
fun fortsetzen_kopiert_keine_unterschriften() = runDbTest {
val source = prepareSourceWithInput()
repo.saveSignature(source.id, "Anna Schmidt", signatureBitmap())
val copy = repo.continueDocumentation(source.id)
assertTrue(copy.signatures.isEmpty())
assertEquals(1, repo.getFullDocumentation(source.id)!!.signatures.size)
assertEquals(1, env.signatureStorage.saved.size) // keine zusätzliche Datei angelegt
}
// --- Fotos ---------------------------------------------------------------------------
@Test
fun fortsetzen_kopiert_fotos_standardmaessig_als_eigenstaendige_dateien() = runDbTest {
val source = prepareSourceWithInput()
val p1 = repo.addPhoto(source.id, Uri.parse("content://test/1"))
val p2 = repo.addPhoto(source.id, Uri.parse("content://test/2"))
val copy = repo.continueDocumentation(source.id)
assertEquals(listOf(0, 1), copy.photos.map { it.orderIndex })
assertTrue(copy.photos.none { it.id == p1.id || it.id == p2.id })
assertTrue("Dateien müssen physisch eigenständig sein", copy.photos.none { it.fileName == p1.fileName || it.fileName == p2.fileName })
assertTrue(copy.photos.all { it.documentationId == copy.id })
assertEquals(
setOf(
DuplicateCall(source.id, p1.fileName, copy.id),
DuplicateCall(source.id, p2.fileName, copy.id),
),
env.photoStorage.duplicated.toSet(),
)
assertEquals(2, env.photoStorage.duplicated.size)
// Quelle behält ihre Fotos.
assertEquals(listOf(p1.fileName, p2.fileName), repo.getFullDocumentation(source.id)!!.photos.map { it.fileName })
}
@Test
fun fortsetzen_mit_copyPhotos_false_uebernimmt_keine_fotos() = runDbTest {
val source = prepareSourceWithInput()
repo.addPhoto(source.id, Uri.parse("content://test/1"))
val copy = repo.continueDocumentation(source.id, copyPhotos = false)
assertTrue(copy.photos.isEmpty())
assertTrue(env.photoStorage.duplicated.isEmpty())
}
@Test
fun foto_loeschen_in_der_kopie_betrifft_die_quelle_nicht() = runDbTest {
val source = prepareSourceWithInput()
repo.addPhoto(source.id, Uri.parse("content://test/1"))
val copy = repo.continueDocumentation(source.id)
repo.removePhoto(copy.photos.single().id)
assertTrue(repo.getFullDocumentation(copy.id)!!.photos.isEmpty())
assertEquals(1, repo.getFullDocumentation(source.id)!!.photos.size)
}
// --- Fehlerfälle / Atomarität --------------------------------------------------------
@Test
fun fortsetzen_einer_unbekannten_dokumentation_wirft_und_legt_nichts_an() = runDbTest {
env.insertPatient()
val error = expectThrows<IllegalStateException> { repo.continueDocumentation("unbekannt") }
assertTrue(error.message.orEmpty().contains("unbekannt"))
assertTrue(env.db.documentationDao().observeAll().first().isEmpty())
}
@Test
fun fortsetzen_ist_atomar_ein_fehler_beim_foto_kopieren_hinterlaesst_keine_halbe_dokumentation() = runDbTest {
val source = prepareSourceWithInput()
repo.addPhoto(source.id, Uri.parse("content://test/1"))
repo.addPhoto(source.id, Uri.parse("content://test/2"))
env.photoStorage.failOnDuplicateCall = 2 // zweites Foto schlägt fehl
expectThrows<IOException> { repo.continueDocumentation(source.id) }
val all = env.db.documentationDao().observeAll().first()
assertEquals(listOf(source.id), all.map { it.id })
assertEquals(1, env.db.documentationDao().countForPatient("patient-1"))
}
@Test
fun loeschen_der_quelle_laesst_die_fortgesetzte_dokumentation_bestehen() = runDbTest {
val source = prepareSourceWithInput()
repo.addPhoto(source.id, Uri.parse("content://test/1"))
val copy = repo.continueDocumentation(source.id)
repo.delete(source.id)
val copyAfter = repo.getFullDocumentation(copy.id)!!
assertNull("Verweis auf die gelöschte Quelle wird auf null gesetzt", copyAfter.continuedFromDocumentationId)
assertEquals(copy.blocks.size, copyAfter.blocks.size)
assertEquals(1, copyAfter.photos.size)
assertEquals(listOf(source.id), env.photoStorage.deletedAll) // nur der Ordner der Quelle
}
@Test
fun mehrfaches_fortsetzen_bildet_eine_kette() = runDbTest {
val first = prepareSourceWithInput()
val second = repo.continueDocumentation(first.id)
val third = repo.continueDocumentation(second.id)
assertEquals(first.id, second.continuedFromDocumentationId)
assertEquals(second.id, third.continuedFromDocumentationId)
assertEquals(1, third.blocks.count { it.isEdited })
assertEquals(3, env.db.documentationDao().countForPatient("patient-1"))
}
// --- continueLatestForPatient --------------------------------------------------------
@Test
fun continueLatestForPatient_liefert_null_wenn_der_patient_noch_keine_dokumentation_hat() = runDbTest {
env.insertPatient()
assertNull(repo.continueLatestForPatient("patient-1"))
assertTrue(env.db.documentationDao().observeAll().first().isEmpty())
}
@Test
fun continueLatestForPatient_setzt_die_zuletzt_erstellte_dokumentation_des_patienten_fort() = runDbTest {
env.insertPatient()
env.insertPatient(id = "patient-2", firstName = "Erika", lastName = "Musterfrau")
val older = repo.createEmpty("patient-1")
val newer = repo.createEmpty("patient-1")
val otherPatientsDoc = repo.createEmpty("patient-2")
env.setCreatedAt(older.id, 1_000L)
env.setCreatedAt(newer.id, 2_000L)
env.setCreatedAt(otherPatientsDoc.id, 3_000L) // neuer, aber anderer Patient
repo.setChecked(newer.blocks.first().firstPlainItem().checkItemStateId, true)
val copy = repo.continueLatestForPatient("patient-1")!!
assertEquals(newer.id, copy.continuedFromDocumentationId)
assertEquals("patient-1", copy.patientId)
assertEquals(1, copy.blocks.count { it.isEdited })
}
}
@@ -0,0 +1,89 @@
package de.medapp.wunddoku.data.repository
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.pdf.ExportedFilesCleaner
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.buildRepository
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/**
* Datenschutz-Löschkonzept: Beim Löschen einer Dokumentation müssen ALLE personenbezogenen
* Daten verschwinden - Datenbankzeilen, Fotos, Signaturen und abgeleitete Exporte (PDF/Bilder).
*/
@RunWith(AndroidJUnit4::class)
class DocumentationDeletionPrivacyTest {
private lateinit var env: TestEnvironment
private class RecordingCleaner(private val failure: Throwable? = null) : ExportedFilesCleaner {
val deleted = mutableListOf<String>()
override suspend fun deleteExportsFor(documentationId: String) {
deleted += documentationId
failure?.let { throw it }
}
override suspend fun deleteStaleFiles() = Unit
}
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
private suspend fun createDocumentationWithData(repo: DocumentationRepository): String {
env.insertPatient()
val id = repo.createEmpty("patient-1").id
repo.addPhoto(id, Uri.parse("content://test/1"))
repo.saveSignature(id, "Pfleger Paul", signatureBitmap())
return id
}
@Test
fun loeschen_entfernt_auch_die_exportierten_dateien() = runDbTest {
val cleaner = RecordingCleaner()
val repo = env.buildRepository(exportedFilesCleaner = cleaner)
val id = createDocumentationWithData(repo)
repo.delete(id)
assertEquals(listOf(id), cleaner.deleted)
assertNull(repo.getFullDocumentation(id))
assertTrue(env.photoStorage.deletedAll.contains(id))
assertTrue(env.signatureStorage.deletedAll.contains(id))
}
@Test
fun fehler_beim_loeschen_der_exporte_verhindert_das_loeschen_der_dokumentation_nicht() = runDbTest {
val repo = env.buildRepository(exportedFilesCleaner = RecordingCleaner(IllegalStateException("Test")))
val id = createDocumentationWithData(repo)
repo.delete(id)
assertNull(repo.getFullDocumentation(id))
assertTrue(repo.observeAll().first().isEmpty())
}
@Test
fun loeschen_einer_nicht_vorhandenen_dokumentation_loescht_keine_exporte() = runDbTest {
val cleaner = RecordingCleaner()
val repo = env.buildRepository(exportedFilesCleaner = cleaner)
repo.delete("gibt-es-nicht")
assertTrue(cleaner.deleted.isEmpty())
}
}
@@ -0,0 +1,274 @@
package de.medapp.wunddoku.data.repository
import android.database.sqlite.SQLiteFullException
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.testutil.FailingPhotoDao
import de.medapp.wunddoku.testutil.FailingPhotoStorage
import de.medapp.wunddoku.testutil.FailingSignatureDao
import de.medapp.wunddoku.testutil.FailingSignatureStorage
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.ThrowingPdfExporter
import de.medapp.wunddoku.testutil.ThrowingPdfToImagesConverter
import de.medapp.wunddoku.testutil.buildRepository
import de.medapp.wunddoku.testutil.expectThrowsSuspend
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.io.IOException
/**
* Fehler- und Datenverlust-Tests für [DocumentationRepository] gegen eine echte In-Memory-
* Room-Datenbank: Speichermangel (Datenbank/Dateisystem voll, Arbeitsspeicher), halbe
* Ergebnisse, verwaiste Dateien und das "wirft-nie"-Verhalten des PDF-Exports.
*/
@RunWith(AndroidJUnit4::class)
class DocumentationRepositoryErrorHandlingTest {
private lateinit var env: TestEnvironment
private val someUri: Uri = Uri.parse("content://test/bild/1")
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
private suspend fun newDocumentationId(): String {
env.insertPatient()
return env.documentationRepository.createEmpty("patient-1").id
}
// --- Foto-Import ---------------------------------------------------------------------
@Test
fun addPhoto_bei_fehlgeschlagenem_dateiimport_legt_keinen_db_eintrag_an() = runDbTest {
val documentationId = newDocumentationId()
val storage = FailingPhotoStorage().apply { importFailure = { IOException("Bild defekt (Test)") } }
val repo = env.buildRepository(photoStorage = storage)
expectThrowsSuspend<IOException> { repo.addPhoto(documentationId, someUri) }
assertTrue(env.db.photoDao().getForDocumentation(documentationId).isEmpty())
}
@Test
fun addPhoto_bei_speichermangel_im_arbeitsspeicher_legt_keinen_db_eintrag_an() = runDbTest {
val documentationId = newDocumentationId()
val storage = FailingPhotoStorage().apply { importFailure = { OutOfMemoryError("Test") } }
val repo = env.buildRepository(photoStorage = storage)
expectThrowsSuspend<OutOfMemoryError> { repo.addPhoto(documentationId, someUri) }
assertTrue(env.db.photoDao().getForDocumentation(documentationId).isEmpty())
}
@Test
fun addPhoto_bei_voller_datenbank_entfernt_die_bereits_importierte_datei() = runDbTest {
val documentationId = newDocumentationId()
val storage = FailingPhotoStorage()
val photoDao = FailingPhotoDao(env.db.photoDao()).apply {
insertFailure = SQLiteFullException("database or disk is full")
}
val repo = env.buildRepository(photoStorage = storage, photoDao = photoDao)
expectThrowsSuspend<SQLiteFullException> { repo.addPhoto(documentationId, someUri) }
assertEquals(1, storage.delegate.imported.size)
assertEquals("verwaiste Datei muss entfernt werden", 1, storage.delegate.deleted.size)
assertEquals(documentationId, storage.delegate.deleted.single().first)
assertTrue(env.db.photoDao().getForDocumentation(documentationId).isEmpty())
}
@Test
fun addPhoto_funktioniert_nach_einem_fehlgeschlagenen_versuch_wieder() = runDbTest {
val documentationId = newDocumentationId()
val storage = FailingPhotoStorage().apply { importFailure = { call -> if (call == 1) IOException("einmalig") else null } }
val repo = env.buildRepository(photoStorage = storage)
expectThrowsSuspend<IOException> { repo.addPhoto(documentationId, someUri) }
repo.addPhoto(documentationId, someUri)
assertEquals(1, env.db.photoDao().getForDocumentation(documentationId).size)
}
// --- Signaturen ----------------------------------------------------------------------
@Test
fun saveSignature_bei_dateifehler_legt_weder_db_eintrag_noch_registereintrag_an() = runDbTest {
val documentationId = newDocumentationId()
val storage = FailingSignatureStorage().apply { saveFailure = IOException("Speicher voll (Test)") }
val repo = env.buildRepository(signatureStorage = storage)
expectThrowsSuspend<IOException> { repo.saveSignature(documentationId, "Pfleger Paul", signatureBitmap()) }
assertTrue(env.db.signatureDao().getForDocumentation(documentationId).isEmpty())
assertNull(env.woundCarerRepository.getMostRecentlyUsed())
}
@Test
fun saveSignature_bei_voller_datenbank_entfernt_die_gespeicherte_datei() = runDbTest {
val documentationId = newDocumentationId()
val storage = FailingSignatureStorage()
val signatureDao = FailingSignatureDao(env.db.signatureDao()).apply {
insertFailure = SQLiteFullException("database or disk is full")
}
val repo = env.buildRepository(signatureStorage = storage, signatureDao = signatureDao)
expectThrowsSuspend<SQLiteFullException> { repo.saveSignature(documentationId, "Pfleger Paul", signatureBitmap()) }
assertEquals(1, storage.delegate.saved.size)
assertEquals("verwaiste PNG-Datei muss entfernt werden", 1, storage.delegate.deleted.size)
assertTrue(env.db.signatureDao().getForDocumentation(documentationId).isEmpty())
}
@Test
fun saveSignature_bleibt_erfolgreich_wenn_nur_das_wundversorger_register_scheitert() = runDbTest {
val documentationId = newDocumentationId()
val brokenRegisterStorage = FailingSignatureStorage().apply { saveFailure = IOException("Register-Speicher voll") }
val brokenRegister = WoundCarerRepository(env.db.woundCarerDao(), brokenRegisterStorage)
val repo = env.buildRepository(woundCarerRepository = brokenRegister)
val signature = repo.saveSignature(documentationId, "Pfleger Paul", signatureBitmap())
assertEquals("Pfleger Paul", signature.signerName)
assertEquals(
"Signatur der Dokumentation muss gespeichert sein",
1,
env.db.signatureDao().getForDocumentation(documentationId).size,
)
}
// --- Fortsetzen ----------------------------------------------------------------------
@Test
fun fortsetzen_raeumt_bereits_kopierte_fotos_der_gescheiterten_kopie_auf() = runDbTest {
val sourceId = newDocumentationId()
env.documentationRepository.addPhoto(sourceId, someUri)
env.documentationRepository.addPhoto(sourceId, someUri)
env.photoStorage.failOnDuplicateCall = 2
expectThrowsSuspend<IOException> { env.documentationRepository.continueDocumentation(sourceId) }
val attemptedTarget = env.photoStorage.duplicated.first().targetDocumentationId
assertTrue(
"Ordner der nie entstandenen Dokumentation muss gelöscht werden",
attemptedTarget in env.photoStorage.deletedAll,
)
assertEquals("keine halbe Dokumentation", 1, env.documentationRepository.observeAll().first().size)
assertTrue(env.db.photoDao().getForDocumentation(attemptedTarget).isEmpty())
}
@Test
fun fortsetzen_laesst_quelle_und_deren_fotos_bei_fehler_unberuehrt() = runDbTest {
val sourceId = newDocumentationId()
env.documentationRepository.addPhoto(sourceId, someUri)
env.photoStorage.failOnDuplicateCall = 1
expectThrowsSuspend<IOException> { env.documentationRepository.continueDocumentation(sourceId) }
assertEquals(1, env.db.photoDao().getForDocumentation(sourceId).size)
assertFalse(sourceId in env.photoStorage.deletedAll)
}
// --- PDF-Export wirft nie ------------------------------------------------------------
@Test
fun exportPdf_uebersetzt_out_of_memory_in_ein_fehlerergebnis() = runDbTest {
val documentationId = newDocumentationId()
val repo = env.buildRepository(pdfExporter = ThrowingPdfExporter(OutOfMemoryError("Test")))
val result = repo.exportPdf(documentationId)
assertEquals(PdfExportResult.Failure(PdfExportError.OutOfMemory), result)
}
@Test
fun exportPdf_uebersetzt_speicher_voll_in_ein_fehlerergebnis() = runDbTest {
val documentationId = newDocumentationId()
val repo = env.buildRepository(
pdfExporter = ThrowingPdfExporter(IOException("write failed: ENOSPC (No space left on device)")),
)
val result = repo.exportPdf(documentationId)
assertEquals(PdfExportResult.Failure(PdfExportError.InsufficientStorage), result)
}
@Test
fun exportPdf_gibt_bei_unerwarteten_fehlern_keine_technischen_details_weiter() = runDbTest {
val documentationId = newDocumentationId()
val repo = env.buildRepository(
pdfExporter = ThrowingPdfExporter(RuntimeException("/files/pdf/2026_Max_Mustermann.pdf kaputt")),
)
val result = repo.exportPdf(documentationId)
assertTrue(result is PdfExportResult.Failure)
val error = (result as PdfExportResult.Failure).error
assertTrue(error is PdfExportError.Unexpected)
assertFalse((error as PdfExportError.Unexpected).message.contains("Mustermann"))
}
@Test
fun exportPdf_reicht_coroutine_abbruch_weiter() = runDbTest {
val documentationId = newDocumentationId()
val repo = env.buildRepository(pdfExporter = ThrowingPdfExporter(CancellationException("abgebrochen")))
expectThrowsSuspend<CancellationException> { repo.exportPdf(documentationId) }
}
@Test
fun exportPdf_bei_geschlossener_datenbank_liefert_fehler_statt_exception() = runDbTest {
val documentationId = newDocumentationId()
env.db.close()
val result = env.documentationRepository.exportPdf(documentationId)
assertTrue("erwartet Failure, war $result", result is PdfExportResult.Failure)
}
@Test
fun convertPdfToImages_uebersetzt_exceptions_in_fehlerergebnisse() = runDbTest {
val file = File("/fake/x.pdf")
val oom = env.buildRepository(pdfToImagesConverter = ThrowingPdfToImagesConverter(OutOfMemoryError()))
.convertPdfToImages("doc", file)
val full = env.buildRepository(
pdfToImagesConverter = ThrowingPdfToImagesConverter(IOException("No space left on device")),
).convertPdfToImages("doc", file)
val other = env.buildRepository(pdfToImagesConverter = ThrowingPdfToImagesConverter(IllegalStateException("x")))
.convertPdfToImages("doc", file)
assertEquals(PdfToImagesResult.Failure.OutOfMemory, oom)
assertEquals(PdfToImagesResult.Failure.InsufficientStorage, full)
assertTrue(other is PdfToImagesResult.Failure.Unexpected)
}
// --- Sonstiges -----------------------------------------------------------------------
@Test
fun setChecked_mit_unbekannter_id_wirft_nicht() = runDbTest {
newDocumentationId()
env.documentationRepository.setChecked("gibt-es-nicht", true)
env.documentationRepository.updateFreeText("gibt-es-nicht", "Text")
}
}
@@ -0,0 +1,545 @@
package de.medapp.wunddoku.data.repository
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.domain.catalog.DocumentationCatalog
import de.medapp.wunddoku.domain.model.DocumentationStatus
import de.medapp.wunddoku.domain.model.InputSpecCodec
import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.blankBitmap
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.firstPlainItem
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.time.LocalDate
import java.time.ZoneId
/**
* Repository-Tests für [DocumentationRepository] gegen eine echte In-Memory-Room-Datenbank.
* Dateispeicher und PDF-Erzeugung sind durch Fakes ersetzt (s. testutil/Fakes.kt).
* "Fortsetzen" wird separat in [ContinueDocumentationTest] getestet.
*/
@RunWith(AndroidJUnit4::class)
class DocumentationRepositoryTest {
private lateinit var env: TestEnvironment
private val repo get() = env.documentationRepository
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
// --- createEmpty ---------------------------------------------------------------------
@Test
fun createEmpty_legt_neue_dokumentation_im_status_in_bearbeitung_an() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
assertEquals("patient-1", doc.patientId)
assertEquals(DocumentationStatus.IN_PROGRESS, doc.status)
assertNull(doc.continuedFromDocumentationId)
assertEquals(DocumentationCatalog.CATALOG_VERSION, doc.catalogVersion)
assertEquals(doc.createdAt, doc.updatedAt)
assertTrue(doc.photos.isEmpty())
assertTrue(doc.signatures.isEmpty())
assertEquals(doc.id, repo.getFullDocumentation(doc.id)?.id)
}
@Test
fun createEmpty_uebernimmt_den_patienten_snapshot() = runDbTest {
env.insertPatient(
firstName = "Erika", lastName = "Musterfrau", birthDate = "1975-12-01",
street = "Hauptstraße 5", postalCode = "54321", city = "Beispielstadt",
)
val snapshot = repo.createEmpty("patient-1").patientSnapshot
assertEquals("Erika", snapshot.firstName)
assertEquals("Musterfrau", snapshot.lastName)
assertEquals(LocalDate.of(1975, 12, 1), snapshot.birthDate)
assertEquals("Hauptstraße 5", snapshot.street)
assertEquals("54321", snapshot.postalCode)
assertEquals("Beispielstadt", snapshot.city)
}
@Test
fun createEmpty_erzeugt_pro_katalogblock_einen_block_mit_titel_und_reihenfolge_snapshot() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
assertEquals(DocumentationCatalog.blocks.size, doc.blocks.size)
for (def in DocumentationCatalog.blocks) {
val block = doc.blocks.single { it.definitionId == def.id }
assertEquals(def.title, block.title)
assertEquals(def.order, block.order)
}
}
@Test
fun createEmpty_haengt_pro_block_die_generische_freitext_option_als_letztes_element_an() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
for (def in DocumentationCatalog.blocks) {
val block = doc.blocks.single { it.definitionId == def.id }
assertEquals(def.items.size + 1, block.items.size)
val generic = block.items.last()
assertEquals(def.freeTextDefinitionId, generic.definitionId)
assertEquals(DocumentationCatalog.FREE_TEXT_LABEL, generic.label)
assertEquals((def.items.maxOfOrNull { it.order } ?: 0) + 1, generic.order)
assertTrue(generic.isFreeText)
assertFalse(generic.isStaticHint)
assertFalse(generic.checked)
assertEquals("", generic.freeTextValue)
}
}
@Test
fun createEmpty_uebernimmt_katalogeintraege_1_zu_1_und_hinweistexte_starten_angehakt() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
for (def in DocumentationCatalog.blocks) {
val block = doc.blocks.single { it.definitionId == def.id }
for (itemDef in def.items) {
val state = block.items.single { it.definitionId == itemDef.id }
assertEquals(itemDef.label, state.label)
assertEquals(itemDef.order, state.order)
assertEquals(itemDef.isFreeText, state.isFreeText)
assertEquals(itemDef.isStaticHint, state.isStaticHint)
// Nur Hinweistexte starten angehakt, alles andere unangehakt/leer.
assertEquals(itemDef.isStaticHint, state.checked)
assertEquals("", state.freeTextValue)
val expectedSpec = itemDef.inputSpec?.let(InputSpecCodec::encode) ?: ""
assertEquals(expectedSpec, state.inputSpec)
}
}
}
@Test
fun createEmpty_ergibt_keinen_bearbeiteten_block_auch_nicht_durch_hinweistexte() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
assertTrue(doc.blocks.none { it.isEdited })
}
@Test
fun createEmpty_vergibt_fuer_jede_dokumentation_neue_ids() = runDbTest {
env.insertPatient()
val first = repo.createEmpty("patient-1")
val second = repo.createEmpty("patient-1")
assertTrue(first.id != second.id)
val allBlockIds = (first.blocks + second.blocks).map { it.blockStateId }
assertEquals(allBlockIds.size, allBlockIds.toSet().size)
assertEquals(2, env.db.documentationDao().countForPatient("patient-1"))
}
@Test
fun createEmpty_mit_unbekanntem_patienten_wirft_und_legt_nichts_an() = runDbTest {
val error = expectThrows<IllegalStateException> { repo.createEmpty("gibt-es-nicht") }
assertTrue(error.message.orEmpty().contains("gibt-es-nicht"))
assertTrue(env.db.documentationDao().observeAll().first().isEmpty())
}
// --- Lesen / Liste / Suche -----------------------------------------------------------
@Test
fun getFullDocumentation_liefert_null_fuer_unbekannte_id() = runDbTest {
assertNull(repo.getFullDocumentation("unbekannt"))
}
@Test
fun observeAll_liefert_neueste_dokumentation_zuerst_mit_snapshot_namen() = runDbTest {
env.insertPatient()
val older = repo.createEmpty("patient-1")
val newer = repo.createEmpty("patient-1")
env.setCreatedAt(older.id, 1_000L)
env.setCreatedAt(newer.id, 2_000L)
val list = repo.observeAll().first()
assertEquals(listOf(newer.id, older.id), list.map { it.id })
assertEquals("Max Mustermann", list.first().patientFullName)
assertEquals(DocumentationStatus.IN_PROGRESS, list.first().status)
}
@Test
fun search_sucht_gegen_den_aktuellen_patientennamen_zeigt_aber_den_snapshot_namen() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val patientDao = env.db.patientDao()
patientDao.update(patientDao.getById("patient-1")!!.copy(lastName = "Musterfrau"))
val found = repo.search("Musterfrau").first()
assertEquals(listOf(doc.id), found.map { it.id })
assertEquals("Max Mustermann", found.single().patientFullName)
assertTrue(repo.search("Mustermann").first().isEmpty())
}
@Test
fun search_findet_auch_ueber_vornamen_und_geburtsdatum() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
assertEquals(listOf(doc.id), repo.search("Ma").first().map { it.id })
assertEquals(listOf(doc.id), repo.search("1980-03").first().map { it.id })
assertTrue(repo.search("Zzz").first().isEmpty())
}
// --- Autosave / Blockstatus ----------------------------------------------------------
@Test
fun setChecked_persistiert_und_macht_den_block_bearbeitet() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val block = doc.blocks.first()
val item = block.firstPlainItem()
repo.setChecked(item.checkItemStateId, true)
val reloaded = repo.getFullDocumentation(doc.id)!!
val reloadedBlock = reloaded.blocks.single { it.blockStateId == block.blockStateId }
assertTrue(reloadedBlock.items.single { it.checkItemStateId == item.checkItemStateId }.checked)
assertTrue(reloadedBlock.isEdited)
assertEquals(1, reloaded.blocks.count { it.isEdited })
}
@Test
fun setChecked_false_nimmt_den_bearbeitet_status_wieder_zurueck() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val block = doc.blocks.first()
val item = block.firstPlainItem()
repo.setChecked(item.checkItemStateId, true)
repo.setChecked(item.checkItemStateId, false)
val reloaded = repo.getFullDocumentation(doc.id)!!
assertTrue(reloaded.blocks.none { it.isEdited })
}
@Test
fun freitext_wird_gespeichert_und_zaehlt_erst_mit_haken_als_bearbeitung() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val block = doc.blocks.first()
val freeText = block.items.last()
val text = "Größe 3,5 × 2 cm\nzweite Zeile – Rötung"
repo.updateFreeText(freeText.checkItemStateId, text)
val textOnly = repo.getFullDocumentation(doc.id)!!
assertEquals(text, textOnly.blocks.single { it.blockStateId == block.blockStateId }.items.last().freeTextValue)
assertTrue("Freitext ohne Haken darf den Block nicht bearbeitet machen", textOnly.blocks.none { it.isEdited })
repo.setChecked(freeText.checkItemStateId, true)
val checked = repo.getFullDocumentation(doc.id)!!
val reloadedBlock = checked.blocks.single { it.blockStateId == block.blockStateId }
assertTrue(reloadedBlock.items.last().checked)
assertEquals(text, reloadedBlock.items.last().freeTextValue)
assertTrue(reloadedBlock.isEdited)
}
@Test
fun aenderungen_in_einer_dokumentation_beruehren_eine_andere_nicht() = runDbTest {
env.insertPatient()
val first = repo.createEmpty("patient-1")
val second = repo.createEmpty("patient-1")
repo.setChecked(first.blocks.first().firstPlainItem().checkItemStateId, true)
assertTrue(repo.getFullDocumentation(second.id)!!.blocks.none { it.isEdited })
}
// --- Fotos ---------------------------------------------------------------------------
@Test
fun addPhoto_vergibt_fortlaufenden_orderIndex_und_speichert_den_storage_dateinamen() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val uri1 = Uri.parse("content://test/1")
val uri2 = Uri.parse("content://test/2")
val p1 = repo.addPhoto(doc.id, uri1)
val p2 = repo.addPhoto(doc.id, uri2)
assertEquals(0, p1.orderIndex)
assertEquals(1, p2.orderIndex)
assertEquals(listOf(doc.id to uri1, doc.id to uri2), env.photoStorage.imported)
val photos = repo.getFullDocumentation(doc.id)!!.photos
assertEquals(listOf(p1.fileName, p2.fileName), photos.map { it.fileName })
assertEquals(listOf(0, 1), photos.map { it.orderIndex })
}
@Test
fun removePhoto_loescht_db_zeile_und_datei_und_vergibt_orderIndex_nicht_neu() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val p1 = repo.addPhoto(doc.id, Uri.parse("content://test/1"))
val p2 = repo.addPhoto(doc.id, Uri.parse("content://test/2"))
repo.removePhoto(p1.id)
assertEquals(listOf(p2.id), repo.getFullDocumentation(doc.id)!!.photos.map { it.id })
assertEquals(listOf(doc.id to p1.fileName), env.photoStorage.deleted)
val p3 = repo.addPhoto(doc.id, Uri.parse("content://test/3"))
assertEquals(2, p3.orderIndex)
}
@Test
fun removePhoto_mit_unbekannter_id_tut_nichts() = runDbTest {
env.insertPatient()
repo.createEmpty("patient-1")
repo.removePhoto("unbekannt")
assertTrue(env.photoStorage.deleted.isEmpty())
}
@Test
fun removePhoto_entfernt_die_db_zeile_auch_wenn_die_datei_nicht_geloescht_werden_kann() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val photo = repo.addPhoto(doc.id, Uri.parse("content://test/1"))
env.photoStorage.failOnDelete = true
repo.removePhoto(photo.id)
assertTrue(repo.getFullDocumentation(doc.id)!!.photos.isEmpty())
}
// --- Signaturen ----------------------------------------------------------------------
@Test
fun saveSignature_trimmt_den_namen_und_vergibt_fortlaufenden_orderIndex() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val first = repo.saveSignature(doc.id, " Anna Schmidt ", signatureBitmap())
val second = repo.saveSignature(doc.id, "Bernd Beispiel", signatureBitmap())
assertEquals("Anna Schmidt", first.signerName)
assertEquals(0, first.orderIndex)
assertEquals(1, second.orderIndex)
val signatures = repo.getFullDocumentation(doc.id)!!.signatures
assertEquals(listOf("Anna Schmidt", "Bernd Beispiel"), signatures.map { it.signerName })
assertEquals(listOf(first.fileName, second.fileName), signatures.map { it.fileName })
assertEquals(listOf(doc.id to first.fileName, doc.id to second.fileName), env.signatureStorage.saved)
}
@Test
fun saveSignature_aktualisiert_das_wundversorger_register() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
assertEquals("Anna Schmidt", env.woundCarerRepository.getMostRecentlyUsed()?.name)
assertEquals(1, env.woundCarerSignatureStorage.saved.size)
}
@Test
fun saveSignature_mit_leerem_namen_wirft_und_hinterlaesst_keine_datei() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
expectThrows<IllegalArgumentException> { repo.saveSignature(doc.id, " ", signatureBitmap()) }
assertTrue(env.signatureStorage.saved.isEmpty())
assertTrue(env.db.signatureDao().getForDocumentation(doc.id).isEmpty())
assertNull(env.woundCarerRepository.getMostRecentlyUsed())
}
@Test
fun saveSignature_mit_leerer_zeichnung_wirft_und_hinterlaesst_keine_datei() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
expectThrows<IllegalArgumentException> { repo.saveSignature(doc.id, "Anna Schmidt", blankBitmap()) }
assertTrue(env.signatureStorage.saved.isEmpty())
assertTrue(env.db.signatureDao().getForDocumentation(doc.id).isEmpty())
assertNull(env.woundCarerRepository.getMostRecentlyUsed())
}
@Test
fun removeSignature_loescht_db_zeile_und_datei() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val signature = repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
repo.removeSignature(signature.id)
assertTrue(repo.getFullDocumentation(doc.id)!!.signatures.isEmpty())
assertEquals(listOf(doc.id to signature.fileName), env.signatureStorage.deleted)
}
@Test
fun removeSignature_mit_unbekannter_id_tut_nichts_und_toleriert_storage_fehler() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
repo.removeSignature("unbekannt")
assertTrue(env.signatureStorage.deleted.isEmpty())
val signature = repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
env.signatureStorage.failOnDelete = true
repo.removeSignature(signature.id)
assertTrue(repo.getFullDocumentation(doc.id)!!.signatures.isEmpty())
}
// --- Löschen -------------------------------------------------------------------------
@Test
fun delete_entfernt_dokumentation_samt_bloecken_fotos_und_signaturen_sowie_dateiordner() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
repo.addPhoto(doc.id, Uri.parse("content://test/1"))
repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
val blockStateIds = doc.blocks.map { it.blockStateId }
repo.delete(doc.id)
assertNull(repo.getFullDocumentation(doc.id))
assertTrue(env.db.blockStateDao().getForDocumentation(doc.id).isEmpty())
assertTrue(env.db.photoDao().getForDocumentation(doc.id).isEmpty())
assertTrue(env.db.signatureDao().getForDocumentation(doc.id).isEmpty())
for (blockStateId in blockStateIds) {
assertTrue(env.db.checkItemStateDao().getForBlockState(blockStateId).isEmpty())
}
assertEquals(listOf(doc.id), env.photoStorage.deletedAll)
assertEquals(listOf(doc.id), env.signatureStorage.deletedAll)
assertNotNull("Der Patient darf nicht mitgelöscht werden", env.patientRepository.getById("patient-1"))
}
@Test
fun delete_beruehrt_das_wundversorger_register_nicht() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
repo.saveSignature(doc.id, "Anna Schmidt", signatureBitmap())
repo.delete(doc.id)
assertEquals("Anna Schmidt", env.woundCarerRepository.getMostRecentlyUsed()?.name)
assertTrue(env.woundCarerSignatureStorage.deletedAll.isEmpty())
assertTrue(env.woundCarerSignatureStorage.deleted.isEmpty())
}
@Test
fun delete_mit_unbekannter_id_tut_nichts() = runDbTest {
repo.delete("unbekannt")
assertTrue(env.photoStorage.deletedAll.isEmpty())
assertTrue(env.signatureStorage.deletedAll.isEmpty())
}
@Test
fun delete_entfernt_die_dokumentation_auch_wenn_dateiordner_nicht_geloescht_werden_koennen() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
env.photoStorage.failOnDeleteAll = true
env.signatureStorage.failOnDeleteAll = true
repo.delete(doc.id)
assertNull(repo.getFullDocumentation(doc.id))
}
// --- PDF-Export ----------------------------------------------------------------------
@Test
fun exportPdf_fuer_unbekannte_dokumentation_liefert_fachlichen_fehler_ohne_exporter_aufruf() = runDbTest {
val result = repo.exportPdf("unbekannt")
assertEquals(PdfExportResult.Failure(PdfExportError.DocumentationNotFound), result)
assertTrue(env.pdfExporter.models.isEmpty())
}
@Test
fun exportPdf_uebergibt_nur_bearbeitete_bloecke_und_den_schemakonformen_dateinamen() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val block = doc.blocks.first()
val item = block.firstPlainItem()
repo.setChecked(item.checkItemStateId, true)
val result = repo.exportPdf(doc.id)
assertTrue(result is PdfExportResult.Success)
val model = env.pdfExporter.models.single()
assertEquals(doc.id, model.documentationId)
assertEquals(listOf(block.title), model.blocks.map { it.title })
assertTrue(model.blocks.single().items.any { it.label == item.label })
val createdDate = doc.createdAt.atZone(ZoneId.systemDefault()).toLocalDate()
assertEquals("${createdDate}_Max_Mustermann_1980-03-14.pdf", model.fileName)
}
@Test
fun exportPdf_verwendet_den_snapshot_und_nicht_den_aktuellen_patientendatensatz() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val patientDao = env.db.patientDao()
patientDao.update(patientDao.getById("patient-1")!!.copy(lastName = "Musterfrau", city = "Neustadt"))
repo.exportPdf(doc.id)
val model = env.pdfExporter.models.single()
assertEquals("Max Mustermann", model.patientFullName)
assertEquals("Musterstraße 1, 12345 Musterstadt", model.patientDisplayAddress)
}
@Test
fun exportPdf_reicht_das_exporter_ergebnis_unveraendert_durch() = runDbTest {
env.insertPatient()
val doc = repo.createEmpty("patient-1")
val failure = PdfExportResult.Failure(PdfExportError.WriteFailed("Speicher voll"))
env.pdfExporter.result = { failure }
assertEquals(failure, repo.exportPdf(doc.id))
}
@Test
fun convertPdfToImages_delegiert_an_den_converter() = runDbTest {
val pdf = File("/fake/pdfs/test.pdf")
env.pdfToImagesConverter.result = PdfToImagesResult.Failure.FileNotFound
val result = repo.convertPdfToImages("doc-1", pdf)
assertEquals(PdfToImagesResult.Failure.FileNotFound, result)
assertEquals(listOf("doc-1" to pdf), env.pdfToImagesConverter.calls)
}
}
@@ -0,0 +1,223 @@
package de.medapp.wunddoku.data.repository
import android.database.sqlite.SQLiteConstraintException
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.domain.model.Patient
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.runDbTest
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.time.Instant
import java.time.LocalDate
import java.util.UUID
/** Repository-Tests für [PatientRepository] gegen eine echte In-Memory-Room-Datenbank. */
@RunWith(AndroidJUnit4::class)
class PatientRepositoryTest {
private lateinit var env: TestEnvironment
private val repo get() = env.patientRepository
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
private fun patient(
id: String = "p-1",
firstName: String = "Max",
lastName: String = "Mustermann",
birthDate: LocalDate = LocalDate.of(1980, 3, 14),
) = Patient(
id = id,
firstName = firstName,
lastName = lastName,
birthDate = birthDate,
street = "Musterstraße 1",
postalCode = "12345",
city = "Musterstadt",
phone1 = "0123 456789",
phone2 = "0987 654321",
phone3 = null,
createdAt = Instant.EPOCH,
updatedAt = Instant.EPOCH,
)
// --- upsert / getById ----------------------------------------------------------------
@Test
fun upsert_legt_neuen_patienten_an_und_setzt_zeitstempel_zentral() = runDbTest {
val before = Instant.now().toEpochMilli()
val saved = repo.upsert(patient())
assertEquals(saved.createdAt, saved.updatedAt)
assertTrue(saved.createdAt.toEpochMilli() >= before)
val loaded = repo.getById("p-1")!!
assertEquals("Max", loaded.firstName)
assertEquals("Mustermann", loaded.lastName)
assertEquals(LocalDate.of(1980, 3, 14), loaded.birthDate)
assertEquals("Musterstraße 1", loaded.street)
assertEquals("12345", loaded.postalCode)
assertEquals("Musterstadt", loaded.city)
assertEquals("0123 456789", loaded.phone1)
assertEquals("0987 654321", loaded.phone2)
assertNull(loaded.phone3)
// Die Datenbank speichert Millisekunden, upsert() liefert Instant.now() mit höherer Genauigkeit.
assertEquals(saved.createdAt.toEpochMilli(), loaded.createdAt.toEpochMilli())
}
@Test
fun upsert_aktualisiert_bestehenden_patienten_und_behaelt_createdAt() = runDbTest {
val first = repo.upsert(patient())
// createdAt im übergebenen Objekt wird bewusst ignoriert (Instant.EPOCH aus dem Helper).
val second = repo.upsert(patient(lastName = "Musterfrau"))
assertEquals(first.createdAt.toEpochMilli(), second.createdAt.toEpochMilli())
assertTrue(second.updatedAt.toEpochMilli() >= first.updatedAt.toEpochMilli())
val loaded = repo.getById("p-1")!!
assertEquals("Musterfrau", loaded.lastName)
assertEquals(first.createdAt.toEpochMilli(), loaded.createdAt.toEpochMilli())
}
@Test
fun upsert_erzeugt_bei_unterschiedlichen_ids_getrennte_datensaetze() = runDbTest {
repo.upsert(patient(id = "p-1"))
repo.upsert(patient(id = "p-2", firstName = "Erika", lastName = "Musterfrau"))
assertEquals("Max", repo.getById("p-1")!!.firstName)
assertEquals("Erika", repo.getById("p-2")!!.firstName)
}
@Test
fun getById_liefert_null_fuer_unbekannte_id() = runDbTest {
assertNull(repo.getById("unbekannt"))
}
@Test
fun newId_liefert_gueltige_und_eindeutige_uuids() {
val a = repo.newId()
val b = repo.newId()
assertEquals(a, UUID.fromString(a).toString())
assertTrue(a != b)
}
// --- findExisting --------------------------------------------------------------------
@Test
fun findExisting_trifft_nur_bei_gleichem_namen_und_geburtsdatum() = runDbTest {
repo.upsert(patient())
assertEquals("p-1", repo.findExisting("Max", "Mustermann", LocalDate.of(1980, 3, 14))?.id)
assertNull(repo.findExisting("Max", "Mustermann", LocalDate.of(1980, 3, 15)))
assertNull(repo.findExisting("Moritz", "Mustermann", LocalDate.of(1980, 3, 14)))
assertNull(repo.findExisting("Max", "Musterfrau", LocalDate.of(1980, 3, 14)))
}
// --- Dokumentationen / Löschen -------------------------------------------------------
@Test
fun hasDocumentations_ist_erst_nach_dem_anlegen_einer_dokumentation_true() = runDbTest {
repo.upsert(patient())
assertFalse(repo.hasDocumentations("p-1"))
env.documentationRepository.createEmpty("p-1")
assertTrue(repo.hasDocumentations("p-1"))
}
@Test
fun delete_entfernt_patienten_ohne_dokumentation() = runDbTest {
repo.upsert(patient())
repo.delete("p-1")
assertNull(repo.getById("p-1"))
}
@Test
fun delete_wird_bei_vorhandener_dokumentation_von_der_datenbank_verhindert() = runDbTest {
repo.upsert(patient())
val doc = env.documentationRepository.createEmpty("p-1")
expectThrows<SQLiteConstraintException> { repo.delete("p-1") }
assertNotNull(repo.getById("p-1"))
assertNotNull(env.documentationRepository.getFullDocumentation(doc.id))
}
@Test
fun delete_mit_unbekannter_id_tut_nichts() = runDbTest {
repo.upsert(patient())
repo.delete("unbekannt")
assertNotNull(repo.getById("p-1"))
}
// --- Liste / Suche -------------------------------------------------------------------
@Test
fun observeAll_sortiert_nach_nachname_dann_vorname() = runDbTest {
repo.upsert(patient(id = "p-1", firstName = "Max", lastName = "Müller"))
repo.upsert(patient(id = "p-2", firstName = "Anna", lastName = "Müller"))
repo.upsert(patient(id = "p-3", firstName = "Zoe", lastName = "Adler"))
val list = repo.observeAll().first()
assertEquals(listOf("p-3", "p-2", "p-1"), list.map { it.id })
assertEquals("Zoe Adler", list.first().fullName)
}
@Test
fun observeAll_zeigt_datum_der_letzten_dokumentation_oder_null() = runDbTest {
repo.upsert(patient(id = "p-1"))
repo.upsert(patient(id = "p-2", firstName = "Erika", lastName = "Musterfrau"))
val older = env.documentationRepository.createEmpty("p-1")
val newer = env.documentationRepository.createEmpty("p-1")
env.setCreatedAt(older.id, 1_000L)
env.setCreatedAt(newer.id, 2_000L)
val byId = repo.observeAll().first().associateBy { it.id }
assertEquals(Instant.ofEpochMilli(2_000L), byId.getValue("p-1").lastDocumentationAt)
assertNull(byId.getValue("p-2").lastDocumentationAt)
}
@Test
fun search_findet_ueber_nachname_vorname_und_geburtsdatum() = runDbTest {
repo.upsert(patient(id = "p-1", firstName = "Max", lastName = "Mustermann"))
repo.upsert(patient(id = "p-2", firstName = "Erika", lastName = "Musterfrau", birthDate = LocalDate.of(1975, 12, 1)))
// Beide Nachnamen beginnen mit "Muster": sortiert nach Nachname -> Musterfrau vor Mustermann.
assertEquals(listOf("p-2", "p-1"), repo.search("Muster").first().map { it.id })
assertEquals(listOf("p-1"), repo.search("Max").first().map { it.id })
assertEquals(listOf("p-2"), repo.search("Erika").first().map { it.id })
assertEquals(listOf("p-2"), repo.search("1975-12").first().map { it.id })
assertTrue(repo.search("Zzz").first().isEmpty())
}
@Test
fun search_ist_unabhaengig_von_gross_und_kleinschreibung() = runDbTest {
repo.upsert(patient())
assertEquals(listOf("p-1"), repo.search("mustermann").first().map { it.id })
assertEquals(listOf("p-1"), repo.search("MAX").first().map { it.id })
}
}
@@ -0,0 +1,156 @@
package de.medapp.wunddoku.data.repository
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/** Repository-Tests für das Wundversorger-Register ([WoundCarerRepository]). */
@RunWith(AndroidJUnit4::class)
class WoundCarerRepositoryTest {
private lateinit var env: TestEnvironment
private val repo get() = env.woundCarerRepository
private val storage get() = env.woundCarerSignatureStorage
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
/** updatedAt basiert auf System.currentTimeMillis() - kurze Pause für eindeutige Reihenfolge. */
private fun tick() = Thread.sleep(5)
@Test
fun getMostRecentlyUsed_ist_null_solange_nie_eine_signatur_gespeichert_wurde() = runDbTest {
assertNull(repo.getMostRecentlyUsed())
}
@Test
fun recordSignatureUsage_legt_neuen_eintrag_mit_eigener_signaturdatei_an() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
val carer = repo.getMostRecentlyUsed()!!
assertEquals("Anna Schmidt", carer.name)
assertEquals(1, storage.saved.size)
assertEquals(carer.id to carer.signatureFileName, storage.saved.single())
}
@Test
fun recordSignatureUsage_aktualisiert_bekannten_namen_case_insensitiv_und_ersetzt_die_signatur() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
val first = repo.getMostRecentlyUsed()!!
tick()
repo.recordSignatureUsage("anna schmidt", signatureBitmap())
val second = repo.getMostRecentlyUsed()!!
assertEquals(first.id, second.id)
assertEquals("anna schmidt", second.name) // zuletzt getippte Schreibweise
assertNotEquals(first.signatureFileName, second.signatureFileName)
assertTrue(second.updatedAt >= first.updatedAt)
assertEquals(1, repo.search("a").size)
// Alte Signaturdatei wurde entfernt, genau eine Datei bleibt zugeordnet.
assertEquals(listOf(first.id to first.signatureFileName), storage.deleted)
}
@Test
fun recordSignatureUsage_ignoriert_fehler_beim_loeschen_der_alten_signaturdatei() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
storage.failOnDelete = true
tick()
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
assertEquals(1, repo.search("Anna").size)
}
@Test
fun getMostRecentlyUsed_liefert_den_zuletzt_aktualisierten_wundversorger() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
tick()
repo.recordSignatureUsage("Bernd Beispiel", signatureBitmap())
assertEquals("Bernd Beispiel", repo.getMostRecentlyUsed()?.name)
tick()
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
assertEquals("Anna Schmidt", repo.getMostRecentlyUsed()?.name)
}
// --- search --------------------------------------------------------------------------
@Test
fun search_liefert_praefix_treffer_case_insensitiv_neueste_zuerst() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
tick()
repo.recordSignatureUsage("Andreas Müller", signatureBitmap())
tick()
repo.recordSignatureUsage("Bernd Beispiel", signatureBitmap())
val result = repo.search("an")
assertEquals(listOf("Andreas Müller", "Anna Schmidt"), result.map { it.name })
}
@Test
fun search_trimmt_die_eingabe_und_liefert_bei_leerer_eingabe_nichts() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
assertEquals(listOf("Anna Schmidt"), repo.search(" Ann ").map { it.name })
assertTrue(repo.search("").isEmpty())
assertTrue(repo.search(" ").isEmpty())
}
@Test
fun search_ist_ein_praefix_und_kein_teilstring_abgleich() = runDbTest {
repo.recordSignatureUsage("Anna Schmidt", signatureBitmap())
assertTrue(repo.search("Schmidt").isEmpty())
}
@Test
fun search_beachtet_das_limit() = runDbTest {
for (name in listOf("Anna A", "Anna B", "Anna C", "Anna D")) {
repo.recordSignatureUsage(name, signatureBitmap())
tick()
}
assertEquals(2, repo.search("Anna", limit = 2).size)
assertEquals(4, repo.search("Anna").size) // Standardlimit 5 deckt alle vier ab
assertEquals(listOf("Anna D", "Anna C"), repo.search("Anna", limit = 2).map { it.name })
}
// --- loadSignatureBitmap -------------------------------------------------------------
@Test
fun loadSignatureBitmap_delegiert_an_den_wundversorger_speicher() = runDbTest {
val bitmap = signatureBitmap()
storage.decodeResult = bitmap
val result = repo.loadSignatureBitmap("carer-1", "sig.png")
assertEquals(bitmap, result)
assertEquals(listOf("carer-1" to "sig.png"), storage.decodeCalls)
}
@Test
fun loadSignatureBitmap_liefert_null_wenn_die_datei_fehlt() = runDbTest {
storage.decodeResult = null
assertNull(repo.loadSignatureBitmap("carer-1", "fehlt.png"))
}
}
@@ -0,0 +1,222 @@
package de.medapp.wunddoku.media
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import de.medapp.wunddoku.testutil.expectThrowsSuspend
import de.medapp.wunddoku.testutil.runDbTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.UUID
/**
* Fehlerbehandlung von [InternalPhotoStorage] mit ECHTEN Dateien im App-Speicher: defekte,
* leere oder nicht vorhandene Bilder, große Bilder (Speichermangel) sowie "kein halbes
* Ergebnis zurücklassen".
*/
@RunWith(AndroidJUnit4::class)
class PhotoStorageErrorHandlingTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private lateinit var storage: InternalPhotoStorage
private lateinit var documentationId: String
private lateinit var workDir: File
@Before
fun setUp() {
storage = InternalPhotoStorage(context)
// Eigene, zufällige Dokumentations-ID je Test: keine Berührung echter App-Daten.
documentationId = "test-${UUID.randomUUID()}"
workDir = File(context.cacheDir, "errtest-${UUID.randomUUID()}").apply { mkdirs() }
}
@After
fun tearDown() {
File(File(context.filesDir, "photos"), documentationId).deleteRecursively()
workDir.deleteRecursively()
}
private fun docDir() = File(File(context.filesDir, "photos"), documentationId)
private fun filesInDocDir(): List<File> = docDir().listFiles()?.toList().orEmpty()
private fun writeJpeg(name: String, width: Int, height: Int): File {
val file = File(workDir, name)
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.RED) }
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
bitmap.recycle()
return file
}
private fun uriOf(file: File): Uri = Uri.fromFile(file)
// --- Bildfehler beim Import ----------------------------------------------------------
@Test
fun import_einer_textdatei_statt_bild_wirft_und_hinterlaesst_keine_datei() = runDbTest {
val notAnImage = File(workDir, "kein_bild.jpg").apply { writeText("Das ist kein Bild.") }
expectThrowsSuspend<Exception> { storage.importFrom(documentationId, uriOf(notAnImage)) }
assertTrue(filesInDocDir().isEmpty())
}
@Test
fun import_einer_leeren_datei_wirft_und_hinterlaesst_keine_datei() = runDbTest {
val empty = File(workDir, "leer.jpg").apply { createNewFile() }
expectThrowsSuspend<Exception> { storage.importFrom(documentationId, uriOf(empty)) }
assertTrue(filesInDocDir().isEmpty())
}
@Test
fun import_einer_abgeschnittenen_zufallsdatei_wirft_statt_abzustuerzen() = runDbTest {
val garbage = File(workDir, "muell.jpg").apply { writeBytes(ByteArray(2048) { (it * 31).toByte() }) }
expectThrowsSuspend<Exception> { storage.importFrom(documentationId, uriOf(garbage)) }
assertTrue(filesInDocDir().isEmpty())
}
@Test
fun import_einer_nicht_mehr_vorhandenen_datei_wirft() = runDbTest {
val missing = File(workDir, "gibt_es_nicht.jpg")
expectThrowsSuspend<Exception> { storage.importFrom(documentationId, uriOf(missing)) }
assertTrue(filesInDocDir().isEmpty())
}
@Test
fun import_mit_ungueltiger_content_uri_wirft() = runDbTest {
val invalid = Uri.parse("content://de.medapp.gibt.es.nicht/bild/1")
expectThrowsSuspend<Exception> { storage.importFrom(documentationId, invalid) }
assertTrue(filesInDocDir().isEmpty())
}
@Test
fun nach_einem_fehlgeschlagenen_import_funktioniert_der_naechste_import_normal() = runDbTest {
val broken = File(workDir, "kaputt.jpg").apply { writeText("x") }
val good = writeJpeg("gut.jpg", 200, 100)
expectThrowsSuspend<Exception> { storage.importFrom(documentationId, uriOf(broken)) }
val fileName = storage.importFrom(documentationId, uriOf(good))
assertTrue(storage.resolve(documentationId, fileName).exists())
assertEquals(1, filesInDocDir().size)
}
// --- Speichermangel: große Bilder ----------------------------------------------------
@Test
fun sehr_grosses_bild_wird_speicherschonend_heruntergerechnet_importiert() = runDbTest {
// 8000 x 300: viele Megapixel entlang der langen Kante, aber als Testbild selbst günstig.
val big = writeJpeg("gross.jpg", 8000, 300)
val fileName = storage.importFrom(documentationId, uriOf(big))
val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true }
android.graphics.BitmapFactory.decodeFile(storage.resolve(documentationId, fileName).absolutePath, bounds)
val longest = maxOf(bounds.outWidth, bounds.outHeight)
assertTrue("gespeichert wurde ${bounds.outWidth}x${bounds.outHeight}", longest in 1 until 2 * ImageDownsampler.MAX_STORED_DIMENSION_PX)
}
// --- Datei fehlt/defekt nach dem Import (Vorschau, PDF) ------------------------------
@Test
fun thumbnail_einer_fehlenden_datei_liefert_null() = runDbTest {
assertNull(storage.decodeThumbnail(documentationId, "fehlt.jpg", 300))
}
@Test
fun thumbnail_einer_beschaedigten_datei_liefert_null_statt_abzustuerzen() = runDbTest {
val target = storage.resolve(documentationId, "defekt.jpg")
target.parentFile!!.mkdirs()
target.writeBytes(ByteArray(512) { 7 })
assertNull(storage.decodeThumbnail(documentationId, "defekt.jpg", 300))
}
@Test
fun decodeFitted_einer_fehlenden_oder_beschaedigten_datei_liefert_null() = runDbTest {
assertNull(storage.decodeFitted(documentationId, "fehlt.jpg", 500, 800))
val target = storage.resolve(documentationId, "defekt.jpg")
target.parentFile!!.mkdirs()
target.writeBytes(ByteArray(512) { 7 })
assertNull(storage.decodeFitted(documentationId, "defekt.jpg", 500, 800))
}
@Test
fun decodeFitted_mit_null_zielgroesse_stuerzt_nicht_ab() = runDbTest {
val good = writeJpeg("gut.jpg", 200, 100)
val fileName = storage.importFrom(documentationId, uriOf(good))
// Ungültige Zielgröße (0) darf höchstens null liefern, aber keine Exception werfen.
storage.decodeFitted(documentationId, fileName, 0, 0)
}
@Test
fun decodeFitted_vergroessert_nie_ueber_die_native_aufloesung() = runDbTest {
val small = writeJpeg("klein.jpg", 100, 50)
val fileName = storage.importFrom(documentationId, uriOf(small))
val bitmap = storage.decodeFitted(documentationId, fileName, 4000, 4000)
assertNotNull(bitmap)
assertTrue(bitmap!!.width <= 100 && bitmap.height <= 50)
bitmap.recycle()
}
// --- Kopieren (Dokumentation fortsetzen) ---------------------------------------------
@Test
fun duplicate_einer_fehlenden_quelldatei_wirft_und_hinterlaesst_keine_zieldatei() = runDbTest {
val targetId = "test-${UUID.randomUUID()}"
try {
expectThrowsSuspend<Exception> { storage.duplicate(documentationId, "fehlt.jpg", targetId) }
val targetDir = File(File(context.filesDir, "photos"), targetId)
assertTrue(targetDir.listFiles().orEmpty().isEmpty())
} finally {
File(File(context.filesDir, "photos"), targetId).deleteRecursively()
}
}
@Test
fun duplicate_erzeugt_eine_eigenstaendige_kopie() = runDbTest {
val good = writeJpeg("gut.jpg", 200, 100)
val fileName = storage.importFrom(documentationId, uriOf(good))
val targetId = "test-${UUID.randomUUID()}"
try {
val copy = storage.duplicate(documentationId, fileName, targetId)
storage.delete(documentationId, fileName)
assertTrue("Kopie muss das Löschen des Originals überleben", storage.resolve(targetId, copy).exists())
} finally {
File(File(context.filesDir, "photos"), targetId).deleteRecursively()
}
}
// --- Löschen -------------------------------------------------------------------------
@Test
fun loeschen_nicht_vorhandener_dateien_und_ordner_wirft_nicht() = runDbTest {
storage.delete(documentationId, "gibt_es_nicht.jpg")
storage.deleteAll(documentationId)
assertFalse(docDir().exists())
}
}
@@ -0,0 +1,228 @@
package de.medapp.wunddoku.pdf
import android.graphics.Bitmap
import android.graphics.pdf.PdfRenderer
import android.net.Uri
import android.os.ParcelFileDescriptor
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.signature.SignatureStorage
import de.medapp.wunddoku.testutil.FakePhotoStorage
import de.medapp.wunddoku.testutil.FakeSignatureStorage
import de.medapp.wunddoku.testutil.runDbTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.time.LocalDate
import java.util.UUID
/**
* Fehlerbehandlung des echten [AndroidPdfDocumentExporter] (echte Dateien im App-Speicher):
* Schreibfehler, kein Datenverlust bereits vorhandener PDFs, keine halbfertigen Dateien sowie
* graceful degradation bei fehlenden/defekten Fotos und Signaturen.
*/
@RunWith(AndroidJUnit4::class)
class PdfExportErrorHandlingTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val documentationId = "test-${UUID.randomUUID()}"
private val fileName = "2026-08-19_Max_Mustermann_1980-03-14.pdf"
private val pdfRoot get() = File(context.filesDir, "pdf")
private val targetDir get() = File(pdfRoot, documentationId)
private val targetFile get() = File(targetDir, fileName)
@Before
fun setUp() {
pdfRoot.mkdirs()
}
@After
fun tearDown() {
targetDir.deleteRecursively()
}
private fun exporter(
photoStorage: PhotoStorage = FakePhotoStorage(),
signatureStorage: SignatureStorage = FakeSignatureStorage(),
) = AndroidPdfDocumentExporter(context, photoStorage, signatureStorage)
private fun model(photoCount: Int = 0, signatureCount: Int = 0) = PdfExportModel(
documentationId = documentationId,
fileName = fileName,
patientFullName = "Max Mustermann",
patientBirthDate = LocalDate.of(1980, 3, 14),
patientDisplayAddress = "Musterstraße 1, 12345 Musterstadt",
createdAt = LocalDate.of(2026, 8, 19),
blocks = listOf(
PdfExportBlock(
title = "Wundgrund",
items = listOf(PdfExportItem(label = "Granulation", isFreeText = false, freeTextValue = "")),
),
),
signatures = (1..signatureCount).map { PdfExportSignature("Pfleger $it", "sig_$it.png") },
photos = (1..photoCount).map { PdfExportPhoto("foto_$it.jpg") },
)
private fun pageCount(file: File): Int =
ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { fd ->
PdfRenderer(fd).use { it.pageCount }
}
private fun tmpFiles(): List<File> = targetDir.listFiles().orEmpty().filter { it.name.endsWith(".tmp") }
// --- Normalfall als Referenz ---------------------------------------------------------
@Test
fun erfolgreicher_export_erzeugt_ein_lesbares_pdf_ohne_temporaere_datei() = runDbTest {
val result = exporter().export(model())
assertTrue("erwartet Success, war $result", result is PdfExportResult.Success)
assertTrue(targetFile.exists())
assertTrue(pageCount(targetFile) >= 1)
assertTrue(tmpFiles().isEmpty())
}
// --- Schreibfehler -------------------------------------------------------------------
@Test
fun zielordner_ist_eine_datei_liefert_fehler_statt_absturz() = runDbTest {
// Unter dem PDF-Wurzelordner liegt statt des Dokumentationsordners eine normale Datei.
targetDir.deleteRecursively()
targetDir.writeText("blockiert")
val result = exporter().export(model())
assertTrue("erwartet Failure, war $result", result is PdfExportResult.Failure)
assertTrue((result as PdfExportResult.Failure).error is PdfExportError.WriteFailed)
}
@Test
fun fehler_beim_schreiben_laesst_ein_vorhandenes_pdf_unangetastet() = runDbTest {
val first = exporter().export(model())
assertTrue(first is PdfExportResult.Success)
val sizeBefore = targetFile.length()
assertTrue(sizeBefore > 0)
// Zweiter Export scheitert: am Pfad der temporären Datei steht ein Ordner.
File(targetDir, "$fileName.tmp").mkdirs()
val second = exporter().export(model(signatureCount = 1))
assertTrue("erwartet Failure, war $second", second is PdfExportResult.Failure)
assertTrue("bisheriges PDF darf nicht verloren gehen", targetFile.exists())
assertEquals(sizeBefore, targetFile.length())
}
@Test
fun scheitert_das_umbenennen_bleibt_keine_halbfertige_tmp_datei_zurueck() = runDbTest {
// Am endgültigen Pfad steht ein nicht-leerer Ordner: Löschen/Umbenennen schlagen fehl.
targetFile.mkdirs()
File(targetFile, "inhalt.txt").writeText("x")
val result = exporter().export(model())
assertTrue("erwartet Failure, war $result", result is PdfExportResult.Failure)
assertTrue(tmpFiles().isEmpty())
}
@Test
fun erneuter_export_nach_einem_fehler_funktioniert() = runDbTest {
File(targetDir, "$fileName.tmp").mkdirs()
assertTrue(exporter().export(model()) is PdfExportResult.Failure)
File(targetDir, "$fileName.tmp").deleteRecursively()
val retry = exporter().export(model())
assertTrue("erwartet Success, war $retry", retry is PdfExportResult.Success)
}
@Test
fun wiederholter_export_ersetzt_die_vorhandene_datei_statt_weitere_anzulegen() = runDbTest {
exporter().export(model())
exporter().export(model(signatureCount = 1))
assertEquals(listOf(fileName), targetDir.listFiles().orEmpty().map { it.name })
}
// --- Fotos / Signaturen fehlen oder sind defekt --------------------------------------
@Test
fun fehlende_fotodateien_fuehren_zu_hinweisseiten_statt_zum_abbruch() = runDbTest {
// FakePhotoStorage.decodeFitted liefert null (= Datei fehlt/defekt).
val result = exporter().export(model(photoCount = 3))
assertTrue("erwartet Success, war $result", result is PdfExportResult.Success)
// Kopfseite + je eine Seite pro (nicht ladbarem) Foto
assertTrue("mindestens Kopfseite + 3 Fotoseiten", pageCount(targetFile) >= 4)
}
@Test
fun foto_dekodierung_mit_out_of_memory_fuehrt_nicht_zum_absturz() = runDbTest {
val oomStorage = object : PhotoStorage by FakePhotoStorage() {
override suspend fun decodeFitted(
documentationId: String,
fileName: String,
maxWidthPx: Int,
maxHeightPx: Int,
): Bitmap? = throw OutOfMemoryError("Test: zu wenig Speicher")
}
val result = exporter(photoStorage = oomStorage).export(model(photoCount = 2))
assertTrue("erwartet Success, war $result", result is PdfExportResult.Success)
assertTrue("mindestens Kopfseite + 2 Fotoseiten", pageCount(targetFile) >= 3)
assertTrue(tmpFiles().isEmpty())
}
@Test
fun foto_dekodierung_mit_io_fehler_fuehrt_nicht_zum_abbruch() = runDbTest {
val failing = object : PhotoStorage by FakePhotoStorage() {
override suspend fun decodeFitted(
documentationId: String,
fileName: String,
maxWidthPx: Int,
maxHeightPx: Int,
): Bitmap? = throw java.io.IOException("Test: Lesefehler")
}
val result = exporter(photoStorage = failing).export(model(photoCount = 1))
assertTrue(result is PdfExportResult.Success)
}
@Test
fun fehlende_signaturdatei_fuehrt_zu_platzhalter_statt_zum_abbruch() = runDbTest {
// FakeSignatureStorage.decode liefert standardmäßig null.
val result = exporter().export(model(signatureCount = 2))
assertTrue("erwartet Success, war $result", result is PdfExportResult.Success)
assertTrue(pageCount(targetFile) >= 1)
}
@Test
fun signatur_dekodierung_mit_exception_fuehrt_nicht_zum_abbruch() = runDbTest {
val failing = object : SignatureStorage by FakeSignatureStorage() {
override suspend fun decode(documentationId: String, fileName: String): Bitmap? =
throw IllegalStateException("Test: defekte Signatur")
}
val result = exporter(signatureStorage = failing).export(model(signatureCount = 1))
assertTrue("erwartet Success, war $result", result is PdfExportResult.Success)
}
@Test
fun grosser_export_mit_vielen_nicht_ladbaren_fotos_bleibt_stabil() = runDbTest {
val result = exporter().export(model(photoCount = 40, signatureCount = 3))
assertTrue("erwartet Success, war $result", result is PdfExportResult.Success)
assertFalse(tmpFiles().isNotEmpty())
assertTrue("mindestens Kopfseite + 40 Fotoseiten", pageCount(targetFile) >= 41)
}
}
@@ -0,0 +1,210 @@
package de.medapp.wunddoku.pdf
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.os.Bundle
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.UUID
/**
* Fehlerpfade von [AndroidPdfOpener], [AndroidPdfSharer] und [AndroidPdfImageSharer]:
* fehlende Datei, Datei außerhalb der FileProvider-Freigabe, keine passende App, verweigerter
* Zugriff. Der Activity-Start wird über einen [ContextWrapper] abgefangen - es öffnet sich
* dabei nie eine echte App.
*/
@RunWith(AndroidJUnit4::class)
class PdfIntentErrorHandlingTest {
private val baseContext: Context = InstrumentationRegistry.getInstrumentation().targetContext
private val id = "test-${UUID.randomUUID()}"
private lateinit var sharedPdf: File
private lateinit var sharedImage: File
private lateinit var unsharedFile: File
/** Fängt startActivity ab und wirft optional einen vorgegebenen Fehler. */
private class InterceptingContext(base: Context, private val failure: Throwable?) : ContextWrapper(base) {
val started = mutableListOf<Intent>()
override fun startActivity(intent: Intent) {
started += intent
failure?.let { throw it }
}
override fun startActivity(intent: Intent, options: Bundle?) {
started += intent
failure?.let { throw it }
}
}
@Before
fun setUp() {
// Liegt in einem per file_paths.xml freigegebenen Ordner ("pdf/" bzw. "pdf_images/").
sharedPdf = File(File(File(baseContext.filesDir, "pdf"), id), "test.pdf").apply {
parentFile!!.mkdirs()
writeBytes(byteArrayOf(1, 2, 3))
}
sharedImage = File(File(File(baseContext.filesDir, "pdf_images"), id), "seite_01.jpg").apply {
parentFile!!.mkdirs()
writeBytes(byteArrayOf(1, 2, 3))
}
// Liegt bewusst AUSSERHALB aller freigegebenen Ordner.
unsharedFile = File(baseContext.cacheDir, "nicht_freigegeben_$id.pdf").apply { writeBytes(byteArrayOf(1)) }
}
@After
fun tearDown() {
File(File(baseContext.filesDir, "pdf"), id).deleteRecursively()
File(File(baseContext.filesDir, "pdf_images"), id).deleteRecursively()
unsharedFile.delete()
}
// --- PdfOpener -----------------------------------------------------------------------
@Test
fun opener_meldet_fehlende_datei() {
val context = InterceptingContext(baseContext, null)
val result = AndroidPdfOpener(context).open(File(baseContext.filesDir, "pdf/$id/weg.pdf"))
assertEquals(PdfOpenResult.Failure.FileNotFound, result)
assertTrue(context.started.isEmpty())
}
@Test
fun opener_meldet_datei_ausserhalb_der_freigegebenen_ordner() {
val context = InterceptingContext(baseContext, null)
val result = AndroidPdfOpener(context).open(unsharedFile)
assertTrue("erwartet UriResolutionFailed, war $result", result is PdfOpenResult.Failure.UriResolutionFailed)
assertTrue(context.started.isEmpty())
}
@Test
fun opener_meldet_fehlenden_pdf_reader() {
val context = InterceptingContext(baseContext, ActivityNotFoundException("kein Reader"))
val result = AndroidPdfOpener(context).open(sharedPdf)
assertEquals(PdfOpenResult.Failure.NoViewerAvailable, result)
}
@Test
fun opener_meldet_verweigerten_zugriff_als_unerwarteten_fehler() {
val context = InterceptingContext(baseContext, SecurityException("verweigert"))
val result = AndroidPdfOpener(context).open(sharedPdf)
assertTrue("erwartet Unexpected, war $result", result is PdfOpenResult.Failure.Unexpected)
}
@Test
fun opener_faengt_beliebige_laufzeitfehler_beim_start_ab() {
val context = InterceptingContext(baseContext, IllegalStateException("kaputt"))
val result = AndroidPdfOpener(context).open(sharedPdf)
assertTrue("erwartet Unexpected, war $result", result is PdfOpenResult.Failure.Unexpected)
}
@Test
fun opener_gibt_nur_leserechte_frei() {
val context = InterceptingContext(baseContext, null)
val result = AndroidPdfOpener(context).open(sharedPdf)
assertEquals(PdfOpenResult.Success, result)
val intent = context.started.single()
assertEquals(Intent.ACTION_VIEW, intent.action)
assertEquals("application/pdf", intent.type)
assertNotEquals(0, intent.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION)
assertEquals(0, intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
}
// --- PdfSharer -----------------------------------------------------------------------
@Test
fun sharer_meldet_datei_ausserhalb_der_freigegebenen_ordner() {
val context = InterceptingContext(baseContext, null)
val result = AndroidPdfSharer(context).share(unsharedFile)
assertTrue("erwartet UriResolutionFailed, war $result", result is PdfShareResult.Failure.UriResolutionFailed)
assertTrue(context.started.isEmpty())
}
@Test
fun sharer_meldet_fehlende_app_zum_versenden() {
val context = InterceptingContext(baseContext, ActivityNotFoundException("keine App"))
assertEquals(PdfShareResult.Failure.NoAppAvailable, AndroidPdfSharer(context).share(sharedPdf))
}
@Test
fun sharer_meldet_verweigerten_zugriff_und_sonstige_fehler() {
assertTrue(
AndroidPdfSharer(InterceptingContext(baseContext, SecurityException("x"))).share(sharedPdf)
is PdfShareResult.Failure.Unexpected
)
assertTrue(
AndroidPdfSharer(InterceptingContext(baseContext, RuntimeException("x"))).share(sharedPdf)
is PdfShareResult.Failure.Unexpected
)
}
@Test
fun sharer_gibt_nur_leserechte_frei() {
val context = InterceptingContext(baseContext, null)
assertEquals(PdfShareResult.Success, AndroidPdfSharer(context).share(sharedPdf))
@Suppress("DEPRECATION")
val inner = context.started.single().getParcelableExtra<Intent>(Intent.EXTRA_INTENT)!!
assertEquals(Intent.ACTION_SEND, inner.action)
assertEquals("application/pdf", inner.type)
assertEquals(0, inner.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
}
// --- PdfImageSharer ------------------------------------------------------------------
@Test
fun image_sharer_meldet_leere_liste() {
val context = InterceptingContext(baseContext, null)
assertEquals(PdfImageShareResult.Failure.NoImages, AndroidPdfImageSharer(context).share(emptyList()))
assertTrue(context.started.isEmpty())
}
@Test
fun image_sharer_meldet_unfreigegebene_datei_auch_wenn_nur_eine_von_mehreren_betroffen_ist() {
val context = InterceptingContext(baseContext, null)
val result = AndroidPdfImageSharer(context).share(listOf(sharedImage, unsharedFile))
assertTrue("erwartet UriResolutionFailed, war $result", result is PdfImageShareResult.Failure.UriResolutionFailed)
assertTrue(context.started.isEmpty())
}
@Test
fun image_sharer_meldet_fehlende_app_und_verweigerten_zugriff() {
assertEquals(
PdfImageShareResult.Failure.NoAppAvailable,
AndroidPdfImageSharer(InterceptingContext(baseContext, ActivityNotFoundException("x"))).share(listOf(sharedImage)),
)
assertTrue(
AndroidPdfImageSharer(InterceptingContext(baseContext, SecurityException("x"))).share(listOf(sharedImage))
is PdfImageShareResult.Failure.Unexpected
)
}
}
@@ -0,0 +1,110 @@
package de.medapp.wunddoku.pdf
import android.graphics.Paint
import android.graphics.pdf.PdfDocument
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import de.medapp.wunddoku.testutil.runDbTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
/** Fehlerbehandlung von [AndroidPdfToImagesConverter] mit echten PDF-Dateien. */
@RunWith(AndroidJUnit4::class)
class PdfToImagesErrorHandlingTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val converter = AndroidPdfToImagesConverter(context)
private val documentationId = "test-${UUID.randomUUID()}"
private lateinit var workDir: File
private val imagesDir get() = File(File(context.filesDir, "pdf_images"), documentationId)
@Before
fun setUp() {
workDir = File(context.cacheDir, "errtest-${UUID.randomUUID()}").apply { mkdirs() }
}
@After
fun tearDown() {
imagesDir.deleteRecursively()
workDir.deleteRecursively()
}
private fun validPdf(pages: Int = 1): File {
val file = File(workDir, "gueltig.pdf")
val document = PdfDocument()
repeat(pages) { index ->
val page = document.startPage(PdfDocument.PageInfo.Builder(595, 842, index + 1).create())
page.canvas.drawText("Seite ${index + 1}", 50f, 50f, Paint().apply { textSize = 20f })
document.finishPage(page)
}
FileOutputStream(file).use { document.writeTo(it) }
document.close()
return file
}
@Test
fun fehlende_pdf_liefert_file_not_found() = runDbTest {
val result = converter.convert(documentationId, File(workDir, "gibt_es_nicht.pdf"))
assertEquals(PdfToImagesResult.Failure.FileNotFound, result)
}
@Test
fun beschaedigte_pdf_liefert_fehler_statt_absturz_und_hinterlaesst_keine_bilder() = runDbTest {
val corrupt = File(workDir, "defekt.pdf").apply { writeBytes(ByteArray(1024) { (it % 251).toByte() }) }
val result = converter.convert(documentationId, corrupt)
assertTrue("erwartet Failure, war $result", result is PdfToImagesResult.Failure)
assertTrue(imagesDir.listFiles().orEmpty().isEmpty())
}
@Test
fun leere_pdf_datei_liefert_fehler_statt_absturz() = runDbTest {
val empty = File(workDir, "leer.pdf").apply { createNewFile() }
val result = converter.convert(documentationId, empty)
assertTrue("erwartet Failure, war $result", result is PdfToImagesResult.Failure)
}
@Test
fun gueltige_pdf_wird_seitenweise_in_bilder_umgewandelt() = runDbTest {
val result = converter.convert(documentationId, validPdf(pages = 2))
assertTrue("erwartet Success, war $result", result is PdfToImagesResult.Success)
val files = (result as PdfToImagesResult.Success).imageFiles
assertEquals(2, files.size)
assertTrue(files.all { it.exists() && it.length() > 0 })
}
@Test
fun veraltete_bilder_eines_frueheren_exports_werden_entfernt() = runDbTest {
imagesDir.mkdirs()
val stale = File(imagesDir, "seite_09.jpg").apply { writeText("alt") }
val result = converter.convert(documentationId, validPdf(pages = 1))
assertTrue(result is PdfToImagesResult.Success)
assertFalse("veraltete Seite darf nicht mitgeteilt werden", stale.exists())
}
@Test
fun nach_einem_fehler_funktioniert_die_naechste_umwandlung() = runDbTest {
val corrupt = File(workDir, "defekt.pdf").apply { writeBytes(ByteArray(64) { 1 }) }
assertTrue(converter.convert(documentationId, corrupt) is PdfToImagesResult.Failure)
val result = converter.convert(documentationId, validPdf())
assertTrue(result is PdfToImagesResult.Success)
}
}
@@ -0,0 +1,240 @@
package de.medapp.wunddoku.privacy
import android.content.ComponentName
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.view.WindowManager
import androidx.core.content.FileProvider
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import de.medapp.wunddoku.MainActivity
import de.medapp.wunddoku.R
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.xmlpull.v1.XmlPullParser
import java.io.File
import java.util.UUID
/**
* Datenschutz-Prüfung (QA-006) der ZUSAMMENGEFÜHRTEN, tatsächlich installierten App: Das fertige
* Manifest enthält auch Einträge aus Bibliotheken, die im eigenen Manifest nicht sichtbar sind.
*/
@RunWith(AndroidJUnit4::class)
class PrivacyConfigurationTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val packageName = context.packageName
private val id = "test-${UUID.randomUUID()}"
@Before
fun setUp() {
File(context.filesDir, "photos/$id").mkdirs()
}
@After
fun tearDown() {
listOf("photos", "signatures", "pdf", "pdf_images").forEach {
File(File(context.filesDir, it), id).deleteRecursively()
}
}
@Suppress("DEPRECATION")
private fun packageInfo(flags: Int) = context.packageManager.getPackageInfo(packageName, flags)
// --- Berechtigungen ------------------------------------------------------------------
@Test
fun app_fordert_keine_gefaehrlichen_oder_unnoetigen_berechtigungen_an() {
val requested = packageInfo(PackageManager.GET_PERMISSIONS).requestedPermissions?.toList().orEmpty()
val forbidden = listOf(
"android.permission.INTERNET",
"android.permission.ACCESS_NETWORK_STATE",
"android.permission.CAMERA",
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.WRITE_EXTERNAL_STORAGE",
"android.permission.MANAGE_EXTERNAL_STORAGE",
"android.permission.READ_MEDIA_IMAGES",
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.ACCESS_COARSE_LOCATION",
"android.permission.READ_CONTACTS",
"android.permission.READ_PHONE_STATE",
"android.permission.RECORD_AUDIO",
)
assertEquals("Verbotene Berechtigungen: ${requested.filter { it in forbidden }}", emptyList<String>(), requested.filter { it in forbidden })
}
@Test
fun app_hat_ausser_dem_androidx_interna_keine_weiteren_berechtigungen() {
val requested = packageInfo(PackageManager.GET_PERMISSIONS).requestedPermissions?.toList().orEmpty()
// Die Signatur-Berechtigung von androidx (Schutz dynamischer Receiver) ist unkritisch.
val allowed = setOf("$packageName.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION")
val unexpected = requested.filterNot { it in allowed }
assertTrue("Unerwartete Berechtigungen im zusammengeführten Manifest: $unexpected", unexpected.isEmpty())
}
// --- Backup --------------------------------------------------------------------------
@Test
fun backup_ist_deaktiviert() {
val flags = context.applicationInfo.flags
assertEquals("android:allowBackup muss false sein", 0, flags and ApplicationInfo.FLAG_ALLOW_BACKUP)
}
@Test
fun data_extraction_rules_schliessen_alles_aus() {
val parser = context.resources.getXml(R.xml.data_extraction_rules)
var section: String? = null
val excluded = mutableMapOf<String, MutableSet<String>>()
var includeFound = false
while (parser.next() != XmlPullParser.END_DOCUMENT) {
if (parser.eventType == XmlPullParser.START_TAG) {
when (parser.name) {
"cloud-backup", "device-transfer" -> {
section = parser.name
excluded[parser.name] = mutableSetOf()
}
"exclude" -> {
val domain = parser.getAttributeValue(null, "domain")
val current = section
if (domain != null && current != null) excluded[current]?.add(domain)
}
"include" -> includeFound = true
}
} else if (parser.eventType == XmlPullParser.END_TAG && parser.name == section) {
section = null
}
}
assertFalse("Kein <include> erlaubt", includeFound)
assertEquals(setOf("cloud-backup", "device-transfer"), excluded.keys)
excluded.forEach { (name, domains) ->
assertTrue("$name muss database, file und sharedpref ausschließen, war $domains", domains.containsAll(setOf("database", "file", "sharedpref")))
}
}
// --- Exportierte Komponenten ---------------------------------------------------------
@Test
fun ausser_der_start_activity_sind_keine_eigenen_komponenten_exportiert() {
val info = packageInfo(
PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or
PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS
)
// Debug-Builds enthalten zusätzlich Test-/Tooling-Activities von androidx (nicht im Release).
val toolingPrefixes = listOf("androidx.compose.ui.tooling", "androidx.activity.ComponentActivity", "androidx.compose.ui.test")
val exportedActivities = info.activities.orEmpty().filter { it.exported }.map { it.name }
.filterNot { name -> toolingPrefixes.any { name.startsWith(it) } }
assertEquals(listOf(MainActivity::class.java.name), exportedActivities)
val exportedServices = info.services.orEmpty().filter { it.exported }.map { it.name }
assertTrue("Exportierte Services: $exportedServices", exportedServices.isEmpty())
val exportedProviders = info.providers.orEmpty().filter { it.exported }.map { it.name }
assertTrue("Exportierte ContentProvider: $exportedProviders", exportedProviders.isEmpty())
// androidx.profileinstaller ist per Systemberechtigung (DUMP) geschützt und unkritisch.
val exportedReceivers = info.receivers.orEmpty().filter { it.exported }.map { it.name }
.filterNot { it == "androidx.profileinstaller.ProfileInstallReceiver" }
assertTrue("Exportierte Receiver: $exportedReceivers", exportedReceivers.isEmpty())
}
@Test
fun fileprovider_ist_nicht_exportiert_und_vergibt_nur_gezielte_rechte() {
val provider = context.packageManager.getProviderInfo(
ComponentName(packageName, FileProvider::class.java.name),
PackageManager.GET_META_DATA,
)
assertFalse(provider.exported)
assertTrue(provider.grantUriPermissions)
assertEquals("$packageName.fileprovider", provider.authority)
}
// --- FileProvider-Freigaben (minimale Freigabe) --------------------------------------
private fun uriFor(file: File) = FileProvider.getUriForFile(context, "$packageName.fileprovider", file)
@Test
fun pdf_und_bildexporte_sind_freigegeben() {
val pdf = File(context.filesDir, "pdf/$id/x.pdf").apply { parentFile!!.mkdirs(); writeText("x") }
val image = File(context.filesDir, "pdf_images/$id/s.jpg").apply { parentFile!!.mkdirs(); writeText("x") }
assertTrue(uriFor(pdf).toString().startsWith("content://$packageName.fileprovider/"))
assertTrue(uriFor(image).toString().startsWith("content://$packageName.fileprovider/"))
}
@Test
fun originalfotos_und_signaturen_sind_nicht_ueber_den_fileprovider_freigabefaehig() {
val photo = File(context.filesDir, "photos/$id/foto.jpg").apply { parentFile!!.mkdirs(); writeText("x") }
val signature = File(context.filesDir, "signatures/$id/sig.png").apply { parentFile!!.mkdirs(); writeText("x") }
for (file in listOf(photo, signature)) {
try {
uriFor(file)
fail("${file.parentFile!!.parentFile!!.name}/ darf nicht freigebbar sein")
} catch (expected: IllegalArgumentException) {
// erwartet: nicht in file_paths.xml konfiguriert
}
}
}
@Test
fun datenbank_und_sonstige_interne_dateien_sind_nicht_freigabefaehig() {
val database = context.getDatabasePath("irgendeine.db")
val other = File(context.filesDir, "geheim_$id.txt").apply { writeText("x") }
try {
uriFor(other)
fail("Dateien im Wurzelordner von filesDir dürfen nicht freigebbar sein")
} catch (expected: IllegalArgumentException) {
} finally {
other.delete()
}
try {
uriFor(database)
fail("Die Datenbank darf nie freigebbar sein")
} catch (expected: IllegalArgumentException) {
}
}
// --- Speicherort ---------------------------------------------------------------------
@Test
fun dokumentationsdaten_liegen_im_privaten_internen_speicher_und_sind_nicht_fuer_andere_lesbar() {
val file = File(context.filesDir, "photos/$id/foto.jpg").apply { writeText("x") }
assertTrue(file.canonicalPath.startsWith(context.filesDir.canonicalPath))
val external = context.getExternalFilesDir(null)?.canonicalPath
if (external != null) assertFalse(file.canonicalPath.startsWith(external))
val permissions = java.nio.file.Files.getPosixFilePermissions(file.toPath())
assertFalse("Datei darf nicht 'others'-lesbar sein: $permissions", permissions.contains(java.nio.file.attribute.PosixFilePermission.OTHERS_READ))
assertFalse("Datei darf nicht 'others'-schreibbar sein: $permissions", permissions.contains(java.nio.file.attribute.PosixFilePermission.OTHERS_WRITE))
}
// --- Bildschirmschutz ----------------------------------------------------------------
@Test
fun hauptfenster_verwendet_flag_secure_gegen_screenshots_und_app_switcher_vorschau() {
var flags = 0
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
scenario.onActivity { activity -> flags = activity.window.attributes.flags }
}
assertNotEquals("FLAG_SECURE muss gesetzt sein", 0, flags and WindowManager.LayoutParams.FLAG_SECURE)
}
}
@@ -0,0 +1,114 @@
package de.medapp.wunddoku.signature
import android.graphics.Bitmap
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import de.medapp.wunddoku.testutil.blankBitmap
import de.medapp.wunddoku.testutil.expectThrowsSuspend
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.UUID
/**
* Fehlerbehandlung von [InternalSignatureStorage] mit echten Dateien (eigenes Test-
* Wurzelverzeichnis, berührt keine echten Signaturen).
*/
@RunWith(AndroidJUnit4::class)
class SignatureStorageErrorHandlingTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val rootDirName = "test_signatures_${UUID.randomUUID()}"
private lateinit var storage: InternalSignatureStorage
private val documentationId = "doc-1"
@Before
fun setUp() {
storage = InternalSignatureStorage(context, rootDirName)
}
@After
fun tearDown() {
File(context.filesDir, rootDirName).deleteRecursively()
}
private fun filesInDocDir(): List<File> =
File(File(context.filesDir, rootDirName), documentationId).listFiles()?.toList().orEmpty()
@Test
fun speichern_einer_recycelten_bitmap_wirft_und_hinterlaesst_keine_datei() = runDbTest {
val recycled = signatureBitmap().also { it.recycle() }
expectThrowsSuspend<Exception> { storage.save(documentationId, recycled) }
assertTrue("keine leere PNG-Datei als scheinbar gültige Signatur", filesInDocDir().isEmpty())
}
@Test
fun nach_fehlgeschlagenem_speichern_funktioniert_das_naechste_speichern() = runDbTest {
val recycled = signatureBitmap().also { it.recycle() }
expectThrowsSuspend<Exception> { storage.save(documentationId, recycled) }
val fileName = storage.save(documentationId, signatureBitmap())
assertTrue(storage.resolve(documentationId, fileName).exists())
assertEquals(1, filesInDocDir().size)
}
@Test
fun gespeicherte_signatur_laesst_sich_wieder_dekodieren() = runDbTest {
val fileName = storage.save(documentationId, signatureBitmap())
val decoded = storage.decode(documentationId, fileName)
assertNotNull(decoded)
assertEquals(40, decoded!!.width)
decoded.recycle()
}
@Test
fun decode_einer_fehlenden_datei_liefert_null() = runDbTest {
assertNull(storage.decode(documentationId, "fehlt.png"))
}
@Test
fun decode_einer_beschaedigten_datei_liefert_null_statt_abzustuerzen() = runDbTest {
val target = storage.resolve(documentationId, "defekt.png")
target.parentFile!!.mkdirs()
target.writeBytes(ByteArray(256) { 3 })
assertNull(storage.decode(documentationId, "defekt.png"))
}
@Test
fun decode_einer_leeren_datei_liefert_null() = runDbTest {
val target = storage.resolve(documentationId, "leer.png")
target.parentFile!!.mkdirs()
target.createNewFile()
assertNull(storage.decode(documentationId, "leer.png"))
}
@Test
fun loeschen_nicht_vorhandener_dateien_wirft_nicht() = runDbTest {
storage.delete(documentationId, "gibt_es_nicht.png")
storage.deleteAll(documentationId)
assertFalse(File(File(context.filesDir, rootDirName), documentationId).exists())
}
@Test
fun leere_zeichnung_wird_von_der_validierung_erkannt() {
val blank: Bitmap = blankBitmap()
assertTrue(SignatureBitmapValidation.isBlank(blank))
assertFalse(SignatureBitmapValidation.isBlank(signatureBitmap()))
}
}
@@ -0,0 +1,198 @@
package de.medapp.wunddoku.testutil
import android.graphics.Bitmap
import android.net.Uri
import de.medapp.wunddoku.data.dao.CheckItemStateDao
import de.medapp.wunddoku.data.dao.DocumentationDao
import de.medapp.wunddoku.data.dao.PatientDao
import de.medapp.wunddoku.data.entity.PatientEntity
import de.medapp.wunddoku.data.entity.relations.DocumentationWithDetails
import de.medapp.wunddoku.data.dao.PhotoDao
import de.medapp.wunddoku.data.dao.SignatureDao
import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.data.repository.WoundCarerRepository
import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.pdf.DocumentationPdfExporter
import de.medapp.wunddoku.pdf.PdfToImagesConverter
import de.medapp.wunddoku.signature.SignatureStorage
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.delay
import java.io.File
/**
* Hilfsklassen für die Fehlerbehandlungs-Tests. Die bestehenden Fakes (Fakes.kt) bleiben
* unverändert; hier werden sie per Delegation um gezielt auslösbare Fehler ergänzt.
*/
/** [PhotoStorage] mit konfigurierbaren Fehlern pro Aufruf (1-basierte Aufrufnummer). */
class FailingPhotoStorage(
val delegate: FakePhotoStorage = FakePhotoStorage(),
) : PhotoStorage by delegate {
/** Liefert für die n-te [importFrom]-Anweisung einen Fehler (oder `null` = Erfolg). */
var importFailure: (Int) -> Throwable? = { null }
private var importCalls = 0
override suspend fun importFrom(documentationId: String, sourceUri: Uri): String {
importCalls++
importFailure(importCalls)?.let { throw it }
return delegate.importFrom(documentationId, sourceUri)
}
}
/** [SignatureStorage] mit gezielt auslösbarem Fehler beim Speichern. */
class FailingSignatureStorage(
val delegate: FakeSignatureStorage = FakeSignatureStorage(),
) : SignatureStorage by delegate {
var saveFailure: Throwable? = null
override suspend fun save(documentationId: String, bitmap: Bitmap): String {
saveFailure?.let { throw it }
return delegate.save(documentationId, bitmap)
}
}
/** Exporter, der eine beliebige Exception/Error wirft (statt ein Failure-Ergebnis zu liefern). */
class ThrowingPdfExporter(private val error: Throwable) : DocumentationPdfExporter {
override suspend fun export(model: de.medapp.wunddoku.pdf.PdfExportModel): de.medapp.wunddoku.pdf.PdfExportResult =
throw error
}
/** Converter, der eine beliebige Exception/Error wirft. */
class ThrowingPdfToImagesConverter(private val error: Throwable) : PdfToImagesConverter {
override suspend fun convert(documentationId: String, pdfFile: File): de.medapp.wunddoku.pdf.PdfToImagesResult =
throw error
}
/** [CheckItemStateDao] mit abschaltbaren Schreibfehlern (z. B. "database or disk is full"). */
class FailingCheckItemStateDao(
private val delegate: CheckItemStateDao,
) : CheckItemStateDao by delegate {
@Volatile var failure: Throwable? = null
override suspend fun setChecked(id: String, checked: Boolean) {
failure?.let { throw it }
delegate.setChecked(id, checked)
}
override suspend fun updateFreeTextValue(id: String, value: String) {
failure?.let { throw it }
delegate.updateFreeTextValue(id, value)
}
}
/** [DocumentationDao] mit abschaltbarem Fehler beim Laden einer kompletten Dokumentation. */
class FailingDocumentationDao(
private val delegate: DocumentationDao,
) : DocumentationDao by delegate {
@Volatile var loadFailure: Throwable? = null
override suspend fun getFullDocumentation(id: String): DocumentationWithDetails? {
loadFailure?.let { throw it }
return delegate.getFullDocumentation(id)
}
}
/** [PatientDao] mit abschaltbarem Fehler beim Einfügen. */
class FailingPatientDao(
private val delegate: PatientDao,
) : PatientDao by delegate {
@Volatile var insertFailure: Throwable? = null
override suspend fun insert(patient: PatientEntity) {
insertFailure?.let { throw it }
delegate.insert(patient)
}
}
/** [PhotoDao] mit abschaltbarem Fehler beim Einfügen. */
class FailingPhotoDao(
private val delegate: PhotoDao,
) : PhotoDao by delegate {
@Volatile var insertFailure: Throwable? = null
override suspend fun insert(photo: de.medapp.wunddoku.data.entity.PhotoEntity) {
insertFailure?.let { throw it }
delegate.insert(photo)
}
}
/** [SignatureDao] mit abschaltbarem Fehler beim Einfügen. */
class FailingSignatureDao(
private val delegate: SignatureDao,
) : SignatureDao by delegate {
@Volatile var insertFailure: Throwable? = null
override suspend fun insert(signature: de.medapp.wunddoku.data.entity.SignatureEntity) {
insertFailure?.let { throw it }
delegate.insert(signature)
}
}
/** Baut ein [DocumentationRepository] auf der echten In-Memory-DB mit austauschbaren Bausteinen. */
fun TestEnvironment.buildRepository(
photoStorage: PhotoStorage = this.photoStorage,
signatureStorage: SignatureStorage = this.signatureStorage,
woundCarerRepository: WoundCarerRepository = this.woundCarerRepository,
pdfExporter: DocumentationPdfExporter = this.pdfExporter,
pdfToImagesConverter: PdfToImagesConverter = this.pdfToImagesConverter,
checkItemStateDao: CheckItemStateDao = db.checkItemStateDao(),
photoDao: PhotoDao = db.photoDao(),
signatureDao: SignatureDao = db.signatureDao(),
documentationDao: DocumentationDao = db.documentationDao(),
exportedFilesCleaner: de.medapp.wunddoku.pdf.ExportedFilesCleaner = de.medapp.wunddoku.pdf.ExportedFilesCleaner.None,
): DocumentationRepository = DocumentationRepository(
documentationDao = documentationDao,
blockStateDao = db.blockStateDao(),
checkItemStateDao = checkItemStateDao,
photoDao = photoDao,
signatureDao = signatureDao,
patientDao = db.patientDao(),
photoStorage = photoStorage,
signatureStorage = signatureStorage,
woundCarerRepository = woundCarerRepository,
pdfExporter = pdfExporter,
pdfToImagesConverter = pdfToImagesConverter,
db = db,
exportedFilesCleaner = exportedFilesCleaner,
)
/** Erwartet, dass der Suspend-Block eine Exception vom Typ [T] wirft, und liefert sie zurück. */
suspend inline fun <reified T : Throwable> expectThrowsSuspend(block: () -> Unit): T {
try {
block()
} catch (e: Throwable) {
if (e is T) return e
throw e
}
throw AssertionError("Erwartet: ${T::class.java.simpleName}, es wurde aber keine Exception geworfen")
}
/** Wartet (max. [timeoutMs]) bis [condition] wahr ist - für asynchrone ViewModel-Zustände. */
suspend fun awaitCondition(timeoutMs: Long = 5_000, condition: () -> Boolean) {
withTimeout(timeoutMs) {
while (!condition()) delay(20)
}
}
/** Wie [awaitCondition], aber mit Suspend-Bedingung (z. B. Abfrage der Datenbank). */
suspend fun awaitUntil(timeoutMs: Long = 5_000, condition: suspend () -> Boolean) {
withTimeout(timeoutMs) {
while (!condition()) delay(20)
}
}
/** Führt [block] auf dem Main-Thread aus (ViewModels greifen intern auf Main-State zu). */
fun <T> onMain(block: () -> T): T {
var result: Result<T>? = null
androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().runOnMainSync {
result = runCatching(block)
}
return result!!.getOrThrow()
}
@@ -0,0 +1,138 @@
package de.medapp.wunddoku.testutil
import android.graphics.Bitmap
import android.net.Uri
import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.pdf.DocumentationPdfExporter
import de.medapp.wunddoku.pdf.PdfExportModel
import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesConverter
import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.signature.SignatureStorage
import java.io.File
import java.io.IOException
/** Aufgezeichneter Aufruf von [PhotoStorage.duplicate]. */
data class DuplicateCall(
val sourceDocumentationId: String,
val sourceFileName: String,
val targetDocumentationId: String,
)
/**
* In-Memory-Fake für [PhotoStorage]: schreibt keine Dateien, zeichnet aber alle Aufrufe auf und
* vergibt eindeutige Dateinamen. Fehler lassen sich gezielt auslösen.
*/
class FakePhotoStorage : PhotoStorage {
val imported = mutableListOf<Pair<String, Uri>>()
val duplicated = mutableListOf<DuplicateCall>()
val deleted = mutableListOf<Pair<String, String>>()
val deletedAll = mutableListOf<String>()
var failOnDelete = false
var failOnDeleteAll = false
/** 1-basierte Nummer des [duplicate]-Aufrufs, der mit einer [IOException] fehlschlagen soll. */
var failOnDuplicateCall: Int? = null
private var counter = 0
override suspend fun importFrom(documentationId: String, sourceUri: Uri): String {
imported += documentationId to sourceUri
return "imported_${++counter}.jpg"
}
override suspend fun duplicate(
sourceDocumentationId: String,
sourceFileName: String,
targetDocumentationId: String,
): String {
duplicated += DuplicateCall(sourceDocumentationId, sourceFileName, targetDocumentationId)
if (failOnDuplicateCall == duplicated.size) throw IOException("Kopieren fehlgeschlagen (Test)")
return "copy_${++counter}.jpg"
}
override suspend fun delete(documentationId: String, fileName: String) {
if (failOnDelete) throw IOException("Löschen fehlgeschlagen (Test)")
deleted += documentationId to fileName
}
override suspend fun deleteAll(documentationId: String) {
if (failOnDeleteAll) throw IOException("Ordner löschen fehlgeschlagen (Test)")
deletedAll += documentationId
}
override fun resolve(documentationId: String, fileName: String): File =
File("/fake/photos/$documentationId/$fileName")
override suspend fun decodeThumbnail(documentationId: String, fileName: String, maxDimensionPx: Int): Bitmap? = null
override suspend fun decodeFitted(
documentationId: String,
fileName: String,
maxWidthPx: Int,
maxHeightPx: Int,
): Bitmap? = null
}
/** In-Memory-Fake für [SignatureStorage] (analog [FakePhotoStorage]). */
class FakeSignatureStorage : SignatureStorage {
val saved = mutableListOf<Pair<String, String>>() // key (documentationId bzw. woundCarerId) -> fileName
val deleted = mutableListOf<Pair<String, String>>()
val deletedAll = mutableListOf<String>()
val decodeCalls = mutableListOf<Pair<String, String>>()
var failOnDelete = false
var failOnDeleteAll = false
var decodeResult: Bitmap? = null
private var counter = 0
override suspend fun save(documentationId: String, bitmap: Bitmap): String {
val fileName = "sig_${++counter}.png"
saved += documentationId to fileName
return fileName
}
override suspend fun delete(documentationId: String, fileName: String) {
if (failOnDelete) throw IOException("Löschen fehlgeschlagen (Test)")
deleted += documentationId to fileName
}
override suspend fun deleteAll(documentationId: String) {
if (failOnDeleteAll) throw IOException("Ordner löschen fehlgeschlagen (Test)")
deletedAll += documentationId
}
override fun resolve(documentationId: String, fileName: String): File =
File("/fake/signatures/$documentationId/$fileName")
override suspend fun decode(documentationId: String, fileName: String): Bitmap? {
decodeCalls += documentationId to fileName
return decodeResult
}
}
/** Zeichnet die an den Exporter übergebenen Modelle auf und liefert ein konfigurierbares Ergebnis. */
class FakePdfExporter : DocumentationPdfExporter {
val models = mutableListOf<PdfExportModel>()
var result: ((PdfExportModel) -> PdfExportResult) = { model ->
PdfExportResult.Success(File("/fake/pdfs/${model.fileName}"))
}
override suspend fun export(model: PdfExportModel): PdfExportResult {
models += model
return result(model)
}
}
/** Zeichnet Aufrufe auf und liefert ein konfigurierbares Ergebnis. */
class FakePdfToImagesConverter : PdfToImagesConverter {
val calls = mutableListOf<Pair<String, File>>()
var result: PdfToImagesResult = PdfToImagesResult.Success(emptyList())
override suspend fun convert(documentationId: String, pdfFile: File): PdfToImagesResult {
calls += documentationId to pdfFile
return result
}
}
@@ -0,0 +1,133 @@
package de.medapp.wunddoku.testutil
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import androidx.room.Room
import androidx.test.platform.app.InstrumentationRegistry
import de.medapp.wunddoku.data.database.AppDatabase
import de.medapp.wunddoku.data.entity.PatientEntity
import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.data.repository.PatientRepository
import de.medapp.wunddoku.data.repository.WoundCarerRepository
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.DocumentationBlock
import de.medapp.wunddoku.domain.model.DocumentationStatus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.runBlocking
/**
* Gemeinsame Testumgebung für alle Instrumented-Tests: echte Room-Datenbank im Arbeitsspeicher
* (gleiche Entities/DAOs/Foreign Keys wie in Produktion), aber Fakes für Dateispeicher und
* PDF-Erzeugung. Jeder Test erzeugt eine eigene Instanz und ruft am Ende [close] auf.
*/
class TestEnvironment {
val db: AppDatabase = Room.inMemoryDatabaseBuilder(
InstrumentationRegistry.getInstrumentation().targetContext,
AppDatabase::class.java,
).build()
val photoStorage = FakePhotoStorage()
val signatureStorage = FakeSignatureStorage()
val woundCarerSignatureStorage = FakeSignatureStorage()
val pdfExporter = FakePdfExporter()
val pdfToImagesConverter = FakePdfToImagesConverter()
val patientRepository = PatientRepository(patientDao = db.patientDao())
val woundCarerRepository = WoundCarerRepository(
woundCarerDao = db.woundCarerDao(),
woundCarerSignatureStorage = woundCarerSignatureStorage,
)
val documentationRepository = DocumentationRepository(
documentationDao = db.documentationDao(),
blockStateDao = db.blockStateDao(),
checkItemStateDao = db.checkItemStateDao(),
photoDao = db.photoDao(),
signatureDao = db.signatureDao(),
patientDao = db.patientDao(),
photoStorage = photoStorage,
signatureStorage = signatureStorage,
woundCarerRepository = woundCarerRepository,
pdfExporter = pdfExporter,
pdfToImagesConverter = pdfToImagesConverter,
db = db,
)
fun close() = db.close()
/** Legt direkt über das DAO einen Patienten an (ohne Repository-Logik). */
suspend fun insertPatient(
id: String = "patient-1",
firstName: String = "Max",
lastName: String = "Mustermann",
birthDate: String = "1980-03-14",
street: String = "Musterstraße 1",
postalCode: String = "12345",
city: String = "Musterstadt",
): PatientEntity {
val entity = PatientEntity(
id = id,
firstName = firstName,
lastName = lastName,
birthDate = birthDate,
street = street,
postalCode = postalCode,
city = city,
phone1 = "0123 456789",
phone2 = null,
phone3 = null,
createdAt = 1_000L,
updatedAt = 1_000L,
)
db.patientDao().insert(entity)
return entity
}
/** Setzt createdAt einer Dokumentation gezielt (vermeidet zeitabhängige Tests). */
suspend fun setCreatedAt(documentationId: String, createdAt: Long) {
val dao = db.documentationDao()
dao.update(dao.getById(documentationId)!!.copy(createdAt = createdAt))
}
suspend fun setStatus(documentationId: String, status: DocumentationStatus) {
val dao = db.documentationDao()
dao.update(dao.getById(documentationId)!!.copy(status = status))
}
}
/** Führt einen Test mit Suspend-Aufrufen aus; Rückgabetyp Unit, damit JUnit die Methode akzeptiert. */
fun runDbTest(block: suspend CoroutineScope.() -> Unit) {
runBlocking { block() }
}
/** Erstes Item eines Blocks, das eine normale Checkbox ist (kein Freitext, hint oder strukturierte Eingabe). */
fun DocumentationBlock.firstPlainItem(): CheckItem =
items.first { !it.isFreeText && !it.isStaticHint && it.inputSpec.isEmpty() }
/** Weißes Bitmap ohne jeden Strich - gilt als "leere Signatur". */
fun blankBitmap(): Bitmap =
Bitmap.createBitmap(40, 20, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.WHITE) }
/** Weißes Bitmap mit einem schwarzen Strich - gilt als gültige Signatur. */
fun signatureBitmap(): Bitmap = blankBitmap().also { bitmap ->
val paint = Paint().apply {
color = Color.BLACK
strokeWidth = 3f
}
Canvas(bitmap).drawLine(2f, 2f, 38f, 18f, paint)
}
/** Erwartet, dass [block] eine Exception vom Typ [T] wirft, und liefert sie zurück. */
inline fun <reified T : Throwable> expectThrows(block: () -> Unit): T {
try {
block()
} catch (e: Throwable) {
if (e is T) return e
throw e
}
throw AssertionError("Erwartet: ${T::class.java.simpleName}, es wurde aber keine Exception geworfen")
}
@@ -0,0 +1,473 @@
package de.medapp.wunddoku.ui.documentation.edit
import android.database.sqlite.SQLiteFullException
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.testutil.FailingCheckItemStateDao
import de.medapp.wunddoku.testutil.FailingDocumentationDao
import de.medapp.wunddoku.testutil.FailingPhotoStorage
import de.medapp.wunddoku.testutil.FailingSignatureStorage
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.ThrowingPdfExporter
import de.medapp.wunddoku.testutil.awaitCondition
import de.medapp.wunddoku.testutil.awaitUntil
import de.medapp.wunddoku.testutil.blankBitmap
import de.medapp.wunddoku.testutil.buildRepository
import de.medapp.wunddoku.testutil.firstPlainItem
import de.medapp.wunddoku.testutil.onMain
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.io.IOException
/**
* Fehlerbehandlung im [DocumentationEditViewModel] mit echter In-Memory-Room-DB: Autosave bei
* vollem Speicher (Datenverlust verhindern), Fotoimport (defekte Bilder, Speichermangel),
* Signatur, PDF-Export und Teilen. Kein Fehler darf einen Absturz oder einen dauerhaft
* hängenden Ladezustand verursachen.
*/
@RunWith(AndroidJUnit4::class)
class DocumentationEditViewModelErrorHandlingTest {
private lateinit var env: TestEnvironment
private lateinit var documentationId: String
private val someUri: Uri = Uri.parse("content://test/bild/1")
@Before
fun setUp() = runDbTest {
env = TestEnvironment()
env.insertPatient()
documentationId = env.documentationRepository.createEmpty("patient-1").id
}
@After
fun tearDown() {
env.close()
}
private suspend fun createViewModel(
repository: DocumentationRepository = env.documentationRepository,
id: String = documentationId,
): DocumentationEditViewModel {
val viewModel = onMain { DocumentationEditViewModel(id, repository, env.woundCarerRepository) }
awaitCondition { !viewModel.uiState.value.isLoading }
return viewModel
}
private fun DocumentationEditViewModel.plainItem(): CheckItem =
uiState.value.documentation!!.blocks.first().firstPlainItem()
private fun DocumentationEditViewModel.freeTextItem(): CheckItem =
uiState.value.documentation!!.blocks.first().items.first { it.isFreeText }
private fun DocumentationEditViewModel.itemById(id: String): CheckItem =
uiState.value.documentation!!.blocks.flatMap { it.items }.first { it.checkItemStateId == id }
private suspend fun storedFreeText(itemId: String): String? =
env.documentationRepository.getFullDocumentation(documentationId)
?.blocks?.flatMap { it.items }?.firstOrNull { it.checkItemStateId == itemId }?.freeTextValue
// --- Laden ---------------------------------------------------------------------------
@Test
fun unbekannte_dokumentation_zeigt_fehler_und_laedt_nicht_endlos() = runDbTest {
val viewModel = createViewModel(id = "gibt-es-nicht")
assertEquals("Dokumentation nicht gefunden.", viewModel.uiState.value.error)
assertFalse(viewModel.uiState.value.isLoading)
}
@Test
fun datenbankfehler_beim_laden_zeigt_fehler_statt_dauerhaftem_ladezustand() = runDbTest {
val failingDao = FailingDocumentationDao(env.db.documentationDao())
.apply { loadFailure = IllegalStateException("Datenbank nicht verfügbar (Test)") }
val repository = env.buildRepository(documentationDao = failingDao)
val viewModel = createViewModel(repository)
assertFalse(viewModel.uiState.value.isLoading)
assertNotNull(viewModel.uiState.value.error)
assertNull(viewModel.uiState.value.documentation)
}
// --- Autosave: Haken -----------------------------------------------------------------
@Test
fun haken_wird_bei_normalem_betrieb_gespeichert() = runDbTest {
val viewModel = createViewModel()
val item = viewModel.plainItem()
onMain { viewModel.onCheckedChanged(item.checkItemStateId, true) }
awaitUntil {
env.documentationRepository.getFullDocumentation(documentationId)!!
.blocks.flatMap { it.items }.first { it.checkItemStateId == item.checkItemStateId }.checked
}
assertNull(viewModel.uiState.value.saveError)
}
@Test
fun haken_bei_vollem_speicher_meldet_fehler_und_zeigt_den_tatsaechlich_gespeicherten_stand() = runDbTest {
val failingDao = FailingCheckItemStateDao(env.db.checkItemStateDao())
.apply { failure = SQLiteFullException("database or disk is full") }
val viewModel = createViewModel(env.buildRepository(checkItemStateDao = failingDao))
val item = viewModel.plainItem()
onMain { viewModel.onCheckedChanged(item.checkItemStateId, true) }
awaitCondition {
viewModel.uiState.value.saveError != null && !viewModel.itemById(item.checkItemStateId).checked
}
assertTrue(viewModel.uiState.value.saveError!!.contains("Speicherplatz"))
assertFalse("Anzeige darf keinen nicht gespeicherten Haken zeigen", viewModel.itemById(item.checkItemStateId).checked)
}
@Test
fun speicherfehler_beim_haken_laesst_die_app_nicht_abstuerzen_und_spaetere_aenderungen_funktionieren() = runDbTest {
val failingDao = FailingCheckItemStateDao(env.db.checkItemStateDao())
.apply { failure = SQLiteFullException("database or disk is full") }
val viewModel = createViewModel(env.buildRepository(checkItemStateDao = failingDao))
val item = viewModel.plainItem()
onMain { viewModel.onCheckedChanged(item.checkItemStateId, true) }
awaitCondition { viewModel.uiState.value.saveError != null }
onMain { viewModel.dismissSaveError() }
failingDao.failure = null
onMain { viewModel.onCheckedChanged(item.checkItemStateId, true) }
awaitUntil {
env.documentationRepository.getFullDocumentation(documentationId)!!
.blocks.flatMap { it.items }.first { it.checkItemStateId == item.checkItemStateId }.checked
}
assertNull(viewModel.uiState.value.saveError)
}
@Test
fun dismissSaveError_leert_die_meldung() = runDbTest {
val failingDao = FailingCheckItemStateDao(env.db.checkItemStateDao()).apply { failure = IOException("x") }
val viewModel = createViewModel(env.buildRepository(checkItemStateDao = failingDao))
onMain { viewModel.onCheckedChanged(viewModel.plainItem().checkItemStateId, true) }
awaitCondition { viewModel.uiState.value.saveError != null }
onMain { viewModel.dismissSaveError() }
assertNull(viewModel.uiState.value.saveError)
}
// --- Autosave: Freitext (Datenverlust verhindern) ------------------------------------
@Test
fun freitext_wird_nach_debounce_gespeichert() = runDbTest {
val viewModel = createViewModel()
val item = viewModel.freeTextItem()
onMain { viewModel.onFreeTextChanged(item.checkItemStateId, "Wunde trocken") }
awaitUntil { storedFreeText(item.checkItemStateId) == "Wunde trocken" }
}
@Test
fun freitext_bleibt_bei_speicherfehler_in_der_anzeige_erhalten_und_wird_spaeter_nachgeschrieben() = runDbTest {
val failingDao = FailingCheckItemStateDao(env.db.checkItemStateDao())
.apply { failure = SQLiteFullException("database or disk is full") }
val viewModel = createViewModel(env.buildRepository(checkItemStateDao = failingDao))
val item = viewModel.freeTextItem()
onMain { viewModel.onFreeTextChanged(item.checkItemStateId, "Wichtiger Befund") }
awaitCondition { viewModel.uiState.value.saveError != null }
assertEquals(
"Der Nutzer darf seinen Text nicht verlieren",
"Wichtiger Befund",
viewModel.itemById(item.checkItemStateId).freeTextValue,
)
assertEquals("", storedFreeText(item.checkItemStateId))
// Speicher wieder verfügbar: der ON_STOP-Flush schreibt den bisher nicht gespeicherten Text.
failingDao.failure = null
onMain { viewModel.flushPendingChanges() }
awaitUntil { storedFreeText(item.checkItemStateId) == "Wichtiger Befund" }
}
@Test
fun flush_bei_weiterhin_vollem_speicher_verwirft_den_text_nicht() = runDbTest {
val failingDao = FailingCheckItemStateDao(env.db.checkItemStateDao())
.apply { failure = SQLiteFullException("database or disk is full") }
val viewModel = createViewModel(env.buildRepository(checkItemStateDao = failingDao))
val item = viewModel.freeTextItem()
onMain { viewModel.onFreeTextChanged(item.checkItemStateId, "Text") }
onMain { viewModel.flushPendingChanges() }
awaitCondition { viewModel.uiState.value.saveError != null }
onMain { viewModel.dismissSaveError() }
// Zweiter Flush nach erfolgreicher Freigabe von Speicher: der Text muss noch vorhanden sein.
failingDao.failure = null
onMain { viewModel.flushPendingChanges() }
awaitUntil { storedFreeText(item.checkItemStateId) == "Text" }
}
@Test
fun speicherfehler_beendet_den_freitext_autosave_nicht_dauerhaft() = runDbTest {
val failingDao = FailingCheckItemStateDao(env.db.checkItemStateDao())
.apply { failure = SQLiteFullException("database or disk is full") }
val viewModel = createViewModel(env.buildRepository(checkItemStateDao = failingDao))
val item = viewModel.freeTextItem()
onMain { viewModel.onFreeTextChanged(item.checkItemStateId, "Erster Versuch") }
awaitCondition { viewModel.uiState.value.saveError != null }
failingDao.failure = null
onMain { viewModel.onFreeTextChanged(item.checkItemStateId, "Zweiter Versuch") }
awaitUntil { storedFreeText(item.checkItemStateId) == "Zweiter Versuch" }
}
@Test
fun flush_ohne_ausstehende_aenderungen_tut_nichts() = runDbTest {
val viewModel = createViewModel()
onMain { viewModel.flushPendingChanges() }
assertNull(viewModel.uiState.value.saveError)
}
// --- Fotoimport ----------------------------------------------------------------------
@Test
fun foto_import_bei_defekten_bildern_meldet_fehler_und_beendet_den_ladezustand() = runDbTest {
val storage = FailingPhotoStorage().apply { importFailure = { IOException("Bild defekt (Test)") } }
val viewModel = createViewModel(env.buildRepository(photoStorage = storage))
onMain { viewModel.onPhotosPicked(listOf(someUri, someUri)) }
awaitCondition { viewModel.uiState.value.photoImportError != null && !viewModel.uiState.value.photoImportInProgress }
assertTrue(viewModel.uiState.value.photoImportError!!.startsWith("2 von 2"))
assertTrue(viewModel.uiState.value.documentation!!.photos.isEmpty())
}
@Test
fun foto_import_uebernimmt_gueltige_bilder_auch_wenn_ein_bild_fehlschlaegt() = runDbTest {
val storage = FailingPhotoStorage().apply { importFailure = { call -> if (call == 2) IOException("defekt") else null } }
val viewModel = createViewModel(env.buildRepository(photoStorage = storage))
onMain { viewModel.onPhotosPicked(listOf(someUri, someUri, someUri)) }
awaitCondition { viewModel.uiState.value.photoImportError != null && !viewModel.uiState.value.photoImportInProgress }
assertTrue(viewModel.uiState.value.photoImportError!!.startsWith("1 von 3"))
assertEquals(2, viewModel.uiState.value.documentation!!.photos.size)
assertEquals(2, env.db.photoDao().getForDocumentation(documentationId).size)
}
@Test
fun foto_import_bei_arbeitsspeichermangel_stuerzt_nicht_ab_und_nennt_den_grund() = runDbTest {
val storage = FailingPhotoStorage().apply { importFailure = { OutOfMemoryError("Test") } }
val viewModel = createViewModel(env.buildRepository(photoStorage = storage))
onMain { viewModel.onPhotosPicked(listOf(someUri)) }
awaitCondition { viewModel.uiState.value.photoImportError != null && !viewModel.uiState.value.photoImportInProgress }
assertTrue(viewModel.uiState.value.photoImportError!!.contains("Arbeitsspeicher"))
}
@Test
fun foto_import_bei_vollem_geraetespeicher_nennt_den_grund() = runDbTest {
val storage = FailingPhotoStorage().apply {
importFailure = { IOException("write failed: ENOSPC (No space left on device)") }
}
val viewModel = createViewModel(env.buildRepository(photoStorage = storage))
onMain { viewModel.onPhotosPicked(listOf(someUri)) }
awaitCondition { viewModel.uiState.value.photoImportError != null && !viewModel.uiState.value.photoImportInProgress }
assertTrue(viewModel.uiState.value.photoImportError!!.contains("Speicherplatz"))
}
@Test
fun kamera_aufraeumen_laeuft_auch_wenn_der_import_fehlschlaegt() = runDbTest {
val storage = FailingPhotoStorage().apply { importFailure = { IOException("defekt") } }
val viewModel = createViewModel(env.buildRepository(photoStorage = storage))
val cleanedUp = java.util.concurrent.atomic.AtomicBoolean(false)
onMain { viewModel.onCameraPhotoCaptured(someUri) { cleanedUp.set(true) } }
awaitCondition { cleanedUp.get() && !viewModel.uiState.value.photoImportInProgress }
assertTrue(viewModel.uiState.value.photoImportError != null)
}
@Test
fun leere_auswahl_ist_kein_fehler() = runDbTest {
val viewModel = createViewModel()
onMain { viewModel.onPhotosPicked(emptyList()) }
assertNull(viewModel.uiState.value.photoImportError)
assertFalse(viewModel.uiState.value.photoImportInProgress)
}
@Test
fun fehlende_kamera_app_wird_gemeldet() = runDbTest {
val viewModel = createViewModel()
onMain { viewModel.onCameraUnavailable() }
assertNotNull(viewModel.uiState.value.photoImportError)
}
// --- Signatur ------------------------------------------------------------------------
@Test
fun signatur_bei_dateifehler_zeigt_meldung_und_laesst_den_dialog_offen() = runDbTest {
val storage = FailingSignatureStorage().apply { saveFailure = IOException("defekt (Test)") }
val viewModel = createViewModel(env.buildRepository(signatureStorage = storage))
onMain { viewModel.requestAddSignature() }
onMain { viewModel.saveSignature("Pfleger Paul", signatureBitmap()) }
awaitCondition { viewModel.uiState.value.signatureError != null }
assertTrue(viewModel.uiState.value.showAddSignatureDialog)
assertTrue(viewModel.uiState.value.documentation!!.signatures.isEmpty())
}
@Test
fun signatur_bei_vollem_speicher_nennt_den_grund() = runDbTest {
val storage = FailingSignatureStorage().apply {
saveFailure = IOException("write failed: ENOSPC (No space left on device)")
}
val viewModel = createViewModel(env.buildRepository(signatureStorage = storage))
onMain { viewModel.saveSignature("Pfleger Paul", signatureBitmap()) }
awaitCondition { viewModel.uiState.value.signatureError != null }
assertTrue(viewModel.uiState.value.signatureError!!.contains("Speicherplatz"))
}
@Test
fun signatur_bei_arbeitsspeichermangel_stuerzt_nicht_ab() = runDbTest {
val storage = FailingSignatureStorage().apply { saveFailure = OutOfMemoryError("Test") }
val viewModel = createViewModel(env.buildRepository(signatureStorage = storage))
onMain { viewModel.saveSignature("Pfleger Paul", signatureBitmap()) }
awaitCondition { viewModel.uiState.value.signatureError != null }
assertTrue(viewModel.uiState.value.signatureError!!.contains("Arbeitsspeicher"))
}
@Test
fun leere_signatur_wird_mit_fachlicher_meldung_abgelehnt() = runDbTest {
val viewModel = createViewModel()
onMain { viewModel.saveSignature("Pfleger Paul", blankBitmap()) }
awaitCondition { viewModel.uiState.value.signatureError != null }
assertTrue(viewModel.uiState.value.documentation!!.signatures.isEmpty())
}
@Test
fun signatur_ohne_namen_wird_abgelehnt() = runDbTest {
val viewModel = createViewModel()
onMain { viewModel.saveSignature(" ", signatureBitmap()) }
awaitCondition { viewModel.uiState.value.signatureError != null }
assertTrue(viewModel.uiState.value.documentation!!.signatures.isEmpty())
}
// --- PDF-Export ----------------------------------------------------------------------
@Test
fun pdf_export_fehler_beendet_ladezustand_und_bietet_kein_anzeigen_an() = runDbTest {
env.pdfExporter.result = { PdfExportResult.Failure(PdfExportError.WriteFailed("x")) }
val viewModel = createViewModel()
onMain { viewModel.exportPdf() }
awaitCondition { viewModel.uiState.value.pdfExportMessage != null && !viewModel.uiState.value.pdfExportInProgress }
assertTrue(viewModel.uiState.value.pdfExportMessage!!.contains("fehlgeschlagen"))
assertNull(viewModel.uiState.value.lastExportedPdfFile)
}
@Test
fun pdf_export_mit_speichermangel_im_exporter_haengt_nicht_und_stuerzt_nicht_ab() = runDbTest {
val repository = env.buildRepository(pdfExporter = ThrowingPdfExporter(OutOfMemoryError("Test")))
val viewModel = createViewModel(repository)
onMain { viewModel.exportPdf() }
awaitCondition { viewModel.uiState.value.pdfExportMessage != null && !viewModel.uiState.value.pdfExportInProgress }
assertTrue(viewModel.uiState.value.pdfExportMessage!!.contains("Arbeitsspeicher"))
}
@Test
fun pdf_export_erfolg_stellt_die_datei_bereit() = runDbTest {
val viewModel = createViewModel()
onMain { viewModel.exportPdf() }
awaitCondition { viewModel.uiState.value.lastExportedPdfFile != null && !viewModel.uiState.value.pdfExportInProgress }
assertNotNull(viewModel.uiState.value.lastExportedPdfFile)
}
// --- Teilen --------------------------------------------------------------------------
@Test
fun teilen_als_pdf_bei_vollem_speicher_zeigt_meldung_und_teilt_nichts() = runDbTest {
env.pdfExporter.result = { PdfExportResult.Failure(PdfExportError.InsufficientStorage) }
val viewModel = createViewModel()
onMain { viewModel.shareAsPdf() }
awaitCondition { viewModel.uiState.value.shareMessage != null && !viewModel.uiState.value.shareInProgress }
assertTrue(viewModel.uiState.value.shareMessage!!.contains("Speicherplatz"))
assertNull(viewModel.uiState.value.pdfToShare)
}
@Test
fun teilen_als_bilder_bei_konvertierungsfehler_zeigt_meldung_und_teilt_nichts() = runDbTest {
env.pdfToImagesConverter.result = PdfToImagesResult.Failure.OutOfMemory
val viewModel = createViewModel()
onMain { viewModel.shareAsImages() }
awaitCondition { viewModel.uiState.value.shareMessage != null && !viewModel.uiState.value.shareInProgress }
assertTrue(viewModel.uiState.value.shareMessage!!.startsWith("Bildexport fehlgeschlagen"))
assertNull(viewModel.uiState.value.imagesToShare)
}
@Test
fun teilen_als_bilder_bei_export_fehler_ueberspringt_die_konvertierung() = runDbTest {
env.pdfExporter.result = { PdfExportResult.Failure(PdfExportError.OutOfMemory) }
val viewModel = createViewModel()
onMain { viewModel.shareAsImages() }
awaitCondition { viewModel.uiState.value.shareMessage != null && !viewModel.uiState.value.shareInProgress }
assertTrue(env.pdfToImagesConverter.calls.isEmpty())
assertNull(viewModel.uiState.value.imagesToShare)
}
@Test
fun teilen_als_pdf_erfolg_stellt_die_datei_bereit() = runDbTest {
val viewModel = createViewModel()
onMain { viewModel.shareAsPdf() }
awaitCondition { viewModel.uiState.value.pdfToShare != null && !viewModel.uiState.value.shareInProgress }
assertNull(viewModel.uiState.value.shareMessage)
assertTrue(viewModel.uiState.value.pdfToShare is File)
}
}
@@ -0,0 +1,151 @@
package de.medapp.wunddoku.ui.patients.form
import android.database.sqlite.SQLiteFullException
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.data.repository.PatientRepository
import de.medapp.wunddoku.testutil.FailingDocumentationDao
import de.medapp.wunddoku.testutil.FailingPatientDao
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.awaitCondition
import de.medapp.wunddoku.testutil.buildRepository
import de.medapp.wunddoku.testutil.onMain
import de.medapp.wunddoku.testutil.runDbTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/**
* Fehlerbehandlung beim Speichern im [PatientFormViewModel]: voller Speicher/Datenbankfehler
* dürfen weder abstürzen noch zu einer scheinbar erfolgreichen Navigation führen, und die
* Eingaben des Nutzers bleiben im Formular erhalten.
*/
@RunWith(AndroidJUnit4::class)
class PatientFormViewModelErrorHandlingTest {
private lateinit var env: TestEnvironment
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
private fun fill(viewModel: PatientFormViewModel) = onMain {
viewModel.onFirstNameChanged("Max")
viewModel.onLastNameChanged("Mustermann")
viewModel.onBirthDateDigitsChanged("14031980")
viewModel.onStreetChanged("Musterstraße 1")
viewModel.onPostalCodeChanged("12345")
viewModel.onCityChanged("Musterstadt")
viewModel.onPhone1Changed("0123456789")
}
private suspend fun createViewModel(
patientRepository: PatientRepository,
startsNewDocumentation: Boolean,
documentationDao: FailingDocumentationDao? = null,
): PatientFormViewModel {
val documentationRepository = if (documentationDao != null) {
env.buildRepository(documentationDao = documentationDao)
} else {
env.documentationRepository
}
val viewModel = onMain {
PatientFormViewModel(
patientId = null,
startsNewDocumentation = startsNewDocumentation,
repository = patientRepository,
documentationRepository = documentationRepository,
)
}
awaitCondition { !viewModel.uiState.value.isLoading }
return viewModel
}
@Test
fun speicherfehler_beim_anlegen_des_patienten_zeigt_meldung_und_behaelt_die_eingaben() = runDbTest {
val failingDao = FailingPatientDao(env.db.patientDao())
.apply { insertFailure = SQLiteFullException("database or disk is full") }
val viewModel = createViewModel(PatientRepository(failingDao), startsNewDocumentation = true)
fill(viewModel)
onMain { viewModel.save() }
awaitCondition { viewModel.uiState.value.validationError != null }
val state = viewModel.uiState.value
assertTrue(state.validationError!!.contains("Speicherplatz"))
assertFalse(state.patientSaved)
assertNull(state.createdDocumentationId)
assertEquals("Max", state.firstName)
assertEquals("Mustermann", state.lastName)
}
@Test
fun sonstiger_datenbankfehler_beim_speichern_stuerzt_nicht_ab() = runDbTest {
val failingDao = FailingPatientDao(env.db.patientDao())
.apply { insertFailure = IllegalStateException("Test") }
val viewModel = createViewModel(PatientRepository(failingDao), startsNewDocumentation = false)
fill(viewModel)
onMain { viewModel.save() }
awaitCondition { viewModel.uiState.value.validationError != null }
assertFalse(viewModel.uiState.value.patientSaved)
}
@Test
fun fehler_beim_anlegen_der_dokumentation_meldet_fehler_ohne_navigation() = runDbTest {
val failingDocDao = FailingDocumentationDao(env.db.documentationDao())
.apply { loadFailure = SQLiteFullException("database or disk is full") }
val viewModel = createViewModel(
env.patientRepository,
startsNewDocumentation = true,
documentationDao = failingDocDao,
)
fill(viewModel)
onMain { viewModel.save() }
awaitCondition { viewModel.uiState.value.validationError != null }
assertNull("keine Navigation zu einer nicht angelegten Dokumentation", viewModel.uiState.value.createdDocumentationId)
assertNotNull(viewModel.uiState.value.validationError)
// Die Dokumentation wurde per Transaktion zurückgerollt: es gibt keine halbe Dokumentation.
assertEquals(0, env.db.documentationDao().countForPatient(env.db.patientDao().findExisting("Max", "Mustermann", "1980-03-14")!!.id))
}
@Test
fun speichern_funktioniert_nach_einem_fehler_erneut() = runDbTest {
val failingDao = FailingPatientDao(env.db.patientDao())
.apply { insertFailure = IllegalStateException("Test") }
val viewModel = createViewModel(PatientRepository(failingDao), startsNewDocumentation = false)
fill(viewModel)
onMain { viewModel.save() }
awaitCondition { viewModel.uiState.value.validationError != null }
failingDao.insertFailure = null
onMain { viewModel.save() }
awaitCondition { viewModel.uiState.value.patientSaved }
assertNull(viewModel.uiState.value.validationError)
}
@Test
fun ungueltige_eingaben_werden_weiterhin_fachlich_abgelehnt() = runDbTest {
val viewModel = createViewModel(env.patientRepository, startsNewDocumentation = false)
onMain { viewModel.save() }
assertNotNull(viewModel.uiState.value.validationError)
assertFalse(viewModel.uiState.value.patientSaved)
}
}
+8 -1
View File
@@ -1,10 +1,17 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Keine Speicher-/Kamera-Berechtigungen nötig: Der Photo Picker (Abschnitt 11 der <!-- Keine Speicher-/Kamera-Berechtigungen nötig: Der Photo Picker (Abschnitt 11 der
Architektur) benötigt keine READ_MEDIA_IMAGES-Berechtigung, und Fotos/Signaturen/ Architektur) benötigt keine READ_MEDIA_IMAGES-Berechtigung, und Fotos/Signaturen/
PDFs werden ausschließlich im privaten internen App-Speicher abgelegt. --> PDFs werden ausschließlich im privaten internen App-Speicher abgelegt. -->
<!-- Die App arbeitet vollständig offline. Falls eine Bibliothek (jetzt oder nach einem
Update) die INTERNET-Berechtigung mitbringt, wird sie beim Zusammenführen der Manifeste
entfernt - Patientendaten können das Gerät so nicht über das Netzwerk verlassen. -->
<uses-permission android:name="android.permission.INTERNET" tools:node="remove" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
<application <application
android:name=".WunddokuApplication" android:name=".WunddokuApplication"
android:allowBackup="false" android:allowBackup="false"
@@ -2,14 +2,25 @@ package de.medapp.wunddoku
import android.app.Application import android.app.Application
import de.medapp.wunddoku.di.AppContainer import de.medapp.wunddoku.di.AppContainer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class WunddokuApplication : Application() { class WunddokuApplication : Application() {
lateinit var container: AppContainer lateinit var container: AppContainer
private set private set
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
container = AppContainer(this) container = AppContainer(this)
// Datenschutz: veraltete PDF-/Bildexporte und liegengebliebene Kameraaufnahmen entfernen
// (Aufbewahrungsfristen siehe InternalExportedFilesCleaner). Läuft im Hintergrund,
// wirft nie und verzögert den Start nicht.
applicationScope.launch { container.exportedFilesCleaner.deleteStaleFiles() }
} }
} }
@@ -18,6 +18,13 @@ interface SignatureDao {
@Query("SELECT * FROM signatures WHERE id = :id") @Query("SELECT * FROM signatures WHERE id = :id")
suspend fun getById(id: String): SignatureEntity? suspend fun getById(id: String): SignatureEntity?
@Query("SELECT * FROM signatures WHERE documentationId = :documentationId") /** Bereits stabil nach [de.medapp.wunddoku.data.entity.SignatureEntity.orderIndex] sortiert
* - dieselbe Reihenfolge, in der Signaturen später auch im PDF erscheinen (PDF-003). */
@Query("SELECT * FROM signatures WHERE documentationId = :documentationId ORDER BY orderIndex")
suspend fun getForDocumentation(documentationId: String): List<SignatureEntity> suspend fun getForDocumentation(documentationId: String): List<SignatureEntity>
/** Für die Vergabe des nächsten [de.medapp.wunddoku.data.entity.SignatureEntity.orderIndex]
* beim Anlegen einer neuen Signatur - exakt analog [PhotoDao.getMaxOrderIndex]. */
@Query("SELECT COALESCE(MAX(orderIndex), -1) FROM signatures WHERE documentationId = :documentationId")
suspend fun getMaxOrderIndex(documentationId: String): Int
} }
@@ -0,0 +1,33 @@
package de.medapp.wunddoku.data.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import de.medapp.wunddoku.data.entity.WoundCarerEntity
@Dao
interface WoundCarerDao {
/** Ersetzt einen vorhandenen Eintrag mit derselben [WoundCarerEntity.id] vollständig (Upsert
* per Primärschlüssel) - siehe [de.medapp.wunddoku.data.repository.WoundCarerRepository]
* (Find-by-Name + Wiederverwendung der gefundenen ID) für die eigentliche
* "Neuanlegen-oder-Aktualisieren"-Logik anhand des Namens. */
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrUpdate(entity: WoundCarerEntity)
/** Case-insensitiver Exakt-Abgleich - entscheidet, ob unter diesem (getrimmten) Namen
* bereits ein Wundversorger bekannt ist (dann: Signatur aktualisieren) oder ein neuer Eintrag
* angelegt werden muss. */
@Query("SELECT * FROM wound_carers WHERE name = :name COLLATE NOCASE LIMIT 1")
suspend fun findByName(name: String): WoundCarerEntity?
/** Namensvorschläge nach den ersten eingegebenen Buchstaben (Präfix, case-insensitiv),
* neueste Verwendung zuerst. */
@Query("SELECT * FROM wound_carers WHERE name LIKE :prefix || '%' COLLATE NOCASE ORDER BY updatedAt DESC LIMIT :limit")
suspend fun searchByNamePrefix(prefix: String, limit: Int): List<WoundCarerEntity>
/** Für die Vorbelegung des Signatur-Dialogs mit dem zuletzt verwendeten Wundversorger. */
@Query("SELECT * FROM wound_carers ORDER BY updatedAt DESC LIMIT 1")
suspend fun getMostRecentlyUsed(): WoundCarerEntity?
}
@@ -11,12 +11,14 @@ import de.medapp.wunddoku.data.dao.DocumentationDao
import de.medapp.wunddoku.data.dao.PatientDao import de.medapp.wunddoku.data.dao.PatientDao
import de.medapp.wunddoku.data.dao.PhotoDao import de.medapp.wunddoku.data.dao.PhotoDao
import de.medapp.wunddoku.data.dao.SignatureDao import de.medapp.wunddoku.data.dao.SignatureDao
import de.medapp.wunddoku.data.dao.WoundCarerDao
import de.medapp.wunddoku.data.entity.BlockStateEntity import de.medapp.wunddoku.data.entity.BlockStateEntity
import de.medapp.wunddoku.data.entity.CheckItemStateEntity import de.medapp.wunddoku.data.entity.CheckItemStateEntity
import de.medapp.wunddoku.data.entity.DocumentationEntity import de.medapp.wunddoku.data.entity.DocumentationEntity
import de.medapp.wunddoku.data.entity.PatientEntity import de.medapp.wunddoku.data.entity.PatientEntity
import de.medapp.wunddoku.data.entity.PhotoEntity import de.medapp.wunddoku.data.entity.PhotoEntity
import de.medapp.wunddoku.data.entity.SignatureEntity import de.medapp.wunddoku.data.entity.SignatureEntity
import de.medapp.wunddoku.data.entity.WoundCarerEntity
import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter
/** /**
@@ -26,18 +28,31 @@ import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter
* Version 2 (vorher 1): Drei strukturelle Änderungen in einem gemeinsamen Versionssprung - * Version 2 (vorher 1): Drei strukturelle Änderungen in einem gemeinsamen Versionssprung -
* Patient.address -> street/postalCode/city, Wegfall von BlockState.freeText zugunsten von * Patient.address -> street/postalCode/city, Wegfall von BlockState.freeText zugunsten von
* CheckItemState.isFreeText/freeTextValue, sowie neue Patienten-Stammdaten-Snapshot-Spalten auf * CheckItemState.isFreeText/freeTextValue, sowie neue Patienten-Stammdaten-Snapshot-Spalten auf
* DocumentationEntity. * DocumentationEntity. Dieser Sprung nutzte noch `fallbackToDestructiveMigration()` (die App
* befand sich vor dem produktiven Einsatz, keine echten Patientendaten zu migrieren).
* *
* `fallbackToDestructiveMigration()`: Da die App sich noch vor dem produktiven Einsatz befindet * Version 3 (vorher 2, MEDIA-004/PDF-003): `signatures.orderIndex` neu (stabile Reihenfolge
* und keine echten Patientendaten migriert werden müssen, wird hier bewusst auf eine * mehrerer Signaturen je Dokumentation) sowie `signatures.signerName` von NULLABLE auf NOT NULL.
* geschriebene `Migration`-Klasse verzichtet - die lokale Datenbank wird bei einem * Anders als beim Sprung 1->2 wird HIER eine echte, verlustfreie [Migration] verwendet
* Versionssprung einfach neu angelegt (bestehende lokale Testdaten auf dem Gerät/Emulator gehen * ([MIGRATION_2_3]) - reale Installationen können ab jetzt bereits Patienten-/
* dabei verloren). Das ist eine bewusste, vorübergehende Entwicklungsentscheidung: VOR dem * Dokumentationsdaten enthalten, die nicht stillschweigend durch einen destruktiven Fallback
* ersten produktiven Rollout muss dies durch eine echte `Migration(1, 2) { ... }` ersetzt * verloren gehen dürfen (Aufgabenstellung Abschnitt 23). Der bisherige
* werden, die reale Patientendaten erhält (insbesondere: die bisherige `address`-Spalte müsste * `fallbackToDestructiveMigration()`-Aufruf wurde deshalb entfernt; fehlt für einen künftigen
* dann sinnvoll in street/postalCode/city überführt werden, z. B. gesamten Altwert nach * Versionssprung eine passende Migration, bricht Room den Aufbau der Datenbank kontrolliert mit
* `street` und postalCode/city leer lassen, da eine automatische Text-Zerlegung einer * einer Exception ab, statt Daten unbemerkt zu löschen.
* Freitext-Adresse nicht zuverlässig möglich ist). *
* Version 4 (vorher 3, Änderungswunsch): neue, eigenständige Tabelle `wound_carers`
* (Namensvorschläge + Standard-Vorbelegung im Signatur-Dialog, s. [WoundCarerEntity]) - rein
* additiv ([MIGRATION_3_4]), keine bestehenden Tabellen betroffen.
*
* Version 5 (vorher 4, finale fachliche Katalog-Inhalte): `check_item_states.isStaticHint` neu
* (nicht editierbarer Hinweistext, z. B. "+ bei Bedarf") - rein additive Spalte mit
* Default-Wert ([MIGRATION_4_5]), keine bestehenden Daten verändert.
*
* Version 6 (vorher 5, Änderungswunsch): `check_item_states.inputSpec` neu (strukturierte
* Eingabefelder wie Zahl/Auswahlliste statt Freitext, s. [de.medapp.wunddoku.domain.model.InputSpec])
* - ebenfalls rein additive Spalte mit Default-Wert ([MIGRATION_5_6]), keine bestehenden Daten
* verändert.
*/ */
@Database( @Database(
entities = [ entities = [
@@ -47,8 +62,9 @@ import de.medapp.wunddoku.data.entity.converter.DocumentationStatusConverter
CheckItemStateEntity::class, CheckItemStateEntity::class,
PhotoEntity::class, PhotoEntity::class,
SignatureEntity::class, SignatureEntity::class,
WoundCarerEntity::class,
], ],
version = 2, version = 6,
exportSchema = true, exportSchema = true,
) )
@TypeConverters(DocumentationStatusConverter::class) @TypeConverters(DocumentationStatusConverter::class)
@@ -60,6 +76,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun checkItemStateDao(): CheckItemStateDao abstract fun checkItemStateDao(): CheckItemStateDao
abstract fun photoDao(): PhotoDao abstract fun photoDao(): PhotoDao
abstract fun signatureDao(): SignatureDao abstract fun signatureDao(): SignatureDao
abstract fun woundCarerDao(): WoundCarerDao
companion object { companion object {
private const val DATABASE_NAME = "wunddoku.db" private const val DATABASE_NAME = "wunddoku.db"
@@ -74,7 +91,8 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java, AppDatabase::class.java,
DATABASE_NAME, DATABASE_NAME,
) )
.fallbackToDestructiveMigration() // s. Klassenkommentar: vorübergehend, vor Produktivbetrieb ersetzen // Kein fallbackToDestructiveMigration() mehr ab Version 3 - s. Klassenkommentar.
.addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6)
.build().also { instance = it } .build().also { instance = it }
} }
} }
@@ -0,0 +1,150 @@
package de.medapp.wunddoku.data.database
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
/**
* Migration von Schema-Version 2 auf 3 (MEDIA-004/PDF-003: Mehrere Signaturen pro
* Dokumentation).
*
* Zwei Änderungen am `signatures`-Schema, die NICHT über ein einfaches `ALTER TABLE ... ADD
* COLUMN` abgebildet werden können:
*
* 1. Neue Spalte `orderIndex INTEGER NOT NULL` für eine stabile Reihenfolge mehrerer
* Signaturen (bisher gab es nur eine implizite, undefinierte Reihenfolge).
* 2. `signerName` wechselt von NULLABLE auf NOT NULL. SQLite kann eine bestehende Spalte nicht
* nachträglich auf NOT NULL umstellen - deshalb wird die Tabelle komplett neu aufgebaut
* (neue Tabelle mit Zielschema anlegen, Daten verlustfrei übernehmen, alte Tabelle
* entfernen, neue Tabelle umbenennen, Index wiederherstellen).
*
* Es wird NICHT angenommen, dass die Tabelle leer ist, nur weil die Signaturfunktion vor
* dieser Version über keine UI erreichbar war (Aufgabenstellung Abschnitt 22) - die Migration
* behandelt den allgemeinen Fall mit ggf. bereits vorhandenen Zeilen:
*
* - `orderIndex` wird pro `documentationId` anhand von `createdAt` (mit `id` als
* Tie-Breaker für den unwahrscheinlichen Fall exakt gleicher Zeitstempel) rückwirkend
* vergeben, beginnend bei 0 - über eine korrelierte Unterabfrage, da SQLite in der von Room
* unterstützten Form kein `ROW_NUMBER()`-Fenster in einem `UPDATE` bereitstellt.
* - `signerName` wird für Altzeilen mit NULL **oder** leerem/nur-Leerzeichen-Wert auf den klar
* als historisch fehlend erkennbaren technischen Platzhalter `"(nicht erfasst)"` gesetzt
* (ausdrücklich freigegeben - NICHT `""`, NICHT `"Unbekannt"`, da Letzteres wie ein echter
* Name wirken könnte). Der leere-String-Fall wurde defensiv ergänzt: `signerName` war in
* Schema 2 zwar nullable, aber nie auf "nicht blank" geprüft - ein rein theoretisch über
* einen früheren Code-Pfad denkbarer Leerstring soll denselben Platzhalter erhalten wie
* NULL, damit hier keine Zeile mit einem leeren, aber technisch "vorhandenen" Namen entsteht.
* Diese Regel gilt ausschließlich für migrierte Altbestände - neu erfasste Signaturen
* erzwingen weiterhin serverseitig (Repository) ein nicht-leeres, getrimmtes Pflichtfeld.
*
* Da die Signaturfunktion vor dieser Version über keine UI erreichbar war, ist in der Praxis auf
* so gut wie jeder realen Installation ohnehin mit einer leeren `signatures`-Tabelle zu rechnen
* - die obige Logik greift dann einfach nicht (INSERT ... SELECT liefert 0 Zeilen).
*/
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `signatures_new` (
`id` TEXT NOT NULL,
`documentationId` TEXT NOT NULL,
`signerName` TEXT NOT NULL,
`fileName` TEXT NOT NULL,
`orderIndex` INTEGER NOT NULL,
`createdAt` INTEGER NOT NULL,
PRIMARY KEY(`id`),
FOREIGN KEY(`documentationId`) REFERENCES `documentations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE
)
""".trimIndent()
)
db.execSQL(
"""
INSERT INTO `signatures_new` (`id`, `documentationId`, `signerName`, `fileName`, `orderIndex`, `createdAt`)
SELECT
s.id,
s.documentationId,
CASE
WHEN s.signerName IS NULL OR TRIM(s.signerName) = '' THEN '(nicht erfasst)'
ELSE s.signerName
END,
s.fileName,
(
SELECT COUNT(*)
FROM signatures s2
WHERE s2.documentationId = s.documentationId
AND (
s2.createdAt < s.createdAt
OR (s2.createdAt = s.createdAt AND s2.id < s.id)
)
),
s.createdAt
FROM signatures s
""".trimIndent()
)
db.execSQL("DROP TABLE `signatures`")
db.execSQL("ALTER TABLE `signatures_new` RENAME TO `signatures`")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_signatures_documentationId` ON `signatures` (`documentationId`)")
}
}
/**
* Migration von Schema-Version 3 auf 4 (Änderungswunsch: Wundversorger-Register für
* Namensvorschläge und Standard-Vorbelegung im Signatur-Dialog).
*
* Rein additiv: eine neue, komplett eigenständige Tabelle `wound_carers` ohne Fremdschlüssel-
* Bezug zu einer bestehenden Tabelle (unabhängig vom Lebenszyklus einzelner Dokumentationen/
* Signaturen, s. [de.medapp.wunddoku.data.repository.WoundCarerRepository]). Keine bestehenden
* Tabellen/Daten werden hierbei verändert.
*/
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `wound_carers` (
`id` TEXT NOT NULL,
`name` TEXT NOT NULL,
`signatureFileName` TEXT NOT NULL,
`updatedAt` INTEGER NOT NULL,
PRIMARY KEY(`id`)
)
""".trimIndent()
)
}
}
/**
* Migration von Schema-Version 4 auf 5 (finale fachliche Katalog-Inhalte: neuer Content-Typ
* "statischer Hinweistext", z. B. "+ bei Bedarf" bei "Empfohlene Wundversorgung").
*
* Rein additiv - eine einzelne neue, NOT NULL-Spalte mit Default `false` (0) auf einer
* bestehenden Tabelle. Anders als bei der Signaturen-Migration (2->3, dort war ein
* NULL->NOT-NULL-Wechsel auf einer bereits befüllten Spalte nötig) ist hier ein einfaches
* `ALTER TABLE ... ADD COLUMN` mit Default-Wert ausreichend und von SQLite direkt unterstützt -
* kein Tabellenneubau nötig. Bestehende Zeilen erhalten automatisch `isStaticHint = 0`
* (entspricht ihrem bisherigen, unveränderten Verhalten als normale Checkbox- bzw.
* Freitext-Zeile).
*/
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE `check_item_states` ADD COLUMN `isStaticHint` INTEGER NOT NULL DEFAULT 0"
)
}
}
/**
* Migration von Schema-Version 5 auf 6 (Änderungswunsch: strukturierte Eingabefelder statt
* Freitext, z. B. "Länge/Breite/Tiefe" bei "Größe" oder "Von ... bis ... Uhr" bei "Fistel /
* Tasche" - s. [de.medapp.wunddoku.domain.model.InputSpec]).
*
* Wie schon bei Migration 4->5 rein additiv: eine neue NOT NULL-Spalte mit Default `''` (leerer
* String = "dieser Eintrag hat keine strukturierten Felder", entspricht dem bisherigen
* Verhalten aller bestehenden Zeilen unverändert). Kein Tabellenneubau nötig.
*/
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE `check_item_states` ADD COLUMN `inputSpec` TEXT NOT NULL DEFAULT ''"
)
}
}
@@ -15,6 +15,27 @@ import androidx.room.PrimaryKey
* zusätzliche CheckItemStateEntity mit `isFreeText = true` - das ist die Freitext-Option des * zusätzliche CheckItemStateEntity mit `isFreeText = true` - das ist die Freitext-Option des
* Blocks (Änderungswunsch: Freitext als weitere auswählbare Option statt eigenes Feld am * Blocks (Änderungswunsch: Freitext als weitere auswählbare Option statt eigenes Feld am
* Block). Für normale Checkbox-Optionen bleibt `isFreeText = false` und `freeTextValue` leer. * Block). Für normale Checkbox-Optionen bleibt `isFreeText = false` und `freeTextValue` leer.
* Derselbe Mechanismus wird jetzt auch für vom KATALOG selbst definierte Freitext-Checkboxen
* verwendet (z. B. "Wundgrund"/"Erscheinung" oder die vier Empfänger-Freitexte bei "Kopie
* nachrichtlich an") - technisch identisch zur automatisch angehängten Option, nur mit eigener,
* fachlich benannter [labelSnapshot] statt des generischen "Freitext"-Labels.
*
* [isStaticHint]: Markiert eine Zeile als reinen, nicht editierbaren Hinweistext (z. B.
* "+ bei Bedarf") - keine Checkbox in der UI, kein Umschalten möglich. Solche Zeilen werden
* beim Anlegen (s. DocumentationRepository.insertBlocksFromCatalog) mit `checked = true`
* fest verankert, damit die bestehende, unverändert gebliebene "nur angehakte Elemente"-Regel
* im PDF-Export sie automatisch mit anzeigt, ohne dass diese Regel selbst angepasst werden
* musste. [BlockEditedPolicy] schließt `isStaticHint`-Zeilen dagegen explizit von der
* "bearbeitet"-Prüfung aus, da ein reiner Hinweistext keine Nutzereingabe darstellt.
*
* [inputSpec]: NEU. Nicht-leer, wenn dieser Eintrag statt eines einfachen Häkchens ein oder
* mehrere strukturierte Eingabefelder besitzt (Zahl oder Auswahlliste, z. B. "Von [3] bis [5]
* Uhr" oder "Länge/Breite/Tiefe"), s. [de.medapp.wunddoku.domain.model.InputSpec] und
* [de.medapp.wunddoku.domain.model.InputSpecCodec]. Die eingegebenen Werte selbst liegen -
* mehrere durch ein Trennzeichen kombiniert - weiterhin in [freeTextValue]; es wird also KEINE
* weitere Spalte pro Feld benötigt. [checked] wird bei einem solchen Eintrag automatisch von der
* UI gesetzt, sobald mindestens ein Feld einen Wert hat - dieselbe Checkbox-Semantik wie überall
* sonst, keine zweite "bearbeitet"-Logik.
*/ */
@Entity( @Entity(
tableName = "check_item_states", tableName = "check_item_states",
@@ -40,4 +61,6 @@ data class CheckItemStateEntity(
val checked: Boolean, val checked: Boolean,
val isFreeText: Boolean = false, val isFreeText: Boolean = false,
val freeTextValue: String = "", val freeTextValue: String = "",
val isStaticHint: Boolean = false,
val inputSpec: String = "",
) )
@@ -7,9 +7,14 @@ import androidx.room.PrimaryKey
/** /**
* Room-Entity für eine digitale Unterschrift. Eigene Tabelle statt eines einzelnen Feldes an * Room-Entity für eine digitale Unterschrift. Eigene Tabelle statt eines einzelnen Feldes an
* DocumentationEntity, um später mehrere Unterschriften pro Dokumentation zu unterstützen * DocumentationEntity, damit eine Dokumentation beliebig viele Unterschriften unterschiedlicher
* (Aufgabenstellung Abschnitt 11). [fileName] verweist auf eine PNG-Datei unter * Wundversorger besitzen kann (1:n, MEDIA-004). [fileName] verweist auf eine PNG-Datei unter
* context.filesDir/signatures/ – kein BLOB in SQLite. * context.filesDir/signatures/<documentationId>/ – kein BLOB in SQLite.
*
* Schema-Historie: In Version 2 war [signerName] noch nullable und es gab kein [orderIndex] -
* Migration 2->3 ([de.medapp.wunddoku.data.database.MIGRATION_2_3]) baut die Tabelle deshalb neu
* auf (SQLite kann eine Spalte nicht per ALTER TABLE von NULL auf NOT NULL umstellen) und füllt
* dabei sowohl [orderIndex] als auch fehlende [signerName]-Werte auf.
*/ */
@Entity( @Entity(
tableName = "signatures", tableName = "signatures",
@@ -28,7 +33,10 @@ import androidx.room.PrimaryKey
data class SignatureEntity( data class SignatureEntity(
@PrimaryKey val id: String, @PrimaryKey val id: String,
val documentationId: String, val documentationId: String,
val signerName: String?, /** Fachliches Pflichtfeld seit Schema 3 (non-null, getrimmt, nicht blank) - s. Repository. */
val signerName: String,
val fileName: String, val fileName: String,
/** Stabile Reihenfolge mehrerer Signaturen einer Dokumentation, analog [de.medapp.wunddoku.data.entity.PhotoEntity.orderIndex]. */
val orderIndex: Int,
val createdAt: Long, val createdAt: Long,
) )
@@ -0,0 +1,37 @@
package de.medapp.wunddoku.data.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Leichtgewichtiges "Zuletzt verwendet"-Register für Wundversorger-Namen samt ihrer jeweils
* AKTUELLSTEN Signatur - AUSDRÜCKLICH KEIN Benutzer-/Account-/Mitarbeitersystem: es gibt keine
* Anmeldung, keine Rechte, keine Verknüpfung zu historischen Dokumentationen, und (vorerst) keine
* eigene Verwaltungsansicht zum Bearbeiten/Löschen einzelner Einträge - ausschließlich internes
* Cache für zwei Komfortfunktionen im Signatur-Dialog:
*
* 1. Namensvorschläge nach den ersten eingegebenen Buchstaben.
* 2. Vorbelegung von Name UND Signatur mit dem zuletzt verwendeten Wundversorger beim Öffnen des
* Dialogs (weiterhin änderbar).
*
* WICHTIG: Diese Tabelle hat KEINEN Einfluss auf bereits gespeicherte [SignatureEntity]-Zeilen -
* jede historische Signatur bleibt ein unveränderlicher Snapshot (Name + Bild) zum Zeitpunkt
* ihrer Erstellung. [signatureFileName] hier wird bei jeder neu gezeichneten Signatur unter
* demselben Namen überschrieben ("die letzte geänderte Version wird gespeichert") - komplett
* unabhängig vom Speicherort/Lebenszyklus der pro Dokumentation gespeicherten Signaturen.
*/
@Entity(tableName = "wound_carers")
data class WoundCarerEntity(
@PrimaryKey val id: String,
/** Trimmter Anzeigename, wie zuletzt eingegeben. Abgleich beim Wiedererkennen erfolgt
* case-insensitive (s. WoundCarerDao), gespeichert wird die zuletzt getippte Schreibweise. */
val name: String,
/** Relative Referenz auf die aktuellste PNG-Signatur dieses Wundversorgers, verwaltet über
* eine eigene, von [de.medapp.wunddoku.signature.SignatureStorage]-Instanz mit eigenem
* Wurzelverzeichnis (unabhängig von jeder documentationId). */
val signatureFileName: String,
/** Zeitpunkt der letzten Verwendung/Aktualisierung - bestimmt sowohl die Sortierung der
* Namensvorschläge als auch, wer als Standard beim Öffnen des Dialogs vorbelegt wird
* (jeweils der zuletzt aktualisierte Eintrag). */
val updatedAt: Long,
)
@@ -40,12 +40,15 @@ fun DocumentationWithDetails.toDomain(): Documentation = Documentation(
createdAt = Instant.ofEpochMilli(it.createdAt), createdAt = Instant.ofEpochMilli(it.createdAt),
) )
}, },
signatures = signatures.map { signatures = signatures
.sortedBy { it.orderIndex }
.map {
de.medapp.wunddoku.domain.model.Signature( de.medapp.wunddoku.domain.model.Signature(
id = it.id, id = it.id,
documentationId = it.documentationId, documentationId = it.documentationId,
signerName = it.signerName, signerName = it.signerName,
fileName = it.fileName, fileName = it.fileName,
orderIndex = it.orderIndex,
createdAt = Instant.ofEpochMilli(it.createdAt), createdAt = Instant.ofEpochMilli(it.createdAt),
) )
}, },
@@ -68,6 +71,8 @@ fun CheckItemStateEntity.toDomain(): CheckItem = CheckItem(
order = itemOrder, order = itemOrder,
checked = checked, checked = checked,
isFreeText = isFreeText, isFreeText = isFreeText,
isStaticHint = isStaticHint,
inputSpec = inputSpec,
freeTextValue = freeTextValue, freeTextValue = freeTextValue,
) )
@@ -18,16 +18,29 @@ import de.medapp.wunddoku.data.entity.SignatureEntity
import de.medapp.wunddoku.data.mapper.toDomain import de.medapp.wunddoku.data.mapper.toDomain
import de.medapp.wunddoku.data.mapper.toSummary import de.medapp.wunddoku.data.mapper.toSummary
import de.medapp.wunddoku.domain.catalog.DocumentationCatalog import de.medapp.wunddoku.domain.catalog.DocumentationCatalog
import de.medapp.wunddoku.domain.error.FailureCategory
import de.medapp.wunddoku.domain.error.runCatchingCancellable
import de.medapp.wunddoku.domain.model.Documentation import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.DocumentationStatus import de.medapp.wunddoku.domain.model.DocumentationStatus
import de.medapp.wunddoku.domain.model.DocumentationSummary import de.medapp.wunddoku.domain.model.DocumentationSummary
import de.medapp.wunddoku.domain.model.InputSpecCodec
import de.medapp.wunddoku.domain.model.Photo import de.medapp.wunddoku.domain.model.Photo
import de.medapp.wunddoku.domain.model.Signature import de.medapp.wunddoku.domain.model.Signature
import de.medapp.wunddoku.media.ImageDownsampler import de.medapp.wunddoku.media.ImageDownsampler
import de.medapp.wunddoku.media.PhotoStorage import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.signature.SignatureFileStorage import de.medapp.wunddoku.pdf.DocumentationPdfExporter
import de.medapp.wunddoku.pdf.ExportedFilesCleaner
import de.medapp.wunddoku.pdf.DocumentationToPdfExportModelMapper
import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesConverter
import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.signature.SignatureBitmapValidation
import de.medapp.wunddoku.signature.SignatureStorage
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import java.io.File
import java.time.Instant import java.time.Instant
import java.util.UUID import java.util.UUID
@@ -39,6 +52,10 @@ import java.util.UUID
* *
* [patientDao] wurde ergänzt, da beim Erstellen bzw. Fortsetzen einer Dokumentation ein * [patientDao] wurde ergänzt, da beim Erstellen bzw. Fortsetzen einer Dokumentation ein
* frischer Patienten-Stammdaten-Snapshot benötigt wird (s. createEmpty/continueDocumentation). * frischer Patienten-Stammdaten-Snapshot benötigt wird (s. createEmpty/continueDocumentation).
* [pdfExporter] wird für [exportPdf] benötigt - der Exporter selbst kennt weder Room noch die
* documentationId, nur das fertig gemappte [de.medapp.wunddoku.pdf.PdfExportModel]; das Laden
* der Dokumentation und die Fehlerübersetzung ("Dokumentation existiert nicht") übernimmt
* bewusst das Repository, nicht der Exporter.
*/ */
class DocumentationRepository( class DocumentationRepository(
private val documentationDao: DocumentationDao, private val documentationDao: DocumentationDao,
@@ -48,8 +65,12 @@ class DocumentationRepository(
private val signatureDao: SignatureDao, private val signatureDao: SignatureDao,
private val patientDao: PatientDao, private val patientDao: PatientDao,
private val photoStorage: PhotoStorage, private val photoStorage: PhotoStorage,
private val signatureFileStorage: SignatureFileStorage, private val signatureStorage: SignatureStorage,
private val woundCarerRepository: WoundCarerRepository,
private val pdfExporter: DocumentationPdfExporter,
private val pdfToImagesConverter: PdfToImagesConverter,
private val db: AppDatabase, private val db: AppDatabase,
private val exportedFilesCleaner: ExportedFilesCleaner = ExportedFilesCleaner.None,
) { ) {
fun observeAll(): Flow<List<DocumentationSummary>> = fun observeAll(): Flow<List<DocumentationSummary>> =
@@ -113,8 +134,10 @@ class DocumentationRepository(
* - status: IN_PROGRESS * - status: IN_PROGRESS
* - continuedFromDocumentationId: ID der Quelle * - continuedFromDocumentationId: ID der Quelle
* - Unterschriften: werden NICHT kopiert * - Unterschriften: werden NICHT kopiert
* - Fotos: über [copyPhotos] steuerbar, Standard `false` - fachlich noch nicht final * - Fotos: werden standardmäßig mit übernommen (physisch eigenständige Kopien pro
* entschieden * Dokumentation, s. [PhotoStorage.duplicate]) - fachliche Entscheidung, ursprünglich als
* offen markiert, inzwischen bewusst so festgelegt. Über [copyPhotos] weiterhin
* abschaltbar, falls dafür später doch ein Anwendungsfall entsteht.
*/ */
suspend fun continueDocumentation( suspend fun continueDocumentation(
sourceDocumentationId: String, sourceDocumentationId: String,
@@ -172,7 +195,9 @@ class DocumentationRepository(
itemOrder = item.itemOrder, itemOrder = item.itemOrder,
checked = item.checked, checked = item.checked,
isFreeText = item.isFreeText, isFreeText = item.isFreeText,
isStaticHint = item.isStaticHint,
freeTextValue = item.freeTextValue, freeTextValue = item.freeTextValue,
inputSpec = item.inputSpec,
) )
} }
) )
@@ -181,6 +206,7 @@ class DocumentationRepository(
// Unterschriften werden bewusst NICHT kopiert (siehe Doc-Kommentar oben). // Unterschriften werden bewusst NICHT kopiert (siehe Doc-Kommentar oben).
if (copyPhotos) { if (copyPhotos) {
try {
for (sourcePhoto in source.photos) { for (sourcePhoto in source.photos) {
val copiedFileName = photoStorage.duplicate( val copiedFileName = photoStorage.duplicate(
sourceDocumentationId = source.documentation.id, sourceDocumentationId = source.documentation.id,
@@ -197,6 +223,13 @@ class DocumentationRepository(
) )
) )
} }
} catch (e: Throwable) {
// Die Room-Transaktion rollt die DB-Zeilen zurück - bereits kopierte Fotodateien
// der nie entstandenen Dokumentation würden aber verwaist im Speicher bleiben
// (bei Speichermangel besonders ärgerlich). Aufräumen, dann Fehler weiterreichen.
runCatching { photoStorage.deleteAll(newDocumentationId) }
throw e
}
} }
documentationDao.getFullDocumentation(newDocumentationId)!!.toDomain() documentationDao.getFullDocumentation(newDocumentationId)!!.toDomain()
@@ -230,16 +263,22 @@ class DocumentationRepository(
suspend fun addPhoto(documentationId: String, sourceUri: Uri): Photo { suspend fun addPhoto(documentationId: String, sourceUri: Uri): Photo {
val fileName = photoStorage.importFrom(documentationId, sourceUri) val fileName = photoStorage.importFrom(documentationId, sourceUri)
val nextOrder = photoDao.getMaxOrderIndex(documentationId) + 1
val now = Instant.now() val now = Instant.now()
val entity = PhotoEntity( val entity = try {
val nextOrder = photoDao.getMaxOrderIndex(documentationId) + 1
PhotoEntity(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
documentationId = documentationId, documentationId = documentationId,
fileName = fileName, fileName = fileName,
orderIndex = nextOrder, orderIndex = nextOrder,
createdAt = now.toEpochMilli(), createdAt = now.toEpochMilli(),
) ).also { photoDao.insert(it) }
photoDao.insert(entity) } catch (e: Throwable) {
// DB-Eintrag fehlgeschlagen (z. B. Datenbank voll): die gerade importierte Datei
// wäre sonst eine verwaiste Datei ohne Referenz.
runCatching { photoStorage.delete(documentationId, fileName) }
throw e
}
return Photo(entity.id, entity.documentationId, entity.fileName, entity.orderIndex, now) return Photo(entity.id, entity.documentationId, entity.fileName, entity.orderIndex, now)
} }
@@ -255,6 +294,53 @@ class DocumentationRepository(
runCatching { photoStorage.delete(photo.documentationId, photo.fileName) } runCatching { photoStorage.delete(photo.documentationId, photo.fileName) }
} }
/**
* Erzeugt aus einer Wunddokumentation ein PDF im internen App-Speicher (PDF-001/002). Lädt
* die vollständige Dokumentation, übersetzt "nicht gefunden" in einen fachlichen Fehler
* statt einer NullPointerException, mappt anschließend auf das schlanke [de.medapp.wunddoku.pdf.PdfExportModel]
* (dabei werden über [DocumentationToPdfExportModelMapper] ausschließlich bearbeitete
* Blöcke gemäß der bestehenden BlockEditedPolicy sowie die historischen Snapshot-Werte
* verwendet) und delegiert die eigentliche Erzeugung an [pdfExporter].
*/
suspend fun exportPdf(documentationId: String): PdfExportResult {
return try {
val documentation = getFullDocumentation(documentationId)
?: return PdfExportResult.Failure(PdfExportError.DocumentationNotFound)
val model = DocumentationToPdfExportModelMapper.map(documentation)
pdfExporter.export(model)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
// Vertrag: der Export wirft nie - weder bei Datenbankfehlern noch bei Speichermangel.
// Technische Fehlertexte werden bewusst nicht weitergereicht (Datenschutz).
when (FailureCategory.of(e)) {
FailureCategory.OUT_OF_MEMORY -> PdfExportResult.Failure(PdfExportError.OutOfMemory)
FailureCategory.OUT_OF_STORAGE -> PdfExportResult.Failure(PdfExportError.InsufficientStorage)
FailureCategory.OTHER -> PdfExportResult.Failure(PdfExportError.Unexpected(e::class.java.simpleName))
}
}
}
/**
* Rendert eine bereits erzeugte PDF-Datei (s. [exportPdf]) seitenweise in JPEG-Dateien
* (Änderungswunsch: "PDF zusätzlich als JPEG speichern/senden können"). Nimmt bewusst die
* fertige [pdfFile] entgegen, statt die Dokumentation erneut zu laden/zu mappen - der
* Aufrufer hat sie direkt aus einem vorangegangenen [exportPdf]-Aufruf zur Verfügung, ein
* erneutes Laden wäre unnötige Doppelarbeit.
*/
suspend fun convertPdfToImages(documentationId: String, pdfFile: File): PdfToImagesResult =
try {
pdfToImagesConverter.convert(documentationId, pdfFile)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
when (FailureCategory.of(e)) {
FailureCategory.OUT_OF_MEMORY -> PdfToImagesResult.Failure.OutOfMemory
FailureCategory.OUT_OF_STORAGE -> PdfToImagesResult.Failure.InsufficientStorage
FailureCategory.OTHER -> PdfToImagesResult.Failure.Unexpected(e::class.java.simpleName)
}
}
/** /**
* Liefert eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter * Liefert eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter
* der Dokumentationsbearbeitung (kein Laden der vollen gespeicherten Auflösung). Wird von * der Dokumentationsbearbeitung (kein Laden der vollen gespeicherten Auflösung). Wird von
@@ -264,44 +350,127 @@ class DocumentationRepository(
suspend fun loadPhotoThumbnail(documentationId: String, fileName: String): Bitmap? = suspend fun loadPhotoThumbnail(documentationId: String, fileName: String): Bitmap? =
photoStorage.decodeThumbnail(documentationId, fileName, ImageDownsampler.THUMBNAIL_MAX_DIMENSION_PX) photoStorage.decodeThumbnail(documentationId, fileName, ImageDownsampler.THUMBNAIL_MAX_DIMENSION_PX)
suspend fun saveSignature(documentationId: String, signerName: String?, bitmap: Bitmap): Signature { /**
val fileName = signatureFileStorage.save(bitmap) * Lädt ein Foto in Originalgröße für die Vollbildansicht beim Antippen einer Foto-Kachel
* (Änderungswunsch). Verwendet bewusst [de.medapp.wunddoku.media.PhotoStorage.decodeFitted]
* statt eines unskalierten Rohzugriffs: Fotos werden bereits beim Import auf eine sinnvolle
* Maximalauflösung begrenzt (s. PhotoStorage-Importlogik), ein zusätzliches Herunterrechnen
* auf höchstens die tatsächliche Displaygröße ([maxWidthPx]/[maxHeightPx], vom Aufrufer aus
* der Bildschirmauflösung ermittelt) vermeidet unnötig hohen Speicherbedarf, ohne das Bild
* sichtbar zu verkleinern - der Nutzer sieht es exakt so groß wie der Bildschirm es ohnehin
* darstellen könnte.
*/
suspend fun loadPhotoFullSize(documentationId: String, fileName: String, maxWidthPx: Int, maxHeightPx: Int): Bitmap? =
photoStorage.decodeFitted(documentationId, fileName, maxWidthPx, maxHeightPx)
/**
* Lädt eine Signatur für die Vorschau in der Dokumentationsbearbeitung. Kein separates
* Thumbnail-Downsampling nötig - das logische Signaturformat (1000x400px) ist bereits klein
* genug für die direkte Anzeige. Liefert `null` bei fehlender/beschädigter Datei (kein
* Absturz, konsistent zu [loadPhotoThumbnail]).
*/
suspend fun loadSignatureBitmap(documentationId: String, fileName: String): Bitmap? =
signatureStorage.decode(documentationId, fileName)
/**
* Speichert eine neue Signatur (MEDIA-003/004). [signerName] ist fachliches Pflichtfeld:
* wird getrimmt, ein danach leerer String wird abgelehnt (Aufgabenstellung Abschnitt 8) -
* ebenso eine leere Zeichnung ([SignatureBitmapValidation.isBlank], Abschnitt 12: zweite,
* von der UI unabhängige Absicherungsebene). [orderIndex] wird analog [addPhoto] fortlaufend
* je Dokumentation vergeben, damit mehrere Signaturen eine stabile Reihenfolge haben
* (Anzeige UND spätere PDF-Reihenfolge, PDF-003). Die Namensvalidierung erfolgt VOR dem
* Schreiben der PNG-Datei, damit bei einem ungültigen Namen keine verwaiste Datei entsteht.
*
* Aktualisiert nach erfolgreichem Speichern zusätzlich das Wundversorger-Register
* ([WoundCarerRepository.recordSignatureUsage]) mit derselben Signatur - unabhängig davon,
* ob [bitmap] frisch gezeichnet oder aus einem Namensvorschlag im Dialog übernommen wurde.
* Das hat KEINEN Einfluss auf den hier gerade erzeugten, unveränderlichen [Signature]-
* Snapshot dieser Dokumentation.
*/
suspend fun saveSignature(documentationId: String, signerName: String, bitmap: Bitmap): Signature {
val trimmedName = signerName.trim()
require(trimmedName.isNotEmpty()) { "Der Name des Wundversorgers darf nicht leer sein." }
require(!SignatureBitmapValidation.isBlank(bitmap)) { "Eine leere Signatur kann nicht gespeichert werden." }
val fileName = signatureStorage.save(documentationId, bitmap)
val now = Instant.now() val now = Instant.now()
val entity = SignatureEntity( val entity = try {
SignatureEntity(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
documentationId = documentationId, documentationId = documentationId,
signerName = signerName, signerName = trimmedName,
fileName = fileName, fileName = fileName,
orderIndex = signatureDao.getMaxOrderIndex(documentationId) + 1,
createdAt = now.toEpochMilli(), createdAt = now.toEpochMilli(),
) ).also { signatureDao.insert(it) }
signatureDao.insert(entity) } catch (e: Throwable) {
return Signature(entity.id, entity.documentationId, entity.signerName, entity.fileName, now) runCatching { signatureStorage.delete(documentationId, fileName) }
throw e
}
// Das Wundversorger-Register ist nur eine Komfortfunktion (Namensvorschläge). Scheitert
// es, ist die Signatur der Dokumentation trotzdem bereits sicher gespeichert - der
// Nutzer darf dann keine Fehlermeldung bekommen, die ihn die Signatur erneut erfassen lässt.
runCatchingCancellable { woundCarerRepository.recordSignatureUsage(trimmedName, bitmap) }
return Signature(entity.id, entity.documentationId, entity.signerName, entity.fileName, entity.orderIndex, now)
} }
/** Löschreihenfolge analog [removePhoto]: DB zuerst, dann Datei. */ /** Löschreihenfolge analog [removePhoto]: DB zuerst, dann Datei. Signaturen sind nach dem
* Speichern unveränderlich (Aufgabenstellung Abschnitt 17) - "korrigieren" bedeutet fachlich
* immer löschen + neu erfassen, nie ein nachträgliches Bearbeiten. */
suspend fun removeSignature(signatureId: String) { suspend fun removeSignature(signatureId: String) {
val signature = signatureDao.getById(signatureId) ?: return val signature = signatureDao.getById(signatureId) ?: return
signatureDao.delete(signature) signatureDao.delete(signature)
runCatching { signatureFileStorage.delete(signature.fileName) } runCatching { signatureStorage.delete(signature.documentationId, signature.fileName) }
} }
suspend fun delete(documentationId: String) { suspend fun delete(documentationId: String) {
val entity = documentationDao.getById(documentationId) ?: return val entity = documentationDao.getById(documentationId) ?: return
val signatures = signatureDao.getForDocumentation(documentationId)
documentationDao.delete(entity) documentationDao.delete(entity)
// Ein einziger Verzeichnis-Löschvorgang statt Einzeldateien - siehe PhotoStorage- // Je ein einziger Verzeichnis-Löschvorgang statt Einzeldateien für Fotos UND Signaturen
// Kommentar zur dokumentationsbezogenen Ordnerstruktur. Room-Cascade entfernt zwar die // - beide Storage-Implementierungen organisieren ihre Dateien pro documentationId in
// PhotoEntity-Zeilen automatisch, aber keine Dateien aus filesDir - das übernimmt // einem eigenen Unterordner (s. PhotoStorage-/SignatureStorage-Kommentar). Room-Cascade
// ausschließlich diese Methode. // entfernt zwar die PhotoEntity-/SignatureEntity-Zeilen automatisch, aber keine Dateien
// aus filesDir - das übernimmt ausschließlich diese Methode. Da deleteAll() über den
// Ordnernamen (documentationId) statt über zuvor geladene DB-Zeilen arbeitet, muss hier
// - anders als vorher bei den Signaturen - auch keine Liste mehr vor dem DB-Delete
// geladen werden.
runCatching { photoStorage.deleteAll(documentationId) } runCatching { photoStorage.deleteAll(documentationId) }
signatures.forEach { runCatching { signatureFileStorage.delete(it.fileName) } } runCatching { signatureStorage.deleteAll(documentationId) }
// Datenschutz (Löschkonzept): Auch abgeleitete Kopien der Patientendaten - exportierte
// PDFs und deren Seitenbilder - müssen mit der Dokumentation verschwinden.
runCatching { exportedFilesCleaner.deleteExportsFor(documentationId) }
} }
/** /**
* Legt für jeden Katalog-Block einen BlockState an, sowie je ein CheckItemState pro * Legt für jeden Katalog-Block einen BlockState an, sowie je ein CheckItemState pro
* Katalog-Checkbox PLUS eine zusätzliche, automatisch angehängte Freitext-Option * Katalog-Eintrag PLUS eine zusätzliche, automatisch angehängte generische Freitext-Option
* (isFreeText = true, letzte Position im Block) - siehe DocumentationCatalog.FREE_TEXT_LABEL * (isFreeText = true, Label "Freitext") - siehe DocumentationCatalog.FREE_TEXT_LABEL und
* und BlockDefinition.freeTextDefinitionId. * BlockDefinition.freeTextDefinitionId. Diese generische Option wird WEITERHIN für JEDEN
* Block angehängt, unabhängig davon, ob der Block selbst bereits eigene, fachlich benannte
* `isFreeText`-Einträge definiert (z. B. "Wundgrund + Erscheinung" oder "Kopie
* nachrichtlich an") - so bestätigt und akzeptiert, auch wenn das für einzelne Blöcke eine
* zusätzliche, generisch benannte Freitext-Checkbox neben den fachlichen Einträgen bedeutet.
*
* Katalog-Einträge werden 1:1 aus [BlockDefinition.items] übernommen: `isFreeText`,
* `isStaticHint` und `inputSpec` steuern direkt das Verhalten der jeweiligen Zeile (normale
* Checkbox, eigene benannte Freitext-Checkbox, nicht editierbarer Hinweistext, oder
* strukturierte Eingabefelder) - keine block-spezifische Sonderlogik nötig, die
* Katalogdefinition allein entscheidet.
*
* Hinweistext-Einträge (`isStaticHint = true`) werden bereits HIER mit `checked = true`
* angelegt (statt änderbar per UI-Checkbox) - dadurch greift die bestehende, unveränderte
* "nur angehakte Elemente"-Regel im PDF-Export automatisch auch für sie, ohne dass diese
* Regel selbst angepasst werden musste. [de.medapp.wunddoku.domain.usecase.BlockEditedPolicy]
* schließt solche Zeilen dagegen explizit aus der "bearbeitet"-Prüfung aus.
*
* Änderungswunsch: Die generische Freitext-Option erscheint jetzt als LETZTES Element im
* Block (itemOrder = höchste vorkommende Katalog-`order` + 1) - zuvor war sie das ERSTE
* Element (itemOrder = 0). Vom Katalog selbst definierte, eigens benannte Freitext-Einträge
* (isFreeText = true) behalten dagegen ihre im Katalog festgelegte Position, sofern der
* jeweilige Block das explizit so vorsieht. Betrifft ausschließlich NEU erstellte
* Dokumentationen: bereits bestehende BlockState-/CheckItemState-Zeilen behalten ihre
* historisch gespeicherte itemOrder unverändert (Snapshot-Prinzip, s. Klassendokumentation
* zu titleSnapshot/labelSnapshot).
*/ */
private suspend fun insertBlocksFromCatalog(documentationId: String, now: Long) { private suspend fun insertBlocksFromCatalog(documentationId: String, now: Long) {
for (blockDef in DocumentationCatalog.blocks) { for (blockDef in DocumentationCatalog.blocks) {
@@ -319,29 +488,39 @@ class DocumentationRepository(
) )
) )
val normalItems = blockDef.items.map { itemDef -> val catalogItems = blockDef.items.map { itemDef ->
CheckItemStateEntity( CheckItemStateEntity(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
blockStateId = blockStateId, blockStateId = blockStateId,
checkItemDefinitionId = itemDef.id, checkItemDefinitionId = itemDef.id,
labelSnapshot = itemDef.label, labelSnapshot = itemDef.label,
itemOrder = itemDef.order, itemOrder = itemDef.order,
checked = false, // Hinweistexte starten bereits "angehakt" (s. Methodenkommentar) - alle
isFreeText = false, // anderen Einträge (Checkbox, eigene Freitext-Checkbox oder strukturierte
// Eingabe) starten unangehakt/leer, wie bisher.
checked = itemDef.isStaticHint,
isFreeText = itemDef.isFreeText,
isStaticHint = itemDef.isStaticHint,
freeTextValue = "", freeTextValue = "",
inputSpec = itemDef.inputSpec?.let(InputSpecCodec::encode) ?: "",
) )
} }
val freeTextItem = CheckItemStateEntity( // Änderungswunsch: generische Freitext-Option jetzt NACH allen Katalog-Einträgen
// dieses Blocks (s. Methodenkommentar) statt davor.
val genericFreeTextOrder = (blockDef.items.maxOfOrNull { it.order } ?: 0) + 1
val genericFreeTextItem = CheckItemStateEntity(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
blockStateId = blockStateId, blockStateId = blockStateId,
checkItemDefinitionId = blockDef.freeTextDefinitionId, checkItemDefinitionId = blockDef.freeTextDefinitionId,
labelSnapshot = DocumentationCatalog.FREE_TEXT_LABEL, labelSnapshot = DocumentationCatalog.FREE_TEXT_LABEL,
itemOrder = blockDef.items.size + 1, itemOrder = genericFreeTextOrder,
checked = false, checked = false,
isFreeText = true, isFreeText = true,
isStaticHint = false,
freeTextValue = "", freeTextValue = "",
inputSpec = "",
) )
checkItemStateDao.insertAll(normalItems + freeTextItem) checkItemStateDao.insertAll(catalogItems + genericFreeTextItem)
} }
} }
} }
@@ -0,0 +1,82 @@
package de.medapp.wunddoku.data.repository
import android.graphics.Bitmap
import de.medapp.wunddoku.data.dao.WoundCarerDao
import de.medapp.wunddoku.data.entity.WoundCarerEntity
import de.medapp.wunddoku.domain.model.WoundCarer
import de.medapp.wunddoku.signature.SignatureStorage
import java.time.Instant
import java.util.UUID
/**
* Leichtgewichtiges "Zuletzt verwendet"-Register für Wundversorger (Namensvorschläge +
* Vorbelegung im Signatur-Dialog mit Name UND Signatur des zuletzt verwendeten Wundversorgers).
*
* AUSDRÜCKLICH KEIN Benutzer-/Account-/Mitarbeitersystem: keine Anmeldung, keine Rechte, keine
* Verknüpfung zu [DocumentationRepository]-Signaturen, und (vorerst) keine eigene
* Verwaltungsansicht. Ändert insbesondere NIE bereits gespeicherte, historische
* [de.medapp.wunddoku.domain.model.Signature]-Snapshots einzelner Dokumentationen - diese bleiben
* unveränderlich, wie ursprünglich festgelegt. Diese Klasse verwaltet eine komplett separate
* Tabelle (`wound_carers`) und ein separates Dateiverzeichnis
* (`filesDir/wound_carer_signatures/`, s. [de.medapp.wunddoku.signature.InternalSignatureStorage]).
*/
class WoundCarerRepository(
private val woundCarerDao: WoundCarerDao,
private val woundCarerSignatureStorage: SignatureStorage,
) {
/** Für die Vorbelegung des Signatur-Dialogs beim Öffnen: der zuletzt verwendete
* Wundversorger (app-weit, nicht auf eine einzelne Dokumentation beschränkt) - `null`, wenn
* noch nie eine Signatur gespeichert wurde. */
suspend fun getMostRecentlyUsed(): WoundCarer? = woundCarerDao.getMostRecentlyUsed()?.toDomain()
/** Namensvorschläge nach den ersten eingegebenen Buchstaben. Leerer/blank [prefix] liefert
* bewusst keine Vorschläge (kein "alle anzeigen" bei leerem Feld). */
suspend fun search(prefix: String, limit: Int = 5): List<WoundCarer> {
val trimmed = prefix.trim()
if (trimmed.isEmpty()) return emptyList()
return woundCarerDao.searchByNamePrefix(trimmed, limit).map { it.toDomain() }
}
/** Lädt die aktuell hinterlegte Signatur eines bekannten Wundversorgers, z. B. zur Anzeige
* einer Vorschau im Signatur-Dialog nach Auswahl eines Namensvorschlags. */
suspend fun loadSignatureBitmap(woundCarerId: String, fileName: String): Bitmap? =
woundCarerSignatureStorage.decode(woundCarerId, fileName)
/**
* Wird bei JEDER erfolgreich gespeicherten Signatur aufgerufen (s.
* [DocumentationRepository.saveSignature]) und aktualisiert das Register: existiert unter
* [name] (getrimmt, case-insensitiver Abgleich) bereits ein Eintrag, wird dessen Signatur
* durch [bitmap] ersetzt (alte Datei wird gelöscht) und der Zeitstempel aktualisiert -
* "die letzte geänderte Version der Unterschrift eines Wundversorgers wird gespeichert".
* Andernfalls wird ein neuer Eintrag angelegt. Verwendet dieselbe [bitmap]-Instanz, die auch
* als historischer Snapshot der jeweiligen Dokumentation gespeichert wird - es wird lediglich
* eine ZWEITE, unabhängige PNG-Kopie in einem eigenen Verzeichnis abgelegt (kein Teilen
* derselben Datei), damit ein späteres Löschen der Dokumentation dieses Register niemals
* beeinträchtigen kann.
*/
suspend fun recordSignatureUsage(name: String, bitmap: Bitmap) {
val existing = woundCarerDao.findByName(name)
val id = existing?.id ?: UUID.randomUUID().toString()
val newFileName = woundCarerSignatureStorage.save(id, bitmap)
woundCarerDao.insertOrUpdate(
WoundCarerEntity(
id = id,
name = name,
signatureFileName = newFileName,
updatedAt = System.currentTimeMillis(),
)
)
val oldFileName = existing?.signatureFileName
if (oldFileName != null && oldFileName != newFileName) {
runCatching { woundCarerSignatureStorage.delete(id, oldFileName) }
}
}
private fun WoundCarerEntity.toDomain() = WoundCarer(
id = id,
name = name,
signatureFileName = signatureFileName,
updatedAt = Instant.ofEpochMilli(updatedAt),
)
}
@@ -4,10 +4,17 @@ import android.content.Context
import de.medapp.wunddoku.data.database.AppDatabase import de.medapp.wunddoku.data.database.AppDatabase
import de.medapp.wunddoku.data.repository.DocumentationRepository import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.data.repository.PatientRepository import de.medapp.wunddoku.data.repository.PatientRepository
import de.medapp.wunddoku.data.repository.WoundCarerRepository
import de.medapp.wunddoku.media.InternalPhotoStorage import de.medapp.wunddoku.media.InternalPhotoStorage
import de.medapp.wunddoku.media.PhotoStorage import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.signature.InternalSignatureFileStorage import de.medapp.wunddoku.pdf.AndroidPdfDocumentExporter
import de.medapp.wunddoku.signature.SignatureFileStorage import de.medapp.wunddoku.pdf.AndroidPdfToImagesConverter
import de.medapp.wunddoku.pdf.DocumentationPdfExporter
import de.medapp.wunddoku.pdf.ExportedFilesCleaner
import de.medapp.wunddoku.pdf.InternalExportedFilesCleaner
import de.medapp.wunddoku.pdf.PdfToImagesConverter
import de.medapp.wunddoku.signature.InternalSignatureStorage
import de.medapp.wunddoku.signature.SignatureStorage
/** /**
* Manuelles Dependency Injection statt Hilt/Koin: Bei dieser Projektgröße (zwei Repositories, * Manuelles Dependency Injection statt Hilt/Koin: Bei dieser Projektgröße (zwei Repositories,
@@ -26,12 +33,33 @@ class AppContainer(context: Context) {
private val database: AppDatabase by lazy { AppDatabase.getInstance(context) } private val database: AppDatabase by lazy { AppDatabase.getInstance(context) }
private val photoStorage: PhotoStorage by lazy { InternalPhotoStorage(context) } private val photoStorage: PhotoStorage by lazy { InternalPhotoStorage(context) }
private val signatureFileStorage: SignatureFileStorage by lazy { InternalSignatureFileStorage(context) } private val signatureStorage: SignatureStorage by lazy { InternalSignatureStorage(context) }
/** Eigenes Wurzelverzeichnis, komplett unabhängig von den dokumentationsbezogenen
* Signaturen - s. [InternalSignatureStorage]-Klassenkommentar. */
private val woundCarerSignatureStorage: SignatureStorage by lazy {
InternalSignatureStorage(context, rootDirName = "wound_carer_signatures")
}
private val pdfExporter: DocumentationPdfExporter by lazy {
AndroidPdfDocumentExporter(context, photoStorage, signatureStorage)
}
private val pdfToImagesConverter: PdfToImagesConverter by lazy { AndroidPdfToImagesConverter(context) }
/** Löscht Exporte gelöschter Dokumentationen sowie veraltete Exporte/Kameraaufnahmen. */
val exportedFilesCleaner: ExportedFilesCleaner by lazy {
InternalExportedFilesCleaner(filesDir = context.filesDir, cacheDir = context.cacheDir)
}
val patientRepository: PatientRepository by lazy { val patientRepository: PatientRepository by lazy {
PatientRepository(patientDao = database.patientDao()) PatientRepository(patientDao = database.patientDao())
} }
val woundCarerRepository: WoundCarerRepository by lazy {
WoundCarerRepository(
woundCarerDao = database.woundCarerDao(),
woundCarerSignatureStorage = woundCarerSignatureStorage,
)
}
val documentationRepository: DocumentationRepository by lazy { val documentationRepository: DocumentationRepository by lazy {
DocumentationRepository( DocumentationRepository(
documentationDao = database.documentationDao(), documentationDao = database.documentationDao(),
@@ -41,8 +69,12 @@ class AppContainer(context: Context) {
signatureDao = database.signatureDao(), signatureDao = database.signatureDao(),
patientDao = database.patientDao(), patientDao = database.patientDao(),
photoStorage = photoStorage, photoStorage = photoStorage,
signatureFileStorage = signatureFileStorage, signatureStorage = signatureStorage,
woundCarerRepository = woundCarerRepository,
pdfExporter = pdfExporter,
pdfToImagesConverter = pdfToImagesConverter,
db = database, db = database,
exportedFilesCleaner = exportedFilesCleaner,
) )
} }
} }
@@ -1,66 +1,599 @@
package de.medapp.wunddoku.domain.catalog package de.medapp.wunddoku.domain.catalog
import de.medapp.wunddoku.domain.model.InputFieldDef
import de.medapp.wunddoku.domain.model.InputSpec
/** /**
* Zentrale, statische Definition aller Dokumentationsblöcke und ihrer Checkboxen. * Zentrale, statische Definition aller Dokumentationsblöcke und ihrer Checkboxen.
* *
* WICHTIG: Die hier verwendeten Inhalte sind ausschließlich neutrale Platzhalter * Enthält die mit dem Kunden final abgestimmten Dokumentationsabschnitte. Basis ist die
* (Vorgabe aus der Aufgabenstellung, Abschnitt 25: "Erfinde keine medizinischen Inhalte"). * handschriftlich vorgegebene 22-Punkte-Reihenfolge, zunächst ergänzt/verfeinert nach Abgleich
* Die späteren echten Inhalte (ca. 20 fachliche Blöcke) werden ausschließlich in dieser * mit dem ursprünglichen Papierformular, und in dieser Fassung ein weiteres Mal überarbeitet
* Datei ergänzt – keine andere Stelle im Code (UI, Datenbank) muss dafür geändert werden. * nach konkreten Änderungswünschen (u. a. neue Checkbox-Listen für Wundart/Vitalwerte/
* Auswirkungen aus Fotos, strukturierte Eingabefelder statt Freitext für Maße/Uhrzeiten/Tiefen,
* ein neuer Abschnitt "Wundheilung beeinflussende Faktoren").
*
* WICHTIGE GENERELLE REGEL (Änderungswunsch): Die automatisch an jeden Block angehängte
* generische Freitext-Option (s. [FREE_TEXT_LABEL]) erscheint jetzt als LETZTES Element eines
* Blocks (vorher: als erstes) - siehe [de.medapp.wunddoku.data.repository.DocumentationRepository.insertBlocksFromCatalog].
* Vom Katalog selbst eigens benannte Freitext-Einträge (`isFreeText = true` an einem konkreten
* [CheckItemDefinition]) behalten dagegen ihre hier festgelegte Position.
*
* Frühere, im Kundenchat zwischenzeitlich diskutierte, aber später ausdrücklich verworfene
* Inhalte (Wortlaut-Variante der alten Wundart-Checkboxen, "Schmerzempfinden 1-10") sind
* weiterhin bewusst NICHT enthalten.
* *
* Diese Definitionen sind bewusst reiner Kotlin-Code und keine Room-Tabellen: Sie ändern sich * Diese Definitionen sind bewusst reiner Kotlin-Code und keine Room-Tabellen: Sie ändern sich
* nur durch neue App-Versionen, nicht durch Nutzerinteraktion. Siehe Architektur-Dokument * nur durch neue App-Versionen, nicht durch Nutzerinteraktion. Siehe Architektur-Dokument
* Abschnitt 2.3b. * Abschnitt 2.3b.
* *
* [CATALOG_VERSION] wird beim Erstellen einer neuen Dokumentation als rein informatives * [CATALOG_VERSION] wird beim Erstellen einer neuen Dokumentation als rein informatives
* Metadatum in Documentation.catalogVersion übernommen. Erhöhe diesen Wert, wenn sich Blöcke * Metadatum in Documentation.catalogVersion übernommen. Auf 6 erhöht (Wundart/Wundgrund geteilt, Wundrandschutz neu geordnet, Granudacyn und Freitext entkoppelt).
* oder Checkboxen inhaltlich ändern (nicht bei rein redaktionellen Korrekturen wie
* Tippfehlern) – rein zu Nachvollziehbarkeitszwecken, nicht sicherheitsrelevant für die
* historische Korrektheit (das übernehmen die Label-/Titel-Snapshots).
*/ */
object DocumentationCatalog { object DocumentationCatalog {
const val CATALOG_VERSION: Int = 1 const val CATALOG_VERSION: Int = 6
/** /**
* Label der automatisch an jeden Block angehängten Freitext-Option (Änderungswunsch: * Label der automatisch an jeden Block angehängten Freitext-Option. Zentral hier definiert,
* "Freitext soll selbst wie eine weitere auswählbare Option behandelt werden"). Zentral * damit Repository (Erstellung/Fortsetzen) und PDF-Export dieselbe Bezeichnung verwenden.
* hier definiert, damit Repository (Erstellung/Fortsetzen) und ggf. der spätere PDF-Export * Gilt weiterhin unverändert für ALLE Blöcke, auch für solche, die fachlich "nur
* dieselbe Bezeichnung verwenden. * Checkboxen" wären - ausdrücklich bestätigt und kein Fehler.
*/ */
const val FREE_TEXT_LABEL: String = "Freitext" const val FREE_TEXT_LABEL: String = "Freitext"
// Wiederverwendete Auswahllisten für strukturierte Eingabefelder (s. InputFieldDef.Choice).
private val HOUR_OPTIONS: List<String> = (1..12).map { it.toString() }
private val WEEKDAY_COUNT_OPTIONS: List<String> = (1..7).map { it.toString() }
private val MINUTE_1_TO_15_OPTIONS: List<String> = (1..15).map { it.toString() }
private val DEPTH_HALF_CM_OPTIONS: List<String> = listOf(
"0,5 cm", "1 cm", "1,5 cm", "2 cm", "2,5 cm", "3 cm", "3,5 cm", "4 cm",
"4,5 cm", "5 cm", "5,5 cm", "6 cm", "6,5 cm", "7 cm", "7,5 cm", "8 cm", ">8 cm",
)
val blocks: List<BlockDefinition> = listOf( val blocks: List<BlockDefinition> = listOf(
BlockDefinition( BlockDefinition(
id = "block_01", id = "wundart",
title = "Block 1", title = "Wundart",
order = 1, order = 1,
items = listOf( items = listOf(
CheckItemDefinition(id = "block_01_a", label = "Option A", order = 1), CheckItemDefinition(id = "wundart_nicht_bekannt_diagnostiziert", label = "Noch nicht diagnostiziert / nicht bekannt", order = 1),
CheckItemDefinition(id = "block_01_b", label = "Option B", order = 2), CheckItemDefinition(id = "wundart_erscheint_wie", label = "Erscheint wie", order = 2, isFreeText = true),
CheckItemDefinition(id = "wundart_decubitus", label = "Decubitus", order = 3),
CheckItemDefinition(id = "wundart_ulcus_cruris_unbezeichnet", label = "Ulcus Cruris (nicht näher bezeichnet)", order = 4),
CheckItemDefinition(id = "wundart_ulcus_cruris_venosum", label = "Ulcus Cruris ven.", order = 5),
CheckItemDefinition(id = "wundart_ulcus_cruris_arteriosum", label = "Ulcus Cruris art.", order = 6),
CheckItemDefinition(id = "wundart_ulcus_cruris_venosum_arteriosum", label = "Ulcus Cruris ven.-art.", order = 7),
CheckItemDefinition(id = "wundart_diabetisches_fusssyndrom", label = "Diab. Fuß Syndrom", order = 8),
CheckItemDefinition(id = "wundart_post_operative_wundheilungsstoerung", label = "Post-operative Wundheilungsstörung", order = 9),
CheckItemDefinition(id = "wundart_dehiszenz", label = "Dehiszenz", order = 10),
CheckItemDefinition(id = "wundart_schuerfwunde", label = "Schürfwunde", order = 11),
CheckItemDefinition(id = "wundart_intertrigo", label = "Intertrigo / Wunde nach Intertrigo", order = 12),
CheckItemDefinition(id = "wundart_iad", label = "IAD (Inkontinenz-assoziierte Dermatitis) / Wunde nach IAD", order = 13),
CheckItemDefinition(id = "wundart_amputationswunde", label = "Amputationswunde", order = 14),
CheckItemDefinition(id = "wundart_spalthautentnahmestelle", label = "Spalthautentnahmestelle", order = 15),
CheckItemDefinition(
id = "wundart_transplantatverlust",
label = "Wunde nach Transplantatverlust",
order = 16,
inputSpec = InputSpec(
pdfTemplate = "Wunde nach Transplantatverlust, Spalthautdeckung ca. {0}%",
fields = listOf(InputFieldDef.Number(label = "Spalthautdeckung ca. (%)", maxLength = 3)),
),
),
CheckItemDefinition(id = "wundart_sekundaer_heilende_wunde", label = "Sekundär heilende Wunde", order = 17),
CheckItemDefinition(id = "wundart_tumorwunde", label = "Tumorwunde", order = 18),
CheckItemDefinition(id = "wundart_wunde_nach_abszess", label = "Wunde nach Abszess", order = 19),
CheckItemDefinition(id = "wundart_verbrennung_verbruehung", label = "Verbrennung / Verbrühung", order = 20),
CheckItemDefinition(id = "wundart_platzwunde", label = "Platzwunde", order = 21),
CheckItemDefinition(id = "wundart_schnittwunde", label = "Schnittwunde", order = 22),
CheckItemDefinition(id = "wundart_bisswunde", label = "Bisswunde", order = 23),
CheckItemDefinition(id = "wundart_chemisch_veraetzung", label = "Chemisch hervorgerufene Wunde / Verätzung", order = 24),
CheckItemDefinition(id = "wundart_strahlenschaden", label = "Strahlenschaden", order = 25),
), ),
), ),
BlockDefinition( BlockDefinition(
id = "block_02", id = "lokalisation",
title = "Block 2", title = "Lokalisation",
order = 2, order = 2,
items = listOf( items = listOf(
CheckItemDefinition(id = "block_02_a", label = "Option A", order = 1), CheckItemDefinition(id = "lokalisation_multiple_wunden", label = "multiple Wunden im Bereich", order = 1),
CheckItemDefinition(id = "block_02_b", label = "Option B", order = 2),
CheckItemDefinition(id = "block_02_c", label = "Option C", order = 3),
), ),
), ),
BlockDefinition( BlockDefinition(
id = "block_03", id = "groesse",
title = "Block 3", title = "Größe",
order = 3, order = 3,
items = listOf( items = listOf(
CheckItemDefinition(id = "block_03_a", label = "Option A", order = 1), CheckItemDefinition(id = "groesse_hinweis_masse", label = "Maße", order = 1, isStaticHint = true),
CheckItemDefinition(id = "block_03_b", label = "Option B", order = 2), CheckItemDefinition(
id = "groesse_laenge_breite_tiefe",
label = "Maße (Länge / Breite / Tiefe)",
order = 2,
inputSpec = InputSpec(
pdfTemplate = "Länge {0} cm, Breite {1} cm, Tiefe {2} cm",
fields = listOf(
InputFieldDef.Number(label = "Länge (cm)", maxLength = 5, allowDecimal = true),
InputFieldDef.Number(label = "Breite (cm)", maxLength = 5, allowDecimal = true),
InputFieldDef.Number(label = "Tiefe (cm)", maxLength = 5, allowDecimal = true),
), ),
), ),
// Weitere Blöcke (insgesamt ca. 20) werden hier ergänzt, sobald die fachlichen ),
// Inhalte geliefert werden. Struktur und Reihenfolge bleiben identisch. CheckItemDefinition(id = "groesse_freitext_masse", label = "Freitext für die Maße", order = 3, isFreeText = true),
CheckItemDefinition(id = "groesse_multiple_wunden_max", label = "Multiple Wunden mit max. (cm)", order = 4, isFreeText = true),
CheckItemDefinition(id = "groesse_hinweis_areal", label = "Zu versorgendes Areal", order = 5, isStaticHint = true),
CheckItemDefinition(id = "groesse_areal", label = "Zu versorgendes Areal", order = 6),
CheckItemDefinition(id = "groesse_freitext_areal", label = "Freitext für zu versorgendes Areal", order = 7, isFreeText = true),
),
),
BlockDefinition(
id = "verlauf",
title = "Verlauf",
order = 4,
items = listOf(
CheckItemDefinition(id = "verlauf_positiv", label = "Positiv", order = 1),
CheckItemDefinition(id = "verlauf_negativ", label = "Negativ", order = 2),
CheckItemDefinition(id = "verlauf_stagnierend", label = "Stagnierend", order = 3),
CheckItemDefinition(id = "verlauf_nicht_bekannt", label = "Nicht bekannt", order = 4),
),
),
BlockDefinition(
id = "exsudat",
title = "Exsudat",
order = 5,
items = listOf(
CheckItemDefinition(id = "exsudat_kein", label = "Kein", order = 1),
CheckItemDefinition(id = "exsudat_wenig", label = "Wenig", order = 2),
CheckItemDefinition(id = "exsudat_maessig", label = "Mäßig", order = 3),
CheckItemDefinition(id = "exsudat_viel", label = "Viel", order = 4),
CheckItemDefinition(id = "exsudat_uebermaessig", label = "Übermäßig", order = 5),
CheckItemDefinition(id = "exsudat_waessrig", label = "Wässrig", order = 6),
CheckItemDefinition(id = "exsudat_zaehfluessig", label = "Zähflüssig", order = 7),
CheckItemDefinition(id = "exsudat_eitrig", label = "Eitrig", order = 8),
CheckItemDefinition(id = "exsudat_blutig", label = "Blutig", order = 9),
CheckItemDefinition(id = "exsudat_klar", label = "Klar", order = 10),
CheckItemDefinition(id = "exsudat_gelblich", label = "Gelblich", order = 11),
CheckItemDefinition(id = "exsudat_gruenlich", label = "Grünlich", order = 12),
CheckItemDefinition(id = "exsudat_braeunlich", label = "Bräunlich", order = 13),
CheckItemDefinition(id = "exsudat_roetlich", label = "Rötlich", order = 14),
CheckItemDefinition(id = "exsudat_weisslich", label = "Weißlich", order = 15),
),
),
BlockDefinition(
id = "wundgrund_erscheinung",
title = "Wundgrund + Erscheinung",
order = 6,
items = listOf(
CheckItemDefinition(id = "wundgrund_wie_ausgestanzt", label = "Wie ausgestanzt", order = 1),
CheckItemDefinition(id = "wundgrund_kraterfoermig", label = "Kraterförmig", order = 2),
CheckItemDefinition(id = "wundgrund_unregelmaessig", label = "Unregelmäßig", order = 3),
CheckItemDefinition(id = "wundgrund_landkartenartig_amorph", label = "Landkartenartig/amorph", order = 4),
CheckItemDefinition(id = "wundgrund_linear_spaltfoermig", label = "Linear/spaltförmig", order = 5),
CheckItemDefinition(id = "wundgrund_rund", label = "Rund", order = 6),
CheckItemDefinition(id = "wundgrund_oval_elliptisch", label = "Oval/elliptisch", order = 7),
CheckItemDefinition(id = "wundgrund_quadrangulaer", label = "Quadrangulär", order = 8),
CheckItemDefinition(id = "wundgrund_eben", label = "Eben", order = 9),
CheckItemDefinition(id = "wundgrund_auf_niveau", label = "Auf Niveau", order = 10),
CheckItemDefinition(id = "wundgrund_ueber_niveau", label = "Über Niveau", order = 11),
CheckItemDefinition(id = "wundgrund_glaenzend", label = "Glänzend", order = 12),
CheckItemDefinition(id = "wundgrund_grob", label = "Grob", order = 13),
CheckItemDefinition(id = "wundgrund_fein", label = "Fein", order = 14),
CheckItemDefinition(id = "wundgrund_glatt", label = "Glatt", order = 15),
CheckItemDefinition(id = "wundgrund_rot", label = "Rot", order = 16),
CheckItemDefinition(id = "wundgrund_blass", label = "Blass", order = 17),
CheckItemDefinition(id = "wundgrund_knochen_sehnen", label = "Knochen/Sehnen", order = 18),
CheckItemDefinition(id = "wundgrund_haemorrhagie", label = "Hämorrhagie", order = 19),
CheckItemDefinition(id = "wundgrund_nicht_kaum_einsehbar", label = "Nicht/kaum einsehbar", order = 20),
CheckItemDefinition(id = "wundgrund_erscheinung_erscheinung", label = "Erscheinung", order = 21, isFreeText = true),
),
),
BlockDefinition(
id = "belag",
title = "Belag",
order = 7,
items = listOf(
CheckItemDefinition(id = "belag_nein", label = "Nein", order = 1),
CheckItemDefinition(id = "belag_ja", label = "Ja", order = 2),
CheckItemDefinition(id = "belag_fibrin", label = "Fibrin", order = 3),
CheckItemDefinition(id = "belag_fremdkoerper", label = "Fremdkörper", order = 4),
CheckItemDefinition(id = "belag_nekrose", label = "Nekrose", order = 5),
CheckItemDefinition(id = "belag_kruste", label = "Kruste", order = 6),
CheckItemDefinition(id = "belag_biofilm", label = "Biofilm", order = 7),
CheckItemDefinition(id = "belag_mit_fibrinsaum", label = "Mit Fibrinsaum", order = 8),
CheckItemDefinition(id = "belag_trocken", label = "Trocken", order = 9),
CheckItemDefinition(id = "belag_feucht", label = "Feucht", order = 10),
CheckItemDefinition(id = "belag_100_prozent", label = "100%", order = 11),
CheckItemDefinition(id = "belag_75_prozent", label = "75%", order = 12),
CheckItemDefinition(id = "belag_50_prozent", label = "50%", order = 13),
CheckItemDefinition(id = "belag_25_prozent_plus", label = "25%+<", order = 14),
),
),
BlockDefinition(
id = "wundrand",
title = "Wundrand",
order = 8,
items = listOf(
CheckItemDefinition(id = "wundrand_unauffaellig", label = "Unauffällig", order = 1),
CheckItemDefinition(id = "wundrand_partiell_unterschiedlich", label = "Partiell unterschiedlich", order = 2),
CheckItemDefinition(id = "wundrand_rosarot", label = "Rosarot", order = 3),
CheckItemDefinition(id = "wundrand_rot", label = "Rot", order = 4),
CheckItemDefinition(id = "wundrand_blaeulich", label = "Bläulich", order = 5),
CheckItemDefinition(id = "wundrand_blass", label = "Blass", order = 6),
CheckItemDefinition(id = "wundrand_blutig", label = "Blutig", order = 7),
CheckItemDefinition(id = "wundrand_mit_kruste_belegt", label = "Mit Kruste belegt", order = 8),
CheckItemDefinition(id = "wundrand_mazeriert", label = "Mazeriert", order = 9),
CheckItemDefinition(id = "wundrand_unterminiert", label = "Unterminiert", order = 10),
CheckItemDefinition(id = "wundrand_trocken", label = "Trocken", order = 11),
CheckItemDefinition(id = "wundrand_glatt", label = "Glatt", order = 12),
CheckItemDefinition(id = "wundrand_flach_einlaufend", label = "Flach einlaufend", order = 13),
CheckItemDefinition(id = "wundrand_wallartig", label = "Wallartig", order = 14),
CheckItemDefinition(id = "wundrand_wulstig", label = "Wulstig", order = 15),
CheckItemDefinition(id = "wundrand_zerklueftet", label = "Zerklüftet", order = 16),
CheckItemDefinition(id = "wundrand_ueberhaengende_strukturen_lefzen", label = "Überhängende Strukturen/Lefzen", order = 17),
),
),
BlockDefinition(
id = "wundumgebung",
title = "Wundumgebung",
order = 9,
items = listOf(
CheckItemDefinition(id = "wundumgebung_unauffaellig", label = "Unauffällig", order = 1),
CheckItemDefinition(id = "wundumgebung_oedem", label = "Ödem", order = 2),
CheckItemDefinition(id = "wundumgebung_ekzem", label = "Ekzem", order = 3),
CheckItemDefinition(id = "wundumgebung_schuppig", label = "Schuppig", order = 4),
CheckItemDefinition(id = "wundumgebung_narbengewebe", label = "Narbengewebe", order = 5),
CheckItemDefinition(id = "wundumgebung_hornhaut_verhaertet", label = "Hornhaut/verhärtet", order = 6),
CheckItemDefinition(id = "wundumgebung_geroetet", label = "Gerötet", order = 7),
CheckItemDefinition(id = "wundumgebung_blaeulich", label = "Bläulich", order = 8),
CheckItemDefinition(id = "wundumgebung_blass", label = "Blass", order = 9),
CheckItemDefinition(id = "wundumgebung_hyperkeratose", label = "Hyperkeratose", order = 10),
CheckItemDefinition(id = "wundumgebung_sehr_trockene_haut", label = "Sehr trockene Haut", order = 11),
CheckItemDefinition(id = "wundumgebung_pergamenthaut", label = "Pergamenthaut", order = 12),
CheckItemDefinition(id = "wundumgebung_rissig_kratzspuren", label = "Rissig/Kratzspuren", order = 13),
CheckItemDefinition(id = "wundumgebung_spannungsblasen", label = "Spannungsblasen", order = 14),
CheckItemDefinition(id = "wundumgebung_haematome", label = "Hämatome", order = 15),
),
),
BlockDefinition(
id = "fistel_tasche",
title = "Fistel / Tasche",
order = 10,
items = listOf(
CheckItemDefinition(id = "fistel_tasche_nein", label = "Nein", order = 1),
CheckItemDefinition(id = "fistel_tasche_ja", label = "Ja", order = 2),
CheckItemDefinition(id = "fistel_tasche_zirkulaer", label = "Zirkulär", order = 3),
CheckItemDefinition(
id = "fistel_tasche_von_bis_uhr",
label = "Von ... bis ... Uhr",
order = 4,
inputSpec = InputSpec(
pdfTemplate = "Von {0} bis {1} Uhr",
fields = listOf(
InputFieldDef.Choice(label = "Von", options = HOUR_OPTIONS),
InputFieldDef.Choice(label = "Bis", options = HOUR_OPTIONS),
),
),
),
CheckItemDefinition(
id = "fistel_tasche_tiefe",
label = "Tiefe",
order = 5,
inputSpec = InputSpec(
pdfTemplate = "Tiefe: {0}",
fields = listOf(InputFieldDef.Choice(label = "Tiefe", options = DEPTH_HALF_CM_OPTIONS)),
),
),
),
),
BlockDefinition(
id = "entzuendungszeichen",
title = "Entzündungszeichen",
order = 11,
items = listOf(
CheckItemDefinition(id = "entzuendungszeichen_nein", label = "Nein", order = 1),
CheckItemDefinition(id = "entzuendungszeichen_temperatur_erhoeht", label = "Temperatur erhöht", order = 2),
CheckItemDefinition(id = "entzuendungszeichen_schwellung", label = "Schwellung", order = 3),
CheckItemDefinition(id = "entzuendungszeichen_roetung", label = "Rötung", order = 4),
CheckItemDefinition(id = "entzuendungszeichen_funktionsstoerung", label = "Funktionsstörung", order = 5),
CheckItemDefinition(id = "entzuendungszeichen_schmerz", label = "Schmerz", order = 6),
),
),
BlockDefinition(
id = "geruch",
title = "Geruch",
order = 12,
items = listOf(
CheckItemDefinition(id = "geruch_ja", label = "Ja", order = 1),
CheckItemDefinition(id = "geruch_nein", label = "Nein", order = 2),
CheckItemDefinition(id = "geruch_gering", label = "Gering", order = 3),
CheckItemDefinition(id = "geruch_deutlich_wahrnehmbar", label = "Deutlich wahrnehmbar", order = 4),
),
),
BlockDefinition(
id = "schmerz",
title = "Schmerz",
order = 13,
items = listOf(
CheckItemDefinition(id = "schmerz_ja", label = "Ja", order = 1),
CheckItemDefinition(id = "schmerz_nein", label = "Nein", order = 2),
CheckItemDefinition(id = "schmerz_immer", label = "Immer", order = 3),
CheckItemDefinition(id = "schmerz_zeitweise", label = "Zeitweise", order = 4),
CheckItemDefinition(id = "schmerz_bei_verbandwechsel", label = "Bei Verbandwechsel", order = 5),
CheckItemDefinition(id = "schmerz_vorwiegend_nachts", label = "Vorwiegend nachts", order = 6),
CheckItemDefinition(id = "schmerz_wunde", label = "Wunde", order = 7),
CheckItemDefinition(id = "schmerz_umgebung", label = "Umgebung", order = 8),
),
),
BlockDefinition(
id = "juckreiz",
title = "Juckreiz",
order = 14,
items = listOf(
CheckItemDefinition(id = "juckreiz_ja", label = "Ja", order = 1),
CheckItemDefinition(id = "juckreiz_nein", label = "Nein", order = 2),
CheckItemDefinition(id = "juckreiz_wunde", label = "Wunde", order = 3),
CheckItemDefinition(id = "juckreiz_umgebung", label = "Umgebung", order = 4),
CheckItemDefinition(id = "juckreiz_immer_oft", label = "Immer / oft", order = 5),
CheckItemDefinition(id = "juckreiz_zeitweise", label = "Zeitweise", order = 6),
),
),
BlockDefinition(
id = "vitalwerte",
title = "Vitalwerte",
order = 15,
items = listOf(
CheckItemDefinition(id = "vitalwerte_arteria_tibialis_posterior_links", label = "Arteria tibialis posterior links", order = 1),
CheckItemDefinition(id = "vitalwerte_arteria_dorsalis_pedis_links", label = "Arteria dorsalis pedis links", order = 2),
CheckItemDefinition(id = "vitalwerte_arteria_tibialis_posterior_rechts", label = "Arteria tibialis posterior rechts", order = 3),
CheckItemDefinition(id = "vitalwerte_arteria_dorsalis_pedis_rechts", label = "Arteria dorsalis pedis rechts", order = 4),
CheckItemDefinition(id = "vitalwerte_per_doppler_taschendoppler", label = "per Doppler (Taschendoppler)", order = 5),
CheckItemDefinition(id = "vitalwerte_per_palpation", label = "per Palpation", order = 6),
CheckItemDefinition(id = "vitalwerte_darstellbar", label = "Darstellbar", order = 7),
CheckItemDefinition(id = "vitalwerte_abgeschwaecht_veraendert", label = "Abgeschwächt/verändert", order = 8),
CheckItemDefinition(id = "vitalwerte_nicht_darstellbar", label = "Nicht darstellbar", order = 9),
CheckItemDefinition(
id = "vitalwerte_rr",
label = "RR (systolisch/diastolisch)",
order = 10,
inputSpec = InputSpec(
pdfTemplate = "RR {0}/{1} mmHg",
fields = listOf(
InputFieldDef.Number(label = "RR systolisch", maxLength = 3),
InputFieldDef.Number(label = "RR diastolisch", maxLength = 3),
),
),
),
CheckItemDefinition(
id = "vitalwerte_puls",
label = "Puls (BPM)",
order = 11,
inputSpec = InputSpec(
pdfTemplate = "Puls {0} BPM",
fields = listOf(InputFieldDef.Number(label = "Puls (BPM)", maxLength = 3)),
),
),
CheckItemDefinition(
id = "vitalwerte_blutzucker",
label = "Blutzucker (mg/dl)",
order = 12,
inputSpec = InputSpec(
pdfTemplate = "Blutzucker {0} mg/dl",
fields = listOf(InputFieldDef.Number(label = "Blutzucker (mg/dl)", maxLength = 3)),
),
),
CheckItemDefinition(
id = "vitalwerte_spo2",
label = "SpO2 (%)",
order = 13,
inputSpec = InputSpec(
pdfTemplate = "SpO2 {0}%",
fields = listOf(InputFieldDef.Number(label = "SpO2 (%)", maxLength = 3)),
),
),
CheckItemDefinition(
id = "vitalwerte_temperatur",
label = "Temperatur (°C)",
order = 14,
inputSpec = InputSpec(
pdfTemplate = "Temp. {0} °C",
fields = listOf(InputFieldDef.Number(label = "Temperatur (°C)", maxLength = 4, allowDecimal = true)),
),
),
),
),
BlockDefinition(
id = "wundheilung_beeinflussende_faktoren",
title = "Wundheilung beeinflussende Faktoren",
order = 16,
items = listOf(
CheckItemDefinition(id = "wbf_infektion_keimbesiedelung", label = "Infektion/Keimbesiedelung", order = 1),
CheckItemDefinition(id = "wbf_alter", label = "Alter", order = 2),
CheckItemDefinition(id = "wbf_diabetes_mellitus", label = "Diabetes mellitus", order = 3),
CheckItemDefinition(id = "wbf_geschwaechtes_immunsystem", label = "Geschwächtes Immunsystem", order = 4),
CheckItemDefinition(id = "wbf_ernaehrungszustand_dehydration", label = "Ernährungszustand/Dehydration", order = 5),
CheckItemDefinition(id = "wbf_medikamente", label = "Medikamente", order = 6),
CheckItemDefinition(id = "wbf_rauchen", label = "Rauchen", order = 7),
CheckItemDefinition(id = "wbf_adipositas", label = "Adipositas", order = 8),
CheckItemDefinition(id = "wbf_psych_stress_schlafmangel", label = "Psych. Stress/Schlafmangel", order = 9),
CheckItemDefinition(id = "wbf_mobilitaet", label = "Mobilität", order = 10),
CheckItemDefinition(id = "wbf_lage_der_wunde", label = "Lage der Wunde", order = 11),
),
),
BlockDefinition(
id = "auswirkungen_alltag",
title = "Auswirkungen auf das alltägliche Leben",
order = 17,
items = listOf(
CheckItemDefinition(id = "auswirkungen_alltag_nein", label = "Nein", order = 1),
CheckItemDefinition(id = "auswirkungen_alltag_schmerz", label = "Schmerz", order = 2),
CheckItemDefinition(id = "auswirkungen_alltag_eingeschraenkte_mobilitaet", label = "Eingeschränkte Mobilität", order = 3),
CheckItemDefinition(id = "auswirkungen_alltag_schlafstoerung", label = "Schlafstörung", order = 4),
CheckItemDefinition(id = "auswirkungen_alltag_koerperbildstoerung", label = "Körperbildstörung", order = 5),
CheckItemDefinition(id = "auswirkungen_alltag_geruch", label = "Geruch", order = 6),
CheckItemDefinition(id = "auswirkungen_alltag_sozialer_rueckzug", label = "Sozialer Rückzug", order = 7),
CheckItemDefinition(id = "auswirkungen_alltag_zeitaufwand", label = "Zeitaufwand", order = 8),
CheckItemDefinition(id = "auswirkungen_alltag_einschraenkung_kleidung_hygiene", label = "Einschränkung Kleidung/Hygiene", order = 9),
CheckItemDefinition(id = "auswirkungen_alltag_abhaengigkeit_von_dritten", label = "Abhängigkeit von Dritten (Pflegedienst/Ärzte/Angehörige)", order = 10),
CheckItemDefinition(id = "auswirkungen_alltag_angst", label = "Angst", order = 11),
CheckItemDefinition(id = "auswirkungen_alltag_hobbyverlust", label = "Hobbyverlust", order = 12),
CheckItemDefinition(id = "auswirkungen_alltag_berufliche_ausfaelle", label = "Berufliche Ausfälle", order = 13),
CheckItemDefinition(id = "auswirkungen_alltag_finanzielle_zusatzbelastung", label = "Finanzielle Zusatzbelastung", order = 14),
),
),
BlockDefinition(
id = "bekannte_allergie",
title = "Bekannte Allergie",
order = 18,
items = listOf(
CheckItemDefinition(id = "bekannte_allergie_ja", label = "Ja", order = 1),
CheckItemDefinition(id = "bekannte_allergie_nein", label = "Nein", order = 2),
),
),
BlockDefinition(
id = "beratung_erfolgte",
title = "Beratung erfolgte",
order = 19,
items = listOf(
CheckItemDefinition(id = "beratung_verbandwechsel", label = "Verbandwechsel", order = 1),
CheckItemDefinition(id = "beratung_hygiene", label = "Hygiene", order = 2),
CheckItemDefinition(id = "beratung_ernaehrung", label = "Ernährung", order = 3),
CheckItemDefinition(id = "beratung_lagerung_druckentlastung", label = "Lagerung / Druckentlastung", order = 4),
CheckItemDefinition(id = "beratung_kleidung_schuhe", label = "Kleidung / Schuhe", order = 5),
CheckItemDefinition(id = "beratung_hautpflege", label = "Hautpflege", order = 6),
),
),
BlockDefinition(
id = "weitere_ueberlegung",
title = "Weitere Überlegung",
order = 20,
items = listOf(
CheckItemDefinition(id = "weitere_ueberlegung_hausarztvisite", label = "Hausarztvisite", order = 1),
CheckItemDefinition(id = "weitere_ueberlegung_kompressionstherapie", label = "Kompressionstherapie", order = 2),
CheckItemDefinition(id = "weitere_ueberlegung_wundabstrich", label = "Wundabstrich", order = 3),
CheckItemDefinition(id = "weitere_ueberlegung_angepasste_schmerztherapie", label = "Angepasste Schmerztherapie", order = 4),
CheckItemDefinition(id = "weitere_ueberlegung_anpassung_wundversorgung", label = "Anpassung der Wundversorgung", order = 5),
CheckItemDefinition(id = "weitere_ueberlegung_facharztliches_konsil", label = "Fachärztliches Konsil", order = 6),
CheckItemDefinition(id = "weitere_ueberlegung_ernaehrungsberatung", label = "Ernährungsberatung", order = 7),
CheckItemDefinition(id = "weitere_ueberlegung_pflegeberatung", label = "Pflegeberatung", order = 8),
CheckItemDefinition(id = "weitere_ueberlegung_hilfsmittel", label = "Hilfsmittel", order = 9),
CheckItemDefinition(id = "weitere_ueberlegung_bewegung_lagerungsplan", label = "Bewegung / Lagerungsplan", order = 10),
CheckItemDefinition(id = "weitere_ueberlegung_verbandwechsel_pflegedienst", label = "Verbandwechsel durch Pflegefachkräfte / Pflegedienst", order = 11),
CheckItemDefinition(
id = "weitere_ueberlegung_aerztliche_abklaerung_dringend",
label = "Ärztliche Abklärung zeitnah/dringend erforderlich / erbeten, Gefahr von bleibenden körperlichen Schäden / lebensbedrohlichen Prozessen",
order = 12,
),
),
),
BlockDefinition(
id = "sonstiges",
title = "Sonstiges",
order = 21,
items = emptyList(),
),
BlockDefinition(
id = "empfohlene_wundversorgung",
title = "Empfohlene Wundversorgung",
order = 22,
items = listOf(
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_reinigen_mit", label = "Reinigen mit:", order = 1, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_sterile_kompresse", label = "Sterile Kompresse", order = 2),
CheckItemDefinition(id = "empfohlene_wundversorgung_nacl", label = "NaCl 0,9%", order = 3),
CheckItemDefinition(id = "empfohlene_wundversorgung_octenisept", label = "Octenisept", order = 4),
// Granudacyn: einfache Checkbox. Der Freitext ist ein eigener, separat
// anzuhakender Punkt direkt darunter (Bezeichnung "Freitext" wird in PDF/JPG
// und, sobald angehakt, auch in der App nicht angezeigt).
CheckItemDefinition(id = "empfohlene_wundversorgung_granudacyn", label = "Granudacyn", order = 5),
CheckItemDefinition(id = "empfohlene_wundversorgung_granudacyn_freitext", label = FREE_TEXT_LABEL, order = 6, isFreeText = true),
CheckItemDefinition(
id = "empfohlene_wundversorgung_getraenkte_kompresse",
label = "Getränkte Kompresse",
order = 7,
inputSpec = InputSpec(
pdfTemplate = "Getränkte Kompresse für ca. {0} Min. auflegen",
fields = listOf(InputFieldDef.Choice(label = "Minuten", options = MINUTE_1_TO_15_OPTIONS)),
),
),
CheckItemDefinition(id = "empfohlene_wundversorgung_tupfend", label = "Tupfend", order = 8),
CheckItemDefinition(id = "empfohlene_wundversorgung_wischend", label = "Wischend", order = 9),
CheckItemDefinition(id = "empfohlene_wundversorgung_nachspuelen_nacl", label = "Nachspülen mit NaCl 0,9%", order = 10),
CheckItemDefinition(id = "empfohlene_wundversorgung_umgebung_trocken_tupfen", label = "Umgebung trocken tupfen", order = 11),
CheckItemDefinition(id = "empfohlene_wundversorgung_primaer_abdecken_austamponieren", label = "Primär Wunde abdecken/austamponieren", order = 12),
CheckItemDefinition(id = "empfohlene_wundversorgung_ueber_wundrand_hinaus", label = "Über Wundrand hinaus", order = 13),
CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit", label = "Nicht über Wundrand mit", order = 14),
CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit_freitext", label = FREE_TEXT_LABEL, order = 15, isFreeText = true),
// Wundrandschutz: EINE Checkbox mit eigenem Freitextfeld (kein Ja/Nein mehr).
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz", label = "Wundrandschutz", order = 16, isFreeText = true),
// "Wie oft pro Woche": eigene Checkbox; die Auswahl (1-7x) ist erst nach dem
// Anhaken aktiv (InputSpec.requiresOwnCheckbox).
CheckItemDefinition(
id = "empfohlene_wundversorgung_wundrandschutz_tage_pro_woche",
label = "Wie oft pro Woche",
order = 17,
inputSpec = InputSpec(
pdfTemplate = "{0}x pro Woche",
fields = listOf(InputFieldDef.Choice(label = "Tage pro Woche", options = WEEKDAY_COUNT_OPTIONS)),
requiresOwnCheckbox = true,
),
),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_sekundaere_wundauflage", label = "Sekundäre Wundauflage:", order = 18, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_sekundaer_sterile_kompresse", label = "Sterile Kompresse", order = 19),
CheckItemDefinition(id = "empfohlene_wundversorgung_saugkompresse", label = "Saugkompresse", order = 20),
CheckItemDefinition(id = "empfohlene_wundversorgung_sonstige_wundauflage", label = "Sonstiges (Wundauflage)", order = 21, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_fixieren_mit", label = "Fixieren mit:", order = 22, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_mullbinde", label = "Mullbinde", order = 23),
CheckItemDefinition(id = "empfohlene_wundversorgung_klebevlies", label = "Klebevlies", order = 24),
CheckItemDefinition(id = "empfohlene_wundversorgung_folienverband", label = "Folienverband", order = 25),
CheckItemDefinition(id = "empfohlene_wundversorgung_schlauchverband", label = "Schlauchverband", order = 26),
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde", label = "Kohäsive Fixierbinde", order = 27),
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde_freitext", label = FREE_TEXT_LABEL, order = 28, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system", label = "Kompressionsverband / System", order = 29),
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system_freitext", label = FREE_TEXT_LABEL, order = 30, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_wechselintervall", label = "Wechselintervall", order = 31, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_bedarf", label = "+ bei Bedarf", order = 32, isStaticHint = true),
CheckItemDefinition(
id = "empfohlene_wundversorgung_hinweis_abklaerung",
label = "Generell: bei akuter Verschlechterung sofort ärztliche Abklärung",
order = 33,
isStaticHint = true,
),
),
),
BlockDefinition(
id = "ziel",
title = "Ziel",
order = 23,
items = listOf(
CheckItemDefinition(id = "ziel_sauberes_wundmilieu", label = "Sauberes Wundmilieu", order = 1),
CheckItemDefinition(id = "ziel_infektionsprophylaxe", label = "Infektionsprophylaxe", order = 2),
CheckItemDefinition(id = "ziel_abpolsterung", label = "Abpolsterung", order = 3),
CheckItemDefinition(id = "ziel_schmerzreduktion", label = "Schmerzreduktion", order = 4),
CheckItemDefinition(id = "ziel_angepasstes_exsudatmanagement", label = "Angepasstes Exsudatmanagement", order = 5),
CheckItemDefinition(id = "ziel_stabile_wundverhaeltnisse", label = "Stabile Wundverhältnisse", order = 6),
CheckItemDefinition(id = "ziel_abheilung", label = "Abheilung", order = 7),
CheckItemDefinition(id = "ziel_geruchsreduzierung", label = "Geruchsreduzierung", order = 8),
CheckItemDefinition(id = "ziel_einfache_durchfuehrung", label = "Einfache Durchführung", order = 9),
CheckItemDefinition(id = "ziel_aufbruch_vorhandenes_material", label = "Aufbruch des vorhandenen Materials", order = 10),
CheckItemDefinition(id = "ziel_selbstversorgung_foerdern", label = "Selbstversorgung der Wunde durch Patient/Angehörige unterstützen/fördern", order = 11),
),
),
BlockDefinition(
id = "kopie_nachrichtlich_an",
title = "Kopie nachrichtlich an",
order = 24,
items = listOf(
CheckItemDefinition(id = "kopie_nachrichtlich_an_arzt", label = "Ärztin / Arzt", order = 1, isFreeText = true),
CheckItemDefinition(id = "kopie_nachrichtlich_an_pflege", label = "Pflege", order = 2, isFreeText = true),
CheckItemDefinition(id = "kopie_nachrichtlich_an_patient", label = "Patient", order = 3, isFreeText = true),
CheckItemDefinition(id = "kopie_nachrichtlich_an_sonstige", label = "Sonstige", order = 4, isFreeText = true),
),
),
BlockDefinition(
id = "naechster_termin",
title = "Nächster Termin",
order = 25,
items = emptyList(),
),
) )
fun findBlock(blockDefinitionId: String): BlockDefinition? = fun findBlock(blockDefinitionId: String): BlockDefinition? =
@@ -74,16 +607,45 @@ data class BlockDefinition(
val items: List<CheckItemDefinition>, val items: List<CheckItemDefinition>,
) { ) {
/** /**
* Stabile, aus der Block-ID abgeleitete ID für die automatisch angehängte Freitext-Option * Stabile, aus der Block-ID abgeleitete ID für die automatisch angehängte, generische
* dieses Blocks (siehe DocumentationCatalog.FREE_TEXT_LABEL). Kein Bestandteil von [items], * Freitext-Option dieses Blocks (siehe DocumentationCatalog.FREE_TEXT_LABEL). Kein
* da die Freitext-Option kein fachlicher Katalog-Eintrag ist, sondern strukturell zu jedem * Bestandteil von [items], da diese generische Freitext-Option kein fachlicher
* Block gehört. * Katalog-Eintrag ist, sondern strukturell zu JEDEM Block gehört - unabhängig davon, ob
* der Block selbst bereits eigene `isFreeText`-Einträge in [items] definiert.
*/ */
val freeTextDefinitionId: String get() = "${id}_freetext" val freeTextDefinitionId: String get() = "${id}_freetext"
} }
/**
* Ein einzelner Katalog-Eintrag innerhalb eines Blocks.
*
* Normalfall: reine Checkbox ([isFreeText] = false, [isStaticHint] = false, [inputSpec] = null).
*
* [isFreeText] = true: eine vom Katalog selbst definierte, fachlich eigenständig benannte
* Freitext-Checkbox (z. B. "Erscheinung", "Wechselintervall", oder ein Empfänger bei "Kopie
* nachrichtlich an") - technisch identisch zur automatisch an JEDEN Block angehängten
* generischen Freitext-Option (s. DocumentationRepository.insertBlocksFromCatalog), nur mit
* eigenem Label statt "Freitext". Ein Block kann beliebig viele solcher Einträge definieren.
*
* [isStaticHint] = true: reiner, nicht editierbarer Hinweistext (z. B. "+ bei Bedarf" oder eine
* optische Zwischenüberschrift wie "Reinigen mit:") - [label] ist hier direkt der anzuzeigende
* Text selbst, keine Checkbox-Beschriftung.
*
* [inputSpec] != null: dieser Eintrag besitzt statt eines einfachen Häkchens ein oder mehrere
* strukturierte Eingabefelder (Zahl oder Auswahlliste, z. B. "Von [ ] bis [ ] Uhr" oder
* "Länge/Breite/Tiefe") - s. [de.medapp.wunddoku.domain.model.InputSpec].
*
* [isFreeText], [isStaticHint] und [inputSpec] schließen sich fachlich gegenseitig aus (ein
* Eintrag ist entweder normale Checkbox, Freitext-Checkbox, Hinweistext oder strukturierte
* Eingabe) - es gibt aber bewusst keine Laufzeitprüfung dafür, um die Katalogdefinition einfach
* zu halten; mehrere davon gleichzeitig zu setzen ergibt schlicht keinen sinnvollen
* Katalogeintrag.
*/
data class CheckItemDefinition( data class CheckItemDefinition(
val id: String, val id: String,
val label: String, val label: String,
val order: Int, val order: Int,
val isFreeText: Boolean = false,
val isStaticHint: Boolean = false,
val inputSpec: InputSpec? = null,
) )
@@ -0,0 +1,59 @@
package de.medapp.wunddoku.domain.error
import kotlinx.coroutines.CancellationException
/**
* Grobe Einordnung eines Fehlers für die Anzeige und Weiterbehandlung.
*
* Bewusst rein in Kotlin/JVM gehalten (keine Android-Klassen), damit die Einordnung per
* einfachem JVM-Unit-Test prüfbar ist. Android-spezifische Typen (z. B. `SQLiteFullException`)
* werden deshalb über Klassenname bzw. Meldungstext erkannt.
*/
enum class FailureCategory {
/** Arbeitsspeicher erschöpft ([OutOfMemoryError]). */
OUT_OF_MEMORY,
/** Geräte-/Datenbankspeicher voll (ENOSPC, `SQLiteFullException`, "disk is full"). */
OUT_OF_STORAGE,
/** Alles andere. */
OTHER,
;
companion object {
private const val SQLITE_FULL_EXCEPTION = "android.database.sqlite.SQLiteFullException"
private val STORAGE_FULL_MARKERS = listOf(
"no space left on device",
"enospc",
"database or disk is full",
"disk full",
"sqlite_full",
)
/** Durchsucht [throwable] samt gesamter Ursachenkette. */
fun of(throwable: Throwable): FailureCategory {
val seen = HashSet<Throwable>()
var current: Throwable? = throwable
while (current != null && seen.add(current)) {
if (current is OutOfMemoryError) return OUT_OF_MEMORY
if (current.javaClass.name == SQLITE_FULL_EXCEPTION) return OUT_OF_STORAGE
val message = current.message?.lowercase()
if (message != null && STORAGE_FULL_MARKERS.any { message.contains(it) }) return OUT_OF_STORAGE
current = current.cause
}
return OTHER
}
}
}
/**
* Wie [runCatching], reicht aber eine Coroutine-Abbruch-Exception immer weiter - ein
* abgebrochener Vorgang darf nicht als "Fehler" verschluckt werden.
*/
inline fun <T> runCatchingCancellable(block: () -> T): Result<T> = try {
Result.success(block())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
@@ -9,12 +9,22 @@ package de.medapp.wunddoku.domain.model
* historische Dokumentationen unabhängig von späteren Katalog-Änderungen unverändert korrekt * historische Dokumentationen unabhängig von späteren Katalog-Änderungen unverändert korrekt
* dargestellt werden. * dargestellt werden.
* *
* [isFreeText]/[freeTextValue]: Der Freitext eines Blocks wird NICHT mehr als separates Feld am * [isFreeText]/[freeTextValue]: Der Freitext eines Blocks wird NICHT als separates Feld am
* Block geführt, sondern als eine weitere, besondere Checkbox-Option behandelt - jeder Block * Block geführt, sondern als eine weitere, besondere Checkbox-Option behandelt. Jeder Block
* bekommt automatisch genau ein CheckItem mit `isFreeText = true`. Ist [isFreeText] `false`, * bekommt automatisch genau ein CheckItem mit `isFreeText = true`; zusätzlich kann der Katalog
* ist [freeTextValue] stets leer und ohne Bedeutung. Diese Struktur erlaubt es dem späteren * selbst beliebig viele weitere `isFreeText = true`-Optionen mit eigenem, fachlichem Label
* PDF-Export, normale Optionen, die Freitext-Option und deren Inhalt eindeutig zu * definieren (z. B. "Wundgrund", "Erscheinung", oder die vier Empfänger bei "Kopie
* unterscheiden, ohne eine Sonderlogik außerhalb des bestehenden Checkbox-Modells zu benötigen. * nachrichtlich an") - technisch identisch, nur mit eigenem Label statt des generischen
* "Freitext". Ist [isFreeText] `false`, ist [freeTextValue] stets leer und ohne Bedeutung.
*
* [isStaticHint]: reiner, nicht editierbarer Hinweistext (z. B. "+ bei Bedarf") - keine
* Checkbox in der UI, [checked] ist bei solchen Zeilen technisch immer `true` (s. Entity-
* Kommentar), zählt aber NICHT als Bearbeitung (s. [de.medapp.wunddoku.domain.usecase.BlockEditedPolicy]).
*
* [inputSpec]: nicht-leer, wenn dieser Eintrag statt eines einfachen Häkchens strukturierte
* Eingabefelder besitzt (Zahl/Auswahlliste) - roher, codierter String, s. [InputSpecCodec.decode].
* Die eingegebenen Werte liegen weiterhin in [freeTextValue] (mehrere Felder durch ein
* Trennzeichen kombiniert, s. [InputSpec.decodeValues]/[InputSpec.encodeValues]).
*/ */
data class CheckItem( data class CheckItem(
val checkItemStateId: String, val checkItemStateId: String,
@@ -24,4 +34,6 @@ data class CheckItem(
val checked: Boolean, val checked: Boolean,
val isFreeText: Boolean = false, val isFreeText: Boolean = false,
val freeTextValue: String = "", val freeTextValue: String = "",
val isStaticHint: Boolean = false,
val inputSpec: String = "",
) )
@@ -0,0 +1,173 @@
package de.medapp.wunddoku.domain.model
/**
* Ein einzelnes Eingabefeld innerhalb eines strukturierten Katalog-Eintrags (s. [InputSpec]).
*
* Bewusst nur zwei Feldarten - genau das, was die Kundenvorgaben bisher benötigen:
* - [Number]: Zahleneingabe mit maximaler Zeichenzahl (z. B. Länge/Breite/Tiefe, Blutdruck)
* - [Choice]: Auswahl aus einer festen Liste (z. B. Uhrzeit-Position 1-12, Tiefe in 0,5-cm-Schritten)
*/
sealed interface InputFieldDef {
val label: String
/**
* @param maxLength maximale Zeichenzahl (bei [allowDecimal] inklusive des Dezimaltrennzeichens)
* @param allowDecimal erlaubt genau ein Dezimalkomma (Punkt wird beim Tippen zu Komma)
*/
data class Number(
override val label: String,
val maxLength: Int,
val allowDecimal: Boolean = false,
) : InputFieldDef
data class Choice(
override val label: String,
val options: List<String>,
) : InputFieldDef
}
/**
* Beschreibt einen Katalog-Eintrag, der statt eines einfachen Häkchens (oder Freitexts) ein oder
* mehrere strukturierte Eingabefelder besitzt - z. B. "Von [3] bis [5] Uhr" oder
* "Länge [ ] Breite [ ] Tiefe [ ]".
*
* Der Eintrag bleibt trotzdem eine ganz normale Checkbox-Zeile (s. CheckItem.checked): Sobald in
* einem Feld etwas eingegeben/gewählt wird, wird das Häkchen automatisch gesetzt (s. UI). Dadurch
* greifen BlockEditedPolicy und die "nur angehakte Elemente"-Regel im PDF-Export unverändert -
* es gibt keine zweite "bearbeitet"-Logik für diesen Eintragstyp.
*
* [pdfTemplate] bestimmt, wie der Eintrag im PDF ausgegeben wird; `{0}`, `{1}` ... werden durch die
* Werte der Felder (in Reihenfolge von [fields]) ersetzt, z. B. `"Von {0} bis {1} Uhr"`.
*
* Die Beschreibung wird beim Anlegen einer Dokumentation zusammen mit dem Eintrag gespeichert
* (Snapshot, s. [InputSpecCodec]) - spätere Katalogänderungen (andere Auswahlwerte, andere
* Textvorlage) verändern bestehende Dokumentationen dadurch nicht rückwirkend.
*/
data class InputSpec(
val pdfTemplate: String,
val fields: List<InputFieldDef>,
/**
* `true`: der Eintrag hat zusätzlich eine EIGENE Checkbox mit dem Label des Eintrags (z. B.
* "Wie oft pro Woche"). Die Eingabefelder sind dann nur aktiv, solange diese Checkbox
* angehakt ist; das Häkchen wird NICHT mehr automatisch aus den Feldwerten abgeleitet,
* sondern ausschließlich vom Nutzer gesetzt. Standard `false` = bisheriges Verhalten
* (Felder immer aktiv, Häkchen automatisch bei mindestens einem Wert).
*/
val requiresOwnCheckbox: Boolean = false,
) {
/** Zerlegt den gespeicherten Gesamtwert (s. [encodeValues]) in genau [fields].size Einzelwerte. */
fun decodeValues(stored: String): List<String> {
val parts = if (stored.isEmpty()) emptyList() else stored.split(VALUE_SEPARATOR)
return List(fields.size) { index -> parts.getOrElse(index) { "" } }
}
/** Fasst die Einzelwerte der Felder zu dem einen String zusammen, der in der Datenbank steht. */
fun encodeValues(values: List<String>): String = values.joinToString(VALUE_SEPARATOR.toString())
/** true, sobald mindestens ein Feld einen Wert enthält. */
fun hasAnyValue(stored: String): Boolean = decodeValues(stored).any { it.isNotBlank() }
/**
* Text für den PDF-Export: [pdfTemplate] mit den eingesetzten Feldwerten. Ein noch leeres
* Feld erscheint als "–", damit erkennbar bleibt, dass dort bewusst nichts gewählt wurde.
*/
fun formatForPdf(stored: String): String {
var result = pdfTemplate
decodeValues(stored).forEachIndexed { index, value ->
result = result.replace("{$index}", value.ifBlank { EMPTY_VALUE_PLACEHOLDER })
}
return result
}
companion object {
/** Trennzeichen zwischen den Werten mehrerer Felder (Steuerzeichen, kommt in Zahlen/Auswahlwerten nie vor). */
const val VALUE_SEPARATOR: Char = '\u001F'
private const val EMPTY_VALUE_PLACEHOLDER = "–"
}
}
/**
* (De-)Serialisierung von [InputSpec] in einen einzelnen String für die Datenbankspalte
* `check_item_states.inputSpec`. Bewusst ein schlichtes eigenes Format mit Steuerzeichen als
* Trennern statt JSON: reines Kotlin ohne Android-Klassen (jederzeit in normalen JUnit-Tests
* prüfbar) und ohne zusätzliche Abhängigkeit. Die verwendeten Trennzeichen kommen in Labels,
* Textvorlagen und Auswahlwerten nicht vor.
*
* Aufbau: `pdfTemplate ␞ feld1 ␞ feld2 ... [␞ B]`, jedes Feld `N␟label␟maxLength␟decimal` bzw.
* `C␟label␟option␝option␝...`.
*/
object InputSpecCodec {
private const val RECORD_SEPARATOR = '\u001E'
private const val UNIT_SEPARATOR = '\u001F'
private const val GROUP_SEPARATOR = '\u001D'
/** Optionaler Abschluss-Datensatz "eigene Checkbox" (s. [InputSpec.requiresOwnCheckbox]).
* Ältere Decoder ignorieren unbekannte Datensätze, bereits gespeicherte Snapshots ohne
* diesen Datensatz werden mit `requiresOwnCheckbox = false` gelesen. */
private const val OWN_CHECKBOX_RECORD = "B"
fun encode(spec: InputSpec): String {
val records = mutableListOf(spec.pdfTemplate)
for (field in spec.fields) {
records += when (field) {
is InputFieldDef.Number -> listOf(
"N",
field.label,
field.maxLength.toString(),
if (field.allowDecimal) "1" else "0",
).joinToString(UNIT_SEPARATOR.toString())
is InputFieldDef.Choice -> listOf(
"C",
field.label,
field.options.joinToString(GROUP_SEPARATOR.toString()),
).joinToString(UNIT_SEPARATOR.toString())
}
}
if (spec.requiresOwnCheckbox) records += OWN_CHECKBOX_RECORD
return records.joinToString(RECORD_SEPARATOR.toString())
}
/** Liefert `null` für einen leeren String (Eintrag ohne strukturierte Felder) oder bei nicht lesbaren Daten. */
fun decode(encoded: String): InputSpec? {
if (encoded.isEmpty()) return null
val records = encoded.split(RECORD_SEPARATOR)
if (records.size < 2) return null
val fields = records.drop(1).mapNotNull { record ->
val attributes = record.split(UNIT_SEPARATOR)
when (attributes.firstOrNull()) {
"N" -> {
val maxLength = attributes.getOrNull(2)?.toIntOrNull()
if (attributes.size >= 4 && maxLength != null) {
InputFieldDef.Number(
label = attributes[1],
maxLength = maxLength,
allowDecimal = attributes[3] == "1",
)
} else {
null
}
}
"C" -> if (attributes.size >= 3) {
InputFieldDef.Choice(
label = attributes[1],
options = attributes[2].split(GROUP_SEPARATOR),
)
} else {
null
}
else -> null
}
}
if (fields.isEmpty()) return null
return InputSpec(
pdfTemplate = records.first(),
fields = fields,
requiresOwnCheckbox = records.drop(1).any { it == OWN_CHECKBOX_RECORD },
)
}
}
@@ -5,15 +5,18 @@ import java.time.Instant
/** /**
* Eine digitale Unterschrift zu einer Wunddokumentation. * Eine digitale Unterschrift zu einer Wunddokumentation.
* *
* Eigene Entität statt eines einzelnen `signature`-Felds an [Documentation], damit später * Eigene Entität statt eines einzelnen `signature`-Felds an [Documentation], damit beliebig
* mehrere Unterschriften unterschiedlicher Wundversorger zu einer Dokumentation gehören können * viele Unterschriften unterschiedlicher Wundversorger zu einer Dokumentation gehören können
* (1:n-Beziehung), ohne Migration. [fileName] verweist auf eine PNG-Datei im internen * (1:n-Beziehung, MEDIA-004). [fileName] verweist auf eine PNG-Datei im internen App-Speicher
* App-Speicher (kein BLOB in SQLite). Siehe Architektur-Dokument Abschnitt 11. * (kein BLOB in SQLite). [signerName] ist ein historischer Snapshot: spätere Änderungen an
* eventuellen Wundversorger-Stammdaten dürfen bereits gespeicherte Signaturen nicht rückwirkend
* verändern. [orderIndex] bestimmt die stabile Anzeige-/Export-Reihenfolge mehrerer Signaturen.
*/ */
data class Signature( data class Signature(
val id: String, val id: String,
val documentationId: String, val documentationId: String,
val signerName: String?, val signerName: String,
val fileName: String, val fileName: String,
val orderIndex: Int,
val createdAt: Instant, val createdAt: Instant,
) )
@@ -0,0 +1,16 @@
package de.medapp.wunddoku.domain.model
import java.time.Instant
/**
* Ein zuletzt verwendeter Wundversorger samt seiner aktuellsten Signatur - reines Komfort-Cache
* für Namensvorschläge und Vorbelegung im Signatur-Dialog, KEIN Benutzer-/Account-Objekt (s.
* [de.medapp.wunddoku.data.entity.WoundCarerEntity]). Hat keinerlei Einfluss auf bereits
* gespeicherte, historische [Signature]-Snapshots einzelner Dokumentationen.
*/
data class WoundCarer(
val id: String,
val name: String,
val signatureFileName: String,
val updatedAt: Instant,
)
@@ -23,8 +23,15 @@ object BlockEditedPolicy {
* nötig: Das Freitextfeld ist in der UI deaktiviert, solange seine Checkbox nicht angehakt * nötig: Das Freitextfeld ist in der UI deaktiviert, solange seine Checkbox nicht angehakt
* ist, ein Ankreuzen zählt also bereits als bewusste Bearbeitung, unabhängig davon, ob schon * ist, ein Ankreuzen zählt also bereits als bewusste Bearbeitung, unabhängig davon, ob schon
* Text eingegeben wurde. * Text eingegeben wurde.
*
* EINZIGE Ausnahme: Hinweistext-Zeilen (CheckItem.isStaticHint = true) werden hierbei
* IGNORIERT, obwohl ihr `checked` technisch immer `true` ist (s. Entity-Kommentar zu
* isStaticHint) - ein reiner, nicht editierbarer Hinweistext ist keine Nutzereingabe und
* darf einen Block nicht allein dadurch als "bearbeitet" erscheinen lassen (z. B.
* "Empfohlene Wundversorgung" enthält zwei feste Hinweiszeilen, soll aber nur dann als
* bearbeitet gelten, wenn tatsächlich eine Empfehlung eingetragen wurde).
*/ */
fun isEdited(block: DocumentationBlock): Boolean { fun isEdited(block: DocumentationBlock): Boolean {
return block.items.any { it.checked } return block.items.any { it.checked && !it.isStaticHint }
} }
} }
@@ -9,6 +9,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
import kotlin.math.roundToInt
/** /**
* Verwaltet die physischen Bilddateien im internen App-Speicher, organisiert pro Dokumentation: * Verwaltet die physischen Bilddateien im internen App-Speicher, organisiert pro Dokumentation:
@@ -86,6 +87,27 @@ interface PhotoStorage {
* wenn eine Datei nachträglich außerhalb der App entfernt wurde). * wenn eine Datei nachträglich außerhalb der App entfernt wurde).
*/ */
suspend fun decodeThumbnail(documentationId: String, fileName: String, maxDimensionPx: Int): Bitmap? suspend fun decodeThumbnail(documentationId: String, fileName: String, maxDimensionPx: Int): Bitmap?
/**
* Decodiert die gespeicherte Datei proportional eingepasst in eine Box von höchstens
* [maxWidthPx] × [maxHeightPx] Pixeln, OHNE Hochskalieren über die native Auflösung hinaus
* (PDF-004: Fotoanhang). Läuft zweistufig:
*
* 1. Grobe, speicherschonende Decodierung über `BitmapFactory.Options.inSampleSize`
* (Zweierpotenz-Downsampling direkt im Decoder) - liefert ein Bitmap, das mindestens so
* groß wie die exakte Zielgröße ist, aber wegen der Zweierpotenz-Beschränkung von
* `inSampleSize` bei Quellbildern nahe der Zielgröße (z. B. nahe dem Import-Limit
* [ImageDownsampler.MAX_STORED_DIMENSION_PX]) oft noch keine ausreichende Reduktion
* bewirkt.
* 2. Exakte Skalierung dieses groben Zwischenergebnisses auf die tatsächliche Zielgröße
* (`Bitmap.createScaledBitmap`). Das Zwischenergebnis aus Schritt 1 wird direkt danach
* freigegeben (`recycle()`) - das ist unproblematisch, da es zu diesem Zeitpunkt noch
* nirgends (insbesondere nicht an `PdfDocument`) weitergereicht wurde. Nur das Ergebnis
* aus Schritt 2 wird von der aufrufenden Stelle tatsächlich in die PDF gezeichnet.
*
* Liefert `null`, wenn die Datei fehlt oder nicht decodiert werden kann (kein Absturz).
*/
suspend fun decodeFitted(documentationId: String, fileName: String, maxWidthPx: Int, maxHeightPx: Int): Bitmap?
} }
class InternalPhotoStorage(private val context: Context) : PhotoStorage { class InternalPhotoStorage(private val context: Context) : PhotoStorage {
@@ -95,13 +117,13 @@ class InternalPhotoStorage(private val context: Context) : PhotoStorage {
} }
private fun documentationDirPath(documentationId: String): File = private fun documentationDirPath(documentationId: String): File =
File(photosRootDir, documentationId) SafePaths.resolveChild(photosRootDir, documentationId)
private fun ensureDocumentationDir(documentationId: String): File = private fun ensureDocumentationDir(documentationId: String): File =
documentationDirPath(documentationId).apply { mkdirs() } documentationDirPath(documentationId).apply { mkdirs() }
override fun resolve(documentationId: String, fileName: String): File = override fun resolve(documentationId: String, fileName: String): File =
File(documentationDirPath(documentationId), fileName) SafePaths.resolveChild(documentationDirPath(documentationId), fileName)
override suspend fun importFrom(documentationId: String, sourceUri: Uri): String = override suspend fun importFrom(documentationId: String, sourceUri: Uri): String =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -150,21 +172,36 @@ class InternalPhotoStorage(private val context: Context) : PhotoStorage {
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL) }.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
} ?: ExifInterface.ORIENTATION_NORMAL } ?: ExifInterface.ORIENTATION_NORMAL
val corrected = ImageDownsampler.applyExifOrientation(decoded, orientation) val corrected = try {
ImageDownsampler.applyExifOrientation(decoded, orientation)
} catch (e: Throwable) {
decoded.recycle()
throw e
}
// 4) Als neue, eigenständige JPEG-Datei im dokumentationsspezifischen Verzeichnis // 4) Als neue, eigenständige JPEG-Datei im dokumentationsspezifischen Verzeichnis
// speichern. Erneutes Kodieren als JPEG verwirft dabei nebenbei die ursprünglichen // speichern. Erneutes Kodieren als JPEG verwirft dabei nebenbei die ursprünglichen
// EXIF-Metadaten (z. B. GPS-Position) des Originalfotos, statt sie 1:1 zu // EXIF-Metadaten (z. B. GPS-Position) des Originalfotos.
// übernehmen - für sensible medizinische Dokumentationsfotos ein willkommener //
// Nebeneffekt, keine bewusste Anforderung dieser Aufgabe. // Fehlerbehandlung: Bitmap.compress() liefert bei einem Schreibfehler (z. B. Speicher
// voll) nur `false` statt zu werfen - das wird hier zu einer Exception, und eine
// halb geschriebene Datei wird in jedem Fehlerfall wieder entfernt, damit nie eine
// defekte Bilddatei im Dokumentationsordner liegen bleibt.
val targetDir = ensureDocumentationDir(documentationId) val targetDir = ensureDocumentationDir(documentationId)
val fileName = "${UUID.randomUUID()}.jpg" val fileName = "${UUID.randomUUID()}.jpg"
File(targetDir, fileName).outputStream().use { out -> val targetFile = File(targetDir, fileName)
try {
val written = targetFile.outputStream().use { out ->
corrected.compress(Bitmap.CompressFormat.JPEG, ImageDownsampler.JPEG_QUALITY, out) corrected.compress(Bitmap.CompressFormat.JPEG, ImageDownsampler.JPEG_QUALITY, out)
} }
if (!written) throw java.io.IOException("Bild konnte nicht gespeichert werden.")
} catch (e: Throwable) {
targetFile.delete()
throw e
} finally {
if (corrected !== decoded) decoded.recycle() if (corrected !== decoded) decoded.recycle()
corrected.recycle() corrected.recycle()
}
fileName fileName
} }
@@ -177,7 +214,13 @@ class InternalPhotoStorage(private val context: Context) : PhotoStorage {
val sourceFile = resolve(sourceDocumentationId, sourceFileName) val sourceFile = resolve(sourceDocumentationId, sourceFileName)
val targetFileName = "${UUID.randomUUID()}.jpg" val targetFileName = "${UUID.randomUUID()}.jpg"
val targetFile = File(ensureDocumentationDir(targetDocumentationId), targetFileName) val targetFile = File(ensureDocumentationDir(targetDocumentationId), targetFileName)
try {
sourceFile.copyTo(targetFile, overwrite = false) sourceFile.copyTo(targetFile, overwrite = false)
} catch (e: Throwable) {
// Keine halb kopierte Datei zurücklassen.
targetFile.delete()
throw e
}
targetFileName targetFileName
} }
@@ -212,4 +255,59 @@ class InternalPhotoStorage(private val context: Context) : PhotoStorage {
) )
}.getOrNull() }.getOrNull()
} }
override suspend fun decodeFitted(
documentationId: String,
fileName: String,
maxWidthPx: Int,
maxHeightPx: Int,
): Bitmap? = withContext(Dispatchers.IO) {
val file = resolve(documentationId, fileName)
if (!file.exists()) return@withContext null
runCatching {
val boundsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, boundsOptions)
val sourceWidth = boundsOptions.outWidth
val sourceHeight = boundsOptions.outHeight
if (sourceWidth <= 0 || sourceHeight <= 0) return@runCatching null
// Zielgröße: proportionale Einpassung in (maxWidthPx x maxHeightPx), Faktor auf
// maximal 1f begrenzt - kein Hochskalieren über die native Auflösung hinaus.
val scale = minOf(
maxWidthPx.toFloat() / sourceWidth,
maxHeightPx.toFloat() / sourceHeight,
1f,
)
val targetWidth = (sourceWidth * scale).roundToInt().coerceAtLeast(1)
val targetHeight = (sourceHeight * scale).roundToInt().coerceAtLeast(1)
// Schritt 1: grobe, speicherschonende Decodierung über inSampleSize - liefert ein
// Bitmap, das mindestens so groß wie (targetWidth, targetHeight) ist.
val inSampleSize = ImageDownsampler.computeInSampleSize(
sourceWidth,
sourceHeight,
maxOf(targetWidth, targetHeight),
)
val decodeOptions = BitmapFactory.Options().apply {
this.inSampleSize = inSampleSize
inPreferredConfig = Bitmap.Config.ARGB_8888
}
val coarseBitmap = BitmapFactory.decodeFile(file.absolutePath, decodeOptions)
?: return@runCatching null
// Schritt 2: exakte Zielgröße. Nur nötig, wenn inSampleSize (Zweierpotenz) nicht
// bereits exakt getroffen hat - insbesondere bei Quellbildern nahe dem
// Import-Limit oft der Fall, da dort inSampleSize = 1 bleibt.
if (coarseBitmap.width == targetWidth && coarseBitmap.height == targetHeight) {
coarseBitmap
} else {
val precise = Bitmap.createScaledBitmap(coarseBitmap, targetWidth, targetHeight, true)
// Das grobe Zwischenergebnis wurde noch nirgends (insbesondere nicht an
// PdfDocument) weitergereicht - Freigabe an dieser Stelle ist unproblematisch.
if (precise !== coarseBitmap) coarseBitmap.recycle()
precise
}
}.getOrNull()
}
} }
@@ -0,0 +1,32 @@
package de.medapp.wunddoku.media
import java.io.File
/**
* Schutz vor Path-Traversal (`../`) beim Auflösen von Datei-/Ordnernamen im privaten App-Speicher.
*
* Datei- und Ordnernamen stammen normalerweise aus der eigenen Datenbank (UUIDs). Als
* Defense-in-Depth wird trotzdem sichergestellt, dass ein aufgelöster Pfad nie aus seinem
* Wurzelordner ausbrechen kann - etwa bei einer manipulierten Datenbank oder einem Programmfehler.
*/
object SafePaths {
/**
* Liefert `File(parent, name)`, wenn [name] ein einzelner, harmloser Pfadbestandteil ist.
*
* @throws IllegalArgumentException bei leerem Namen, Pfadtrennzeichen, "." / ".." oder wenn
* der kanonische Pfad nicht unterhalb von [parent] liegt.
*/
fun resolveChild(parent: File, name: String): File {
require(name.isNotBlank()) { "Name darf nicht leer sein." }
require(!name.contains('/') && !name.contains('\\') && !name.contains('\u0000')) {
"Name darf kein Pfadtrennzeichen enthalten."
}
require(name != "." && name != "..") { "Ungültiger Name." }
val child = File(parent, name)
val parentPath = parent.canonicalFile.path
val childPath = child.canonicalFile.path
require(childPath.startsWith(parentPath + File.separator)) { "Pfad liegt außerhalb des erlaubten Ordners." }
return child
}
}
@@ -0,0 +1,581 @@
package de.medapp.wunddoku.pdf
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Typeface
import android.graphics.pdf.PdfDocument
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import de.medapp.wunddoku.domain.error.FailureCategory
import de.medapp.wunddoku.domain.model.InputSpecCodec
import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.signature.SignatureStorage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/**
* Konkrete [DocumentationPdfExporter]-Implementierung auf Basis von `android.graphics.pdf.PdfDocument`.
*
* Checkboxen werden bewusst NICHT über Unicode-Zeichen (☒/☐) oder sonstige Font-Glyphen
* dargestellt, sondern ausschließlich über direkte Canvas-Vektorprimitive (`drawRect`/
* `drawLine`) gezeichnet - dadurch ist das PDF geräte- und schriftartunabhängig exakt
* reproduzierbar, unabhängig davon, welche Systemschrift auf dem jeweiligen Gerät installiert
* ist bzw. welche Unicode-Symbole sie abdeckt.
*
* PDF-004 (Fotoanhang) - wichtige, geprüfte Speicher-Einschränkung von `PdfDocument`: Jede
* `Page` wird intern über ein `SkPicture` AUFGEZEICHNET (verifiziert anhand des AOSP-
* Quellcodes, `frameworks/base/.../PdfDocument.cpp`: `startPage()` -> `mPicture->beginRecording()`,
* `finishPage()` -> nur `endRecording()`, tatsächliches PDF-Rendering inkl. Bild-Einbettung
* erst in `write()`/`writeTo()` über `canvas->drawPicture(*page->mPicture)`). Das bedeutet: JEDE
* über `Canvas.drawBitmap(...)` gezeichnete Bitmap muss bis zum Abschluss von `writeTo()`
* gültig (nicht recycelt) bleiben - ein `Bitmap.recycle()` direkt nach dem Zeichnen einer
* einzelnen Fotoseite ist NICHT sicher. Deshalb werden alle tatsächlich gezeichneten
* Foto-Bitmaps in [export] gesammelt und erst NACH `writeTo()` (im `finally`-Block)
* freigegeben - siehe [retainedPhotoBitmaps].
*
* Der einzige verbleibende Hebel gegen hohen Speicherverbrauch ist deshalb die Zielauflösung
* pro Foto, nicht der Recycling-Zeitpunkt: Zwischenergebnisse, die selbst nie an
* `PdfDocument`/`Canvas.drawBitmap` übergeben werden (grobe `inSampleSize`-Decodierung vor der
* exakten Zielgrößen-Skalierung, s. [de.medapp.wunddoku.media.PhotoStorage.decodeFitted]),
* dürfen dagegen sofort freigegeben werden.
*
* Speicherort: `filesDir/pdf/<documentationId>/<Dateiname>.pdf` - ausschließlich intern, keine
* MediaStore-Nutzung, kein öffentlicher Speicher.
*/
class AndroidPdfDocumentExporter(
private val context: Context,
private val photoStorage: PhotoStorage,
private val signatureStorage: SignatureStorage,
) : DocumentationPdfExporter {
companion object {
// A4 bei 72pt/Zoll (Standard-PDF-Punkt-Konvention), entspricht 210 x 297 mm.
private const val PAGE_WIDTH = 595
private const val PAGE_HEIGHT = 842
private const val MARGIN = 40f
private const val TITLE_TEXT_SIZE = 18f
private const val SUBTITLE_TEXT_SIZE = 12f
private const val HEADER_INFO_TEXT_SIZE = 11f
private const val BLOCK_TITLE_TEXT_SIZE = 14f
private const val ITEM_TEXT_SIZE = 11f
private const val FREE_TEXT_TEXT_SIZE = 10.5f
private const val FREE_TEXT_INDENT = 20f
private const val RUNNING_HEADER_TEXT_SIZE = 9.5f
private const val RUNNING_HEADER_SPACING_AFTER = 10f
// PDF-003: Signaturbereich. Fachlich handschriftliche Unterschriften, keine
// kryptographische PDF-Signatur (s. Aufgabenstellung Abschnitt 5).
private const val SIGNATURE_LABEL_TEXT_SIZE = 11f
private const val SIGNATURE_NAME_UNDER_LINE_TEXT_SIZE = 11f
private const val SIGNATURE_PLACEHOLDER_TEXT_SIZE = 10.5f
/** Feste Zielbreite des Unterschriftsbilds im PDF (deutlich schmaler als die volle
* Inhaltsbreite - eine Unterschrift muss nicht seitenbreit dargestellt werden). Bei dem
* logischen 1000x400-Signaturformat (Seitenverhältnis 2,5:1, s. Zeichenfläche) ergibt
* das eine Bildhöhe von rund 88pt. */
private const val SIGNATURE_IMAGE_TARGET_WIDTH_PT = 220f
private const val SIGNATURE_LINE_WIDTH_PT = 220f
private const val SIGNATURE_SPACING_AFTER_LABEL = 6f
private const val SIGNATURE_SPACING_AFTER_IMAGE = 4f
private const val SIGNATURE_SPACING_AFTER_LINE = 2f
private const val SIGNATURE_SPACING_AFTER_BLOCK = 16f
private const val SIGNATURE_SECTION_TITLE_SPACING_AFTER = 8f
// Änderungswunsch: kein Checkbox-Symbol mehr im PDF - nur noch eine schlichte Einrückung
// der (bereits ausschließlich angehakten) Optionen unter dem Blocktitel.
private const val ITEM_INDENT = 10f
/**
* Zielauflösung für den Fotoanhang, bewusst als Kompromiss zwischen Bildqualität und
* Speicherverbrauch gewählt (freigegeben nach expliziter Prüfung der `PdfDocument`-
* Speichersemantik, s. Klassendokumentation): untere Grenze des ursprünglich
* vorgeschlagenen 150-200-DPI-Bereichs, da ALLE Foto-Bitmaps bis `writeTo()` gleichzeitig
* im Speicher gehalten werden müssen (keine echte "ein Bitmap zur Zeit"-Verarbeitung mit
* dieser PDF-Engine möglich) und `ARGB_8888` statt `RGB_565` verwendet wird (keine
* Farbtiefenreduktion bei medizinischen Wundfotos).
*/
private const val PHOTO_TARGET_DPI = 150
/**
* Änderungswunsch: Bilder im Fotoanhang werden auf 50 % ihrer bisherigen Größe
* ausgegeben (Breite und Höhe je halbiert, Seitenverhältnis unverändert). Derselbe
* Faktor gilt für die Decode-Auflösung (s. [PHOTO_MAX_WIDTH_PX]), sodass die
* Bilddichte bei [PHOTO_TARGET_DPI] bleibt und das PDF zugleich deutlich kleiner wird.
*/
private const val PHOTO_SIZE_FACTOR = 0.5f
// Verfügbare Bildfläche in Punkten (Seite abzüglich Rand) - siehe drawText()-nahe
// Verwendung von contentWidth/remainingHeight für die tatsächliche Platzierung; diese
// Konstanten bestimmen ausschließlich die DECODE-Zielgröße (wie groß ein Foto maximal
// eingelesen wird), nicht die exakte Zeichenposition (die hängt zusätzlich von der
// jeweils bereits gezeichneten Bildunterschrift ab und wird von PdfPageCursor selbst
// anhand des tatsächlich verbleibenden Platzes bestimmt).
private const val PHOTO_AREA_WIDTH_PT = PAGE_WIDTH - 2 * MARGIN
private const val PHOTO_AREA_HEIGHT_PT = PAGE_HEIGHT - 2 * MARGIN
/** Maximale Decode-Breite in Pixeln bei [PHOTO_TARGET_DPI] und [PHOTO_SIZE_FACTOR] (≈ 536px bei 257,5pt/150dpi). */
private val PHOTO_MAX_WIDTH_PX = (PHOTO_AREA_WIDTH_PT * PHOTO_SIZE_FACTOR / 72f * PHOTO_TARGET_DPI).toInt()
/** Maximale Decode-Höhe in Pixeln bei [PHOTO_TARGET_DPI] und [PHOTO_SIZE_FACTOR] (≈ 793px bei 381pt/150dpi). */
private val PHOTO_MAX_HEIGHT_PX = (PHOTO_AREA_HEIGHT_PT * PHOTO_SIZE_FACTOR / 72f * PHOTO_TARGET_DPI).toInt()
private val HEADER_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
}
override suspend fun export(model: PdfExportModel): PdfExportResult = withContext(Dispatchers.IO) {
val targetDir = File(File(context.filesDir, "pdf"), model.documentationId)
if (!targetDir.exists() && !targetDir.mkdirs()) {
return@withContext PdfExportResult.Failure(PdfExportError.DirectoryCreationFailed(targetDir.absolutePath))
}
val targetFile = File(targetDir, model.fileName)
// Erst in eine temporäre Datei schreiben und nur bei vollständigem Erfolg auf den
// endgültigen Dateinamen umbenennen - so kann am Zielpfad selbst nie eine halbfertige
// Datei liegen bleiben, auch nicht bei einem Fehler mitten im Schreibvorgang.
val tempFile = File(targetDir, "${model.fileName}.tmp")
val pdfDocument = PdfDocument()
// Sammelt AUSSCHLIESSLICH die Bitmaps, die tatsächlich per Canvas.drawBitmap() gezeichnet
// wurden (nicht die groben inSampleSize-Zwischenergebnisse, die bereits innerhalb von
// PhotoStorage.decodeFitted() freigegeben werden). Diese müssen laut der in der
// Klassendokumentation belegten PdfDocument-Speichersemantik bis NACH writeTo() gültig
// bleiben und werden deshalb erst im finally-Block unten recycelt.
val retainedPhotoBitmaps = mutableListOf<Bitmap>()
// Signatur-Bitmaps unterliegen exakt derselben PdfDocument-Speichersemantik wie
// Foto-Bitmaps (s. Klassendokumentation) - eigene Liste statt Wiederverwendung von
// [retainedPhotoBitmaps], damit beide Kategorien unabhängig recycelt werden können.
val retainedSignatureBitmaps = mutableListOf<Bitmap>()
try {
val paints = ExportPaints()
val cursor = PdfPageCursor(
pdfDocument = pdfDocument,
pageWidth = PAGE_WIDTH,
pageHeight = PAGE_HEIGHT,
marginLeft = MARGIN,
marginRight = MARGIN,
marginTop = MARGIN,
marginBottom = MARGIN,
pageHeaderDrawer = PageHeaderDrawer { canvas, pageNumber, left, top, width ->
drawRunningHeader(canvas, pageNumber, left, top, width, model, paints)
},
)
drawHeader(cursor, model, paints)
for (block in model.blocks) {
drawBlock(cursor, block, paints)
}
// Reihenfolge PDF-003/Abschnitt 25: Dokumentationsblöcke -> Signaturen -> Fotoanhang.
if (model.signatures.isNotEmpty()) {
drawSignatures(cursor, model, paints, retainedSignatureBitmaps)
}
for ((index, photo) in model.photos.withIndex()) {
drawPhotoPage(cursor, model, photo, index, paints, retainedPhotoBitmaps)
}
cursor.finish()
FileOutputStream(tempFile).use { out -> pdfDocument.writeTo(out) }
if (targetFile.exists()) targetFile.delete()
if (!tempFile.renameTo(targetFile)) {
tempFile.delete()
return@withContext PdfExportResult.Failure(
PdfExportError.WriteFailed("PDF konnte nicht am endgültigen Speicherort abgelegt werden.")
)
}
PdfExportResult.Success(targetFile)
} catch (e: Throwable) {
tempFile.delete()
if (e is kotlinx.coroutines.CancellationException) throw e
// Bewusst Throwable: OutOfMemoryError ist ein Error, keine Exception, und würde
// sonst die App beenden. Eine bereits vorhandene PDF am Zielpfad bleibt unangetastet.
when (FailureCategory.of(e)) {
FailureCategory.OUT_OF_MEMORY -> PdfExportResult.Failure(PdfExportError.OutOfMemory)
FailureCategory.OUT_OF_STORAGE -> PdfExportResult.Failure(PdfExportError.InsufficientStorage)
FailureCategory.OTHER -> PdfExportResult.Failure(
PdfExportError.WriteFailed(e.message ?: "Unbekannter Fehler bei der PDF-Erzeugung.")
)
}
} finally {
pdfDocument.close()
// Erst JETZT sicher, da writeTo() (im try-Block oben) zu diesem Zeitpunkt entweder
// bereits erfolgreich abgeschlossen ist, oder der Export ohnehin fehlgeschlagen ist -
// in beiden Fällen werden diese Bitmaps nicht mehr gebraucht.
retainedPhotoBitmaps.forEach { runCatching { it.recycle() } }
retainedSignatureBitmaps.forEach { runCatching { it.recycle() } }
}
}
/** Bündelt alle für den Export verwendeten Paints. */
private class ExportPaints {
val title = TextPaint().apply {
isAntiAlias = true
textSize = TITLE_TEXT_SIZE
isFakeBoldText = true
color = Color.BLACK
}
val subtitle = TextPaint().apply {
isAntiAlias = true
textSize = SUBTITLE_TEXT_SIZE
color = Color.DKGRAY
}
val headerInfo = TextPaint().apply {
isAntiAlias = true
textSize = HEADER_INFO_TEXT_SIZE
color = Color.BLACK
}
// Überschriften (Blocktitel, "Unterschriften:", "Fotoanhang:"): fett UND kursiv.
val blockTitle = TextPaint().apply {
isAntiAlias = true
textSize = BLOCK_TITLE_TEXT_SIZE
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD_ITALIC)
color = Color.BLACK
}
val item = TextPaint().apply {
isAntiAlias = true
textSize = ITEM_TEXT_SIZE
color = Color.BLACK
}
val freeText = TextPaint().apply {
isAntiAlias = true
textSize = FREE_TEXT_TEXT_SIZE
color = Color.DKGRAY
}
// Zwischenüberschriften innerhalb eines Blocks: kursiv + unterstrichen.
val itemSubheading = TextPaint().apply {
isAntiAlias = true
textSize = ITEM_TEXT_SIZE
typeface = Typeface.create(Typeface.DEFAULT, Typeface.ITALIC)
isUnderlineText = true
color = Color.BLACK
}
// Wichtiger Hinweis: fett + unterstrichen.
val itemWarning = TextPaint().apply {
isAntiAlias = true
textSize = ITEM_TEXT_SIZE
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
isUnderlineText = true
color = Color.BLACK
}
// Seitenkopf ab Seite 2 (Patient + Erstelldatum).
val runningHeader = TextPaint().apply {
isAntiAlias = true
textSize = RUNNING_HEADER_TEXT_SIZE
color = Color.DKGRAY
}
val runningHeaderLine = Paint().apply {
color = Color.LTGRAY
strokeWidth = 1f
}
// PDF-003: Signaturbereich.
val signatureLabel = TextPaint().apply {
isAntiAlias = true
textSize = SIGNATURE_LABEL_TEXT_SIZE
color = Color.BLACK
}
val signatureNameUnderLine = TextPaint().apply {
isAntiAlias = true
textSize = SIGNATURE_NAME_UNDER_LINE_TEXT_SIZE
color = Color.BLACK
}
val signaturePlaceholder = TextPaint().apply {
isAntiAlias = true
textSize = SIGNATURE_PLACEHOLDER_TEXT_SIZE
color = Color.DKGRAY
}
/** Für das Zeichnen der Fotoanhang-Bitmaps (PDF-004). `isFilterBitmap` sorgt für
* weiche statt treppenartige Kanten beim Canvas-seitigen Skalieren. */
val photo = Paint().apply {
isAntiAlias = true
isFilterBitmap = true
}
}
private fun drawHeader(cursor: PdfPageCursor, model: PdfExportModel, paints: ExportPaints) {
cursor.drawText("VulneraDoc", paints.title)
cursor.drawText("Wunddokumentation", paints.subtitle, spacingAfter = 8f)
val infoLines = listOf(
"Patient: ${model.patientFullName}",
"Geburtsdatum: ${model.patientBirthDate.format(HEADER_DATE_FORMATTER)}",
"Adresse: ${model.patientDisplayAddress}",
"Erstellt am: ${model.createdAt.format(HEADER_DATE_FORMATTER)}",
)
for ((index, line) in infoLines.withIndex()) {
val spacing = if (index == infoLines.lastIndex) 12f else 2f
cursor.drawText(line, paints.headerInfo, spacingAfter = spacing)
}
cursor.drawDivider(spacingAfter = 12f)
}
/**
* Seitenkopf für alle Seiten AB Seite 2 (Änderungswunsch: auf jeder Seite müssen Name des
* Patienten und Erstellungsdatum stehen): Seite 1 trägt bereits den großen Dokumentkopf
* ([drawHeader]). Gilt auch für Unterschriften- und Fotoseiten. Verwendet ausschließlich
* Snapshot-Daten aus dem [PdfExportModel]. Liefert die belegte Höhe zurück.
*/
private fun drawRunningHeader(
canvas: Canvas,
pageNumber: Int,
left: Float,
top: Float,
width: Float,
model: PdfExportModel,
paints: ExportPaints,
): Float {
if (pageNumber <= 1) return 0f
val text = "Patient: ${model.patientFullName}, geb. ${model.patientBirthDate.format(HEADER_DATE_FORMATTER)}" +
" · ${model.patientDisplayAddress} · Erstellt am: ${model.createdAt.format(HEADER_DATE_FORMATTER)}"
val layout = StaticLayout.Builder.obtain(text, 0, text.length, paints.runningHeader, width.toInt().coerceAtLeast(1))
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
.setIncludePad(false)
.build()
canvas.save()
canvas.translate(left, top)
layout.draw(canvas)
canvas.restore()
val lineY = top + layout.height + 4f
canvas.drawLine(left, lineY, left + width, lineY, paints.runningHeaderLine)
return layout.height + 4f + 1f + RUNNING_HEADER_SPACING_AFTER
}
/** Überschriften erscheinen im PDF immer mit abschließendem Doppelpunkt (kein doppelter
* Doppelpunkt, falls der Titel bereits mit einem endet). */
private fun headingText(title: String): String {
val trimmed = title.trimEnd()
return if (trimmed.endsWith(":")) trimmed else "$trimmed:"
}
private fun drawBlock(cursor: PdfPageCursor, block: PdfExportBlock, paints: ExportPaints) {
// Änderungswunsch: "Empfohlene Wundversorgung" beginnt immer auf einer eigenen Seite.
if (block.startsOnNewPage) cursor.startNewPageUnlessEmpty()
cursor.drawText(headingText(block.title), paints.blockTitle, spacingAfter = 6f)
for (item in block.items) {
drawItem(cursor, item, paints)
}
cursor.drawDivider(spacingBefore = 4f, spacingAfter = 12f)
}
/** Änderungswunsch: KEIN Checkbox-Symbol mehr im PDF. Da [PdfExportBlock.items] bereits nur
* angehakte Optionen enthält (s. Mapper), wäre ein "immer angekreuztes" Kästchen ohnehin
* redundant - stattdessen ein einfacher, leicht eingerückter Label-Text je Option. */
private fun drawItem(cursor: PdfPageCursor, item: PdfExportItem, paints: ExportPaints) {
// Änderungswunsch: strukturierte Eingabefelder (Zahl/Auswahlliste, z. B. "Von ... bis
// ... Uhr") werden als EIN fertig formatierter Satz ausgegeben (s.
// InputSpec.formatForPdf) - kein Label+Doppelpunkt+separate Zusatzzeile wie bei
// Freitext, da die Textvorlage im Katalog bereits den vollständigen, lesbaren Satz
// vorgibt.
val decodedInputSpec = if (item.inputSpec.isNotEmpty()) InputSpecCodec.decode(item.inputSpec) else null
if (decodedInputSpec != null) {
// Eintrag mit eigener Checkbox (z. B. "Wie oft pro Woche"), angehakt aber ohne
// Auswahl: nur das Label ausgeben statt einer Vorlage mit "–"-Platzhaltern.
val itemText = if (decodedInputSpec.requiresOwnCheckbox && !decodedInputSpec.hasAnyValue(item.freeTextValue)) {
item.label
} else {
decodedInputSpec.formatForPdf(item.freeTextValue)
}
cursor.drawText(
text = itemText,
paint = paints.item,
indent = ITEM_INDENT,
spacingAfter = 3f,
)
return
}
// Generische "Freitext"-Einträge (Änderungswunsch): Bezeichnung wird NICHT gedruckt, nur
// der eingegebene Text (eingerückt unter dem vorhergehenden Eintrag). Ein angehakter,
// aber leerer Freitext erzeugt keine Ausgabe.
if (!item.showLabel) {
if (item.freeTextValue.isNotBlank()) {
cursor.drawText(
text = item.freeTextValue,
paint = paints.item,
indent = FREE_TEXT_INDENT,
spacingAfter = 6f,
)
}
return
}
// Die Freitext-Option bekommt einen abschließenden Doppelpunkt, sofern tatsächlich Text
// folgt (z. B. "Granudacyn" ohne Text bleibt ohne hängenden Doppelpunkt), normale
// Optionen nie.
val baseLabel = if (item.isFreeText && item.freeTextValue.isNotBlank()) "${item.label}:" else item.label
// Änderungswunsch: Zwischenüberschriften kursiv + unterstrichen, wichtiger Hinweis fett +
// unterstrichen mit Ausrufezeichen davor und dahinter.
val (labelText, labelPaint) = when (item.style) {
PdfItemStyle.SUBHEADING -> baseLabel to paints.itemSubheading
PdfItemStyle.WARNING -> "! $baseLabel !" to paints.itemWarning
PdfItemStyle.NORMAL -> baseLabel to paints.item
}
cursor.drawText(
text = labelText,
paint = labelPaint,
indent = ITEM_INDENT,
spacingAfter = 3f,
)
// Freitext nur zeichnen, wenn tatsächlich Text vorhanden ist - ein angehakter, aber
// leerer Freitext erzeugt keinen leeren Bereich im PDF.
if (item.isFreeText && item.freeTextValue.isNotBlank()) {
cursor.drawText(
text = item.freeTextValue,
paint = paints.freeText,
indent = FREE_TEXT_INDENT,
spacingAfter = 6f,
)
}
}
/**
* Zeichnet den kompletten Signaturbereich (PDF-003): eine einmalige Überschrift
* "Unterschriften", gefolgt von einem atomaren Block je Signatur (bereits vom Mapper stabil
* nach orderIndex sortiert, s. [PdfExportModel.signatures]). Anders als der Fotoanhang
* beginnt dieser Abschnitt NICHT zwingend auf einer neuen Seite - er schließt sich direkt an
* die zuletzt gezeichneten Dokumentationsblöcke an, sofern noch Platz vorhanden ist
* (Aufgabenstellung Abschnitt 25/28).
*/
private suspend fun drawSignatures(
cursor: PdfPageCursor,
model: PdfExportModel,
paints: ExportPaints,
retainedSignatureBitmaps: MutableList<Bitmap>,
) {
cursor.drawText(headingText("Unterschriften"), paints.blockTitle, spacingAfter = SIGNATURE_SECTION_TITLE_SPACING_AFTER)
for (signature in model.signatures) {
drawSignatureBlock(cursor, model, signature, paints, retainedSignatureBitmaps)
}
}
/**
* Zeichnet EINEN Signaturblock (Wundversorgername, Bild bzw. neutraler Platzhalter bei
* beschädigter/fehlender Datei, Unterschriftslinie, ausgeschriebener Name darunter) als
* unteilbare Einheit: Die Gesamthöhe wird VORAB berechnet und über
* [PdfPageCursor.ensureBlockSpace] reserviert, BEVOR die erste Teilzeichnung beginnt - reicht
* der Platz auf der aktuellen Seite nicht, wandert der komplette Block auf die nächste Seite,
* nie nur ein Teil davon (Aufgabenstellung Abschnitt 28).
*
* Bitmap-Lebensdauer: Die dekodierte Signatur-Bitmap wird - genau wie Foto-Bitmaps in
* [drawPhotoPage] - in [retainedSignatureBitmaps] gesammelt und erst nach `writeTo()`
* recycelt (s. Klassendokumentation zur PdfDocument-Speichersemantik).
*/
private suspend fun drawSignatureBlock(
cursor: PdfPageCursor,
model: PdfExportModel,
signature: PdfExportSignature,
paints: ExportPaints,
retainedSignatureBitmaps: MutableList<Bitmap>,
) {
val labelText = "Wundversorger: ${signature.signerName}"
val nameText = signature.signerName
// Fehlende/beschädigte Signaturdatei führt NICHT zum Absturz des gesamten Exports -
// andere Signaturen und Fotos werden trotzdem exportiert (Aufgabenstellung Abschnitt 31).
// Bewusst OHNE Dateipfad/technischen Fehlertext im Platzhalter.
val bitmap = runCatching {
signatureStorage.decode(documentationId = model.documentationId, fileName = signature.fileName)
}.getOrNull()
val labelHeight = cursor.measureTextHeight(labelText, paints.signatureLabel)
val imageOrPlaceholderHeight = if (bitmap != null) {
(SIGNATURE_IMAGE_TARGET_WIDTH_PT.coerceAtMost(cursor.contentWidth.toFloat())) *
bitmap.height / bitmap.width
} else {
cursor.measureTextHeight("Signatur konnte nicht geladen werden.", paints.signaturePlaceholder)
}
val nameHeight = cursor.measureTextHeight(nameText, paints.signatureNameUnderLine)
val totalBlockHeight = labelHeight + SIGNATURE_SPACING_AFTER_LABEL +
imageOrPlaceholderHeight + SIGNATURE_SPACING_AFTER_IMAGE +
1f + SIGNATURE_SPACING_AFTER_LINE + // Linie selbst ist ca. 1pt "hoch"
nameHeight + SIGNATURE_SPACING_AFTER_BLOCK
// Reserviert die GESAMTE Blockhöhe in einem Rutsch, bevor überhaupt mit dem Zeichnen
// begonnen wird - das ist der eigentliche Mechanismus hinter "atomarer Block" (s.
// Methodenkommentar oben und PdfPageCursor.ensureBlockSpace).
cursor.ensureBlockSpace(totalBlockHeight)
cursor.drawText(labelText, paints.signatureLabel, spacingAfter = SIGNATURE_SPACING_AFTER_LABEL)
if (bitmap != null) {
retainedSignatureBitmaps.add(bitmap)
cursor.drawBitmapFixedWidth(
bitmap = bitmap,
targetWidth = SIGNATURE_IMAGE_TARGET_WIDTH_PT,
paint = paints.photo,
spacingAfter = SIGNATURE_SPACING_AFTER_IMAGE,
)
} else {
cursor.drawText(
"Signatur konnte nicht geladen werden.",
paints.signaturePlaceholder,
spacingAfter = SIGNATURE_SPACING_AFTER_IMAGE,
)
}
cursor.drawShortLine(SIGNATURE_LINE_WIDTH_PT, spacingAfter = SIGNATURE_SPACING_AFTER_LINE)
cursor.drawText(nameText, paints.signatureNameUnderLine, spacingAfter = SIGNATURE_SPACING_AFTER_BLOCK)
}
/**
* Zeichnet den Fotoanhang: jedes Foto auf einer eigenen, neu begonnenen Seite (PDF-004).
* Vor dem ersten Foto wird eine kurze Überschrift "Fotoanhang" ergänzt, jedes Foto bekommt
* zusätzlich eine neutrale Nummerierung ("Foto N von M") - keine Patientendaten oder
* ursprünglichen Dateinamen als Beschriftung.
*
* Erfolgreich decodierte Bitmaps werden in [retainedPhotoBitmaps] gesammelt (s. Begründung
* in der Klassendokumentation zur PdfDocument-Speichersemantik) - der Aufrufer ist dafür
* verantwortlich, diese erst NACH `writeTo()` freizugeben.
*/
private suspend fun drawPhotoPage(
cursor: PdfPageCursor,
model: PdfExportModel,
photo: PdfExportPhoto,
index: Int,
paints: ExportPaints,
retainedPhotoBitmaps: MutableList<Bitmap>,
) {
cursor.forceNewPage()
if (index == 0) {
cursor.drawText(headingText("Fotoanhang"), paints.blockTitle, spacingAfter = 8f)
}
cursor.drawText("Foto ${index + 1} von ${model.photos.size}", paints.headerInfo, spacingAfter = 8f)
val bitmap = runCatching {
photoStorage.decodeFitted(
documentationId = model.documentationId,
fileName = photo.fileName,
maxWidthPx = PHOTO_MAX_WIDTH_PX,
maxHeightPx = PHOTO_MAX_HEIGHT_PX,
)
}.getOrNull()
if (bitmap != null) {
retainedPhotoBitmaps.add(bitmap)
cursor.drawBitmapFitted(bitmap, paints.photo, sizeFactor = PHOTO_SIZE_FACTOR)
} else {
// Fehlende/beschädigte Datei, IOException, ungültige Abmessungen o. Ä. - Option B:
// neutrale Hinweisseite statt stillschweigendem Überspringen, damit nachvollziehbar
// bleibt, dass an dieser Stelle ursprünglich ein Foto zur Dokumentation gehörte.
// Bewusst OHNE Dateipfad/technischen Fehlertext.
cursor.drawText("Foto konnte nicht geladen werden.", paints.item)
}
}
}
@@ -0,0 +1,62 @@
package de.medapp.wunddoku.pdf
import java.io.File
/**
* Erzeugt aus einem [PdfExportModel] eine PDF-Datei im internen App-Speicher.
*
* Bewusst vollständig unabhängig von Compose, Navigation, ViewModels und rohen Room-Entities -
* kennt ausschließlich diese schlanke, layoutorientierte Export-Datenstruktur. Als Interface
* mit separater Implementierung ([AndroidPdfDocumentExporter]) gehalten, damit später eine
* PDF-Engine mit Verschlüsselungs-/Signaturunterstützung ergänzt werden kann (siehe
* Aufgabenstellung "Schreibgeschützt"), ohne Repository/ViewModel/UI zu berühren.
*/
interface DocumentationPdfExporter {
suspend fun export(model: PdfExportModel): PdfExportResult
}
/** Klarer Erfolg-/Fehlerpfad statt roher, bis in die UI durchgereichter Exceptions. */
sealed interface PdfExportResult {
data class Success(val file: File) : PdfExportResult
data class Failure(val error: PdfExportError) : PdfExportResult
}
/**
* Fachliche Fehlerkategorien für den PDF-Export.
*
* [DocumentationNotFound] wird NICHT vom Exporter selbst geworfen (der kennt keine
* documentationId, nur das fertige [PdfExportModel]), sondern vom aufrufenden Repository, das
* die Dokumentation vorher lädt - siehe [de.medapp.wunddoku.data.repository.DocumentationRepository.exportPdf].
*
* Ein eigener Fehlerfall für "Patientendaten fehlen unerwartet" existiert bewusst NICHT: Da
* [de.medapp.wunddoku.domain.model.Documentation.patientSnapshot] im Domain-Modell
* nicht-nullable ist, macht der Kotlin-Typ diesen Fall bereits zur Kompilierzeit unmöglich - ein
* zusätzlicher Laufzeit-Check wäre toter Code.
*/
sealed interface PdfExportError {
data object DocumentationNotFound : PdfExportError
data class DirectoryCreationFailed(val path: String) : PdfExportError
data class WriteFailed(val message: String) : PdfExportError
data class Unexpected(val message: String) : PdfExportError
/** Arbeitsspeicher reichte für die PDF-Erzeugung nicht aus (z. B. sehr viele Fotos). */
data object OutOfMemory : PdfExportError
/** Auf dem Gerät ist nicht genug freier Speicherplatz für die PDF-Datei vorhanden. */
data object InsufficientStorage : PdfExportError
}
/**
* Verständliche Anzeige-Meldung ohne technische Details (keine Pfade, keine Exception-Texte -
* Datenschutz: es können Patientendaten im Dateinamen/Pfad stecken).
*/
fun PdfExportError.toUserMessage(): String = when (this) {
PdfExportError.DocumentationNotFound -> "Die Dokumentation wurde nicht gefunden."
is PdfExportError.DirectoryCreationFailed -> "Zielverzeichnis konnte nicht angelegt werden."
is PdfExportError.WriteFailed -> "Beim Schreiben der PDF-Datei ist ein Fehler aufgetreten."
is PdfExportError.Unexpected -> "Es ist ein unerwarteter Fehler aufgetreten."
PdfExportError.OutOfMemory ->
"Nicht genügend Arbeitsspeicher. Bitte andere Apps schließen oder weniger Fotos verwenden."
PdfExportError.InsufficientStorage ->
"Nicht genügend freier Speicherplatz auf dem Gerät."
}
@@ -0,0 +1,151 @@
package de.medapp.wunddoku.pdf
import de.medapp.wunddoku.domain.catalog.DocumentationCatalog
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.DocumentationBlock
import de.medapp.wunddoku.domain.usecase.BlockEditedPolicy
import java.time.ZoneId
/**
* Wandelt ein [Documentation]-Domain-Modell in das layoutfertige [PdfExportModel] um.
*
* Verwendet für die Filterung ausschließlich die bestehende [BlockEditedPolicy] (keine eigene,
* abweichende Definition von "bearbeitet") und ausschließlich die historischen Snapshot-Felder
* (titleSnapshot/labelSnapshot bzw. deren bereits gemappte Domain-Entsprechungen
* [de.medapp.wunddoku.domain.model.DocumentationBlock.title]/[de.medapp.wunddoku.domain.model.CheckItem.label]) -
* niemals den aktuellen [de.medapp.wunddoku.domain.catalog.DocumentationCatalog]. Ebenso werden
* ausschließlich [de.medapp.wunddoku.domain.model.Documentation.patientSnapshot]-Daten
* verwendet, nicht der aktuelle Patientendatensatz - eine spätere Änderung der Patienten-
* Stammdaten darf ein bereits erzeugtes PDF nicht rückwirkend verändern.
*/
object DocumentationToPdfExportModelMapper {
fun map(documentation: Documentation): PdfExportModel {
val createdAtDate = documentation.createdAt.atZone(ZoneId.systemDefault()).toLocalDate()
val snapshot = documentation.patientSnapshot
val editedBlocks = documentation.blocks
.filter { BlockEditedPolicy.isEdited(it) }
.sortedBy { it.order }
.map { block ->
PdfExportBlock(
definitionId = block.definitionId,
title = block.title,
startsOnNewPage = block.definitionId in BLOCKS_ON_OWN_PAGE,
// Nur angehakte Optionen erscheinen im PDF. Hinweistexte (isStaticHint) sind
// technisch immer angehakt und werden gruppenabhängig gefiltert, s.
// [exportedItems]. Ein leeres Ergebnis ist ausgeschlossen: BlockEditedPolicy
// (Filter oben) lässt nur Blöcke mit mindestens einer angehakten Option durch.
items = exportedItems(block).map { item ->
PdfExportItem(
label = item.label,
isFreeText = item.isFreeText,
freeTextValue = item.freeTextValue,
inputSpec = item.inputSpec,
// Generische "Freitext"-Bezeichnung nie drucken, nur den Text.
showLabel = !(item.isFreeText && item.label == DocumentationCatalog.FREE_TEXT_LABEL),
style = styleFor(item),
)
},
)
}
val fileName = PdfFileNameGenerator.generate(
createdAt = createdAtDate,
firstName = snapshot.firstName,
lastName = snapshot.lastName,
birthDate = snapshot.birthDate,
)
// Explizit erneut nach orderIndex sortiert (nicht implizit auf die bereits im
// Domain-Modell vorhandene Reihenfolge verlassen) - dieselbe defensive Konsistenz wie
// bei blocks/items oben, gemäß Vorgabe "nicht Dateiname/UUID/createdAt/Dateisystem-
// Reihenfolge verwenden".
val photos = documentation.photos
.sortedBy { it.orderIndex }
.map { photo -> PdfExportPhoto(fileName = photo.fileName) }
// Explizit erneut nach orderIndex sortiert (dieselbe defensive Konsistenz wie bei
// blocks/items/photos oben) - alle gespeicherten Signaturen werden exportiert, es gibt
// keine feste Obergrenze und kein `signatures.first()` (Aufgabenstellung Abschnitt 29).
val signatures = documentation.signatures
.sortedBy { it.orderIndex }
.map { signature -> PdfExportSignature(signerName = signature.signerName, fileName = signature.fileName) }
return PdfExportModel(
documentationId = documentation.id,
fileName = fileName,
patientFullName = snapshot.fullName,
patientBirthDate = snapshot.birthDate,
patientDisplayAddress = snapshot.displayAddress,
createdAt = createdAtDate,
blocks = editedBlocks,
signatures = signatures,
photos = photos,
)
}
/** Blöcke, die im PDF/JPG immer auf einer neuen Seite beginnen. */
private val BLOCKS_ON_OWN_PAGE: Set<String> = setOf("empfohlene_wundversorgung")
/** Zwischenüberschriften (kursiv + unterstrichen), anhand der stabilen Definitions-IDs. */
private val SUBHEADING_DEFINITION_IDS: Set<String> = setOf(
"empfohlene_wundversorgung_hinweis_reinigen_mit",
"empfohlene_wundversorgung_primaer_abdecken_austamponieren",
"empfohlene_wundversorgung_wundrandschutz",
"empfohlene_wundversorgung_hinweis_sekundaere_wundauflage",
"empfohlene_wundversorgung_hinweis_fixieren_mit",
"empfohlene_wundversorgung_wechselintervall",
)
/** Wichtige Hinweise (fett + unterstrichen, mit Ausrufezeichen). */
private val WARNING_DEFINITION_IDS: Set<String> = setOf(
"empfohlene_wundversorgung_hinweis_abklaerung",
)
private fun styleFor(item: CheckItem): PdfItemStyle = when (item.definitionId) {
in WARNING_DEFINITION_IDS -> PdfItemStyle.WARNING
in SUBHEADING_DEFINITION_IDS -> PdfItemStyle.SUBHEADING
else -> PdfItemStyle.NORMAL
}
/**
* Bestimmt die zu exportierenden Einträge eines Blocks (sortiert nach order):
* - normale Einträge: nur wenn angehakt
* - Hinweistexte (isStaticHint, technisch immer angehakt): Ein Hinweis, dem im Block
* eigene Einträge folgen (bis zum nächsten Hinweis), ist eine Gruppenüberschrift und
* wird nur gedruckt, wenn mindestens einer dieser Einträge angehakt ist (z. B. "Zu
* versorgendes Areal" bei Größe). Hinweise ohne folgende Einträge ("+ bei Bedarf",
* "Generell: ...") bleiben immer erhalten. Der automatisch angehängte generische
* Freitext eines Blocks gehört zu keiner Gruppe. Trägt der erste angehakte Eintrag der
* Gruppe dieselbe Bezeichnung wie der Hinweis, entfällt der Hinweis (keine Doppelung).
*
* Ausschließlich Snapshot-Daten, kein Zugriff auf den aktuellen Katalog.
*/
private fun exportedItems(block: DocumentationBlock): List<CheckItem> {
val sorted = block.items.sortedBy { it.order }
val genericFreeTextId = "${block.definitionId}_freetext"
val result = mutableListOf<CheckItem>()
for ((index, item) in sorted.withIndex()) {
if (!item.checked) continue
if (!item.isStaticHint) {
result.add(item)
continue
}
val groupItems = sorted
.drop(index + 1)
.takeWhile { !it.isStaticHint }
.filter { it.definitionId != genericFreeTextId }
val keep = when {
groupItems.isEmpty() -> true
else -> {
val firstChecked = groupItems.firstOrNull { it.checked }
firstChecked != null && !firstChecked.label.trim().equals(item.label.trim(), ignoreCase = true)
}
}
if (keep) result.add(item)
}
return result
}
}
@@ -0,0 +1,80 @@
package de.medapp.wunddoku.pdf
import de.medapp.wunddoku.media.SafePaths
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
/**
* Löscht abgeleitete, personenbezogene Dateien, die NICHT zur Dokumentation selbst gehören:
* exportierte PDFs (`filesDir/pdf/<documentationId>/`), daraus erzeugte Seitenbilder
* (`filesDir/pdf_images/<documentationId>/`) und liegengebliebene Kameraaufnahmen
* (`cacheDir/camera/`).
*
* Datenschutz: PDFs enthalten Name, Geburtsdatum, Anschrift und Wundbefund. Sie dürfen nach dem
* Löschen einer Dokumentation nicht im Speicher zurückbleiben (Löschkonzept) und werden darüber
* hinaus nach einer Aufbewahrungsfrist automatisch entfernt - sie lassen sich jederzeit aus den
* Daten der Dokumentation neu erzeugen.
*/
interface ExportedFilesCleaner {
/** Entfernt alle PDF- und Bildexporte der Dokumentation [documentationId]. Wirft nie. */
suspend fun deleteExportsFor(documentationId: String)
/** Entfernt veraltete Exporte und liegengebliebene Kameraaufnahmen. Wirft nie. */
suspend fun deleteStaleFiles()
/** Tut nichts (Standard für Tests und Umgebungen ohne Dateisystem). */
object None : ExportedFilesCleaner {
override suspend fun deleteExportsFor(documentationId: String) = Unit
override suspend fun deleteStaleFiles() = Unit
}
}
class InternalExportedFilesCleaner(
filesDir: File,
cacheDir: File,
private val exportMaxAgeMillis: Long = DEFAULT_EXPORT_MAX_AGE_MILLIS,
private val captureMaxAgeMillis: Long = DEFAULT_CAPTURE_MAX_AGE_MILLIS,
private val now: () -> Long = System::currentTimeMillis,
) : ExportedFilesCleaner {
private val pdfRoot = File(filesDir, "pdf")
private val imagesRoot = File(filesDir, "pdf_images")
private val captureDir = File(cacheDir, "camera")
override suspend fun deleteExportsFor(documentationId: String) = withContext(Dispatchers.IO) {
listOf(pdfRoot, imagesRoot).forEach { root ->
runCatching { SafePaths.resolveChild(root, documentationId).deleteRecursively() }
}
Unit
}
override suspend fun deleteStaleFiles() = withContext(Dispatchers.IO) {
val currentTime = now()
runCatching { deleteOlderThan(pdfRoot, currentTime - exportMaxAgeMillis) }
runCatching { deleteOlderThan(imagesRoot, currentTime - exportMaxAgeMillis) }
// Kameraaufnahmen nur nach längerer Frist löschen: Wurde die App während der Aufnahme
// beendet, wird die Datei nach dem Neustart noch für den Import gebraucht.
runCatching { deleteOlderThan(captureDir, currentTime - captureMaxAgeMillis) }
Unit
}
/** Löscht Dateien (rekursiv) mit `lastModified < cutoff`; leere Unterordner werden entfernt. */
private fun deleteOlderThan(dir: File, cutoff: Long) {
val children = dir.listFiles() ?: return
for (child in children) {
if (child.isDirectory) {
deleteOlderThan(child, cutoff)
if (child.listFiles().isNullOrEmpty()) child.delete()
} else if (child.lastModified() < cutoff) {
child.delete()
}
}
}
companion object {
const val DEFAULT_EXPORT_MAX_AGE_MILLIS: Long = 7L * 24 * 60 * 60 * 1000
const val DEFAULT_CAPTURE_MAX_AGE_MILLIS: Long = 24L * 60 * 60 * 1000
}
}
@@ -0,0 +1,89 @@
package de.medapp.wunddoku.pdf
import java.time.LocalDate
/**
* Flache, ausschließlich layoutrelevante Export-Datenstruktur für [DocumentationPdfExporter].
*
* Bewusst getrennt vom Domain-Modell [de.medapp.wunddoku.domain.model.Documentation]: Der
* Exporter soll weder Room-Entities noch domänenspezifische Feinheiten (Snapshot-Mechanik,
* IDs, Status etc.) kennen müssen, sondern ausschließlich das, was tatsächlich auf die Seite
* gezeichnet wird. Die Umwandlung übernimmt [DocumentationToPdfExportModelMapper].
*/
data class PdfExportModel(
val documentationId: String,
val fileName: String,
val patientFullName: String,
val patientBirthDate: LocalDate,
val patientDisplayAddress: String,
val createdAt: LocalDate,
/** Bereits gefiltert (nur bearbeitete Blöcke, s. BlockEditedPolicy) und sortiert (blockOrder). */
val blocks: List<PdfExportBlock>,
/** Bereits sortiert nach orderIndex (PDF-003: Unterschriften). Reihenfolge im fertigen PDF:
* Dokumentationsblöcke -> Signaturen -> Fotoanhang. */
val signatures: List<PdfExportSignature>,
/** Bereits sortiert nach orderIndex (PDF-004: Fotoanhang). */
val photos: List<PdfExportPhoto>,
)
data class PdfExportBlock(
/** Stabile Definitions-ID des Blocks (z. B. "empfohlene_wundversorgung"). */
val definitionId: String = "",
val title: String,
/** `true`: der Block beginnt im PDF/JPG zwingend auf einer neuen Seite (Änderungswunsch:
* "Empfohlene Wundversorgung" auf eigener Seite). */
val startsOnNewPage: Boolean = false,
/** Bereits gefiltert (NUR angehakte Optionen, s. Mapper - Änderungswunsch) und sortiert
* (itemOrder). Nicht angehakte Optionen erscheinen im PDF überhaupt nicht mehr. */
val items: List<PdfExportItem>,
)
/** Kein `checked`-Feld mehr: Da [PdfExportBlock.items] bereits ausschließlich angehakte
* Optionen enthält, wäre es immer `true` und damit bedeutungslos - s. Mapper-Filter.
*
* [inputSpec]: nicht-leer, wenn dieser Eintrag strukturierte Eingabefelder besitzt (s.
* [de.medapp.wunddoku.domain.model.InputSpec]) - der Exporter formatiert in diesem Fall
* [freeTextValue] über [de.medapp.wunddoku.domain.model.InputSpec.formatForPdf] statt es als
* einfachen Zusatztext unter [label] auszugeben. */
data class PdfExportItem(
val label: String,
val isFreeText: Boolean,
val freeTextValue: String,
val inputSpec: String = "",
/** `false`: die Bezeichnung wird nicht gedruckt, nur der eingegebene Text (generische
* "Freitext"-Einträge, Änderungswunsch: Schriftzug "Freitext" im PDF/JPG nicht anzeigen). */
val showLabel: Boolean = true,
val style: PdfItemStyle = PdfItemStyle.NORMAL,
)
/** Darstellung der Bezeichnung eines Eintrags im PDF (rein layoutbezogen). */
enum class PdfItemStyle {
NORMAL,
/** Zwischenüberschrift innerhalb eines Blocks: kursiv und unterstrichen. */
SUBHEADING,
/** Wichtiger Hinweis: fett, unterstrichen, mit Ausrufezeichen davor und dahinter. */
WARNING,
}
/**
* Minimale, für den Fotoanhang benötigte Referenz. Enthält bewusst nur [fileName] -
* `documentationId` steht bereits am [PdfExportModel] selbst zur Verfügung (nötig, um über
* [de.medapp.wunddoku.media.PhotoStorage] die intern gespeicherte Datei aufzulösen).
*/
data class PdfExportPhoto(
val fileName: String,
)
/**
* Minimale, für den Signaturbereich benötigte Referenz (PDF-003). [signerName] ist der
* historische Snapshot zum Zeitpunkt der Unterschrift - nicht der aktuelle Stand eventueller
* Wundversorger-Stammdaten. [fileName] wird analog [PdfExportPhoto] über
* [de.medapp.wunddoku.signature.SignatureStorage] aufgelöst (`documentationId` steht bereits am
* [PdfExportModel] zur Verfügung).
*/
data class PdfExportSignature(
val signerName: String,
val fileName: String,
)
@@ -0,0 +1,53 @@
package de.medapp.wunddoku.pdf
import java.text.Normalizer
import java.time.LocalDate
/**
* Zentrale, reine (keine Android-Abhängigkeit, ohne Instrumentierung testbar) Erzeugung des
* vereinbarten PDF-Dateinamensschemas:
*
* `Erstelldatum_Vorname_Nachname_Geburtsdatum.pdf`, z. B.
* `2026-08-23_Max_Mustermann_1980-03-14.pdf`
*
* Alle Bestandteile werden dateisystemkompatibel normalisiert (keine Pfadseparatoren, keine
* ungültigen Zeichen, deutsche Umlaute transliteriert statt einfach entfernt).
*/
object PdfFileNameGenerator {
fun generate(createdAt: LocalDate, firstName: String, lastName: String, birthDate: LocalDate): String {
val datePart = createdAt.toString() // LocalDate.toString() liefert ISO-8601 (yyyy-MM-dd)
val birthPart = birthDate.toString()
val firstNamePart = normalizeNameComponent(firstName)
val lastNamePart = normalizeNameComponent(lastName)
return "${datePart}_${firstNamePart}_${lastNamePart}_${birthPart}.pdf"
}
/**
* Normalisiert einen einzelnen Namensbestandteil:
* 1. Deutsche Umlaute/ß werden transliteriert (ä→ae usw.), nicht einfach entfernt - sonst
* würden z. B. "Müller" und "Mller" im Dateinamen ununterscheidbar.
* 2. Verbleibende Diakritika (andere Sprachen) werden über Unicode-Normalisierung entfernt.
* 3. Leerzeichen werden zu Unterstrichen, alles außerhalb `[A-Za-z0-9_-]` wird entfernt -
* schließt insbesondere Pfadseparatoren (`/`, `\`) und sonstige für Dateinamen
* problematische Zeichen sicher aus.
* 4. Mehrfache Unterstriche werden zusammengefasst, führende/nachgestellte entfernt.
*/
private fun normalizeNameComponent(input: String): String {
val transliterated = transliterateGerman(input)
val withoutDiacritics = Normalizer.normalize(transliterated, Normalizer.Form.NFD)
.replace(Regex("\\p{M}+"), "")
val safe = withoutDiacritics
.replace(Regex("\\s+"), "_")
.replace(Regex("[^A-Za-z0-9_-]"), "")
.replace(Regex("_+"), "_")
.trim('_', '-')
return safe.ifBlank { "Unbekannt" }
}
private fun transliterateGerman(input: String): String = input
.replace("ä", "ae").replace("Ä", "Ae")
.replace("ö", "oe").replace("Ö", "Oe")
.replace("ü", "ue").replace("Ü", "Ue")
.replace("ß", "ss")
}
@@ -0,0 +1,99 @@
package de.medapp.wunddoku.pdf
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import java.io.File
/**
* Übergibt alle als JPEG gerenderten PDF-Seiten (s. [PdfToImagesConverter]) GEMEINSAM an den
* Android-"Senden an"-Dialog (`ACTION_SEND_MULTIPLE`) - Änderungswunsch: "alle generierten
* Bilder über 'Senden an' direkt verschicken können", nicht Seite für Seite einzeln.
*
* Bewusst getrennt von [PdfToImagesConverter] (reine Datei-Logik): das Starten einer Activity
* ist eine UI-/Plattform-Aktion, keine Repository-Aufgabe - exakt dieselbe Trennung wie bei
* [DocumentationPdfExporter] (Erzeugung) vs. [PdfOpener] (Anzeige).
*
* VulneraDoc implementiert ausdrücklich KEINEN eigenen Versand-Mechanismus; welche konkrete App
* (Mail, Messenger, Cloud-Speicher, ...) die Bilder entgegennimmt, entscheidet ausschließlich
* Android/der Nutzer über den System-Dialog.
*/
interface PdfImageSharer {
fun share(imageFiles: List<File>): PdfImageShareResult
}
sealed interface PdfImageShareResult {
data object Success : PdfImageShareResult
sealed interface Failure : PdfImageShareResult {
/** Es wurden keine Bilddateien übergeben - nichts zum Teilen vorhanden. */
data object NoImages : Failure
/** FileProvider konnte für mindestens eine Datei keine content://-Uri erzeugen. */
data class UriResolutionFailed(val message: String) : Failure
/** Keine installierte App kann Bilder (image/jpeg) entgegennehmen. */
data object NoAppAvailable : Failure
data class Unexpected(val message: String) : Failure
}
}
fun PdfImageShareResult.Failure.toUserMessage(): String = when (this) {
PdfImageShareResult.Failure.NoImages ->
"Es sind keine Bilder zum Versenden vorhanden."
is PdfImageShareResult.Failure.UriResolutionFailed ->
"Die Bilder konnten nicht zum Versenden freigegeben werden."
PdfImageShareResult.Failure.NoAppAvailable ->
"Auf diesem Gerät ist keine App zum Versenden von Bildern installiert."
is PdfImageShareResult.Failure.Unexpected ->
"Die Bilder konnten nicht versendet werden."
}
class AndroidPdfImageSharer(private val context: Context) : PdfImageSharer {
override fun share(imageFiles: List<File>): PdfImageShareResult {
if (imageFiles.isEmpty()) return PdfImageShareResult.Failure.NoImages
val authority = "${context.packageName}.fileprovider"
val uris = try {
imageFiles.map { file -> FileProvider.getUriForFile(context, authority, file) }
} catch (e: IllegalArgumentException) {
// Wird von FileProvider geworfen, wenn eine Datei außerhalb der in file_paths.xml
// freigegebenen Verzeichnisse liegt oder die Authority nicht passt.
return PdfImageShareResult.Failure.UriResolutionFailed(e.message ?: "Uri konnte nicht erzeugt werden.")
}
val intent = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
type = "image/jpeg"
putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris))
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
// Zusätzlich zum Flag oben wird die Leseberechtigung explizit über ClipData an JEDE
// einzelne Uri gehängt - bei ACTION_SEND_MULTIPLE mit mehreren content://-Uris ist
// das der zuverlässige Weg, damit wirklich ALLE empfangenden Apps Zugriff auf ALLE
// Bilder bekommen (nicht nur auf die im Intent-Datenfeld primär referenzierte).
clipData = ClipData.newUri(context.contentResolver, "Wunddokumentation", uris.first()).apply {
for (uri in uris.drop(1)) {
addItem(ClipData.Item(uri))
}
}
}
val chooser = Intent.createChooser(intent, "Bilder senden an")
return try {
context.startActivity(chooser)
PdfImageShareResult.Success
} catch (e: ActivityNotFoundException) {
// Bewusst KEIN vorheriges intent.resolveActivity(...) - s. Begründung in
// AndroidPdfOpener.open() (Package-Visibility-Einschränkungen ab Android 11).
PdfImageShareResult.Failure.NoAppAvailable
} catch (e: SecurityException) {
PdfImageShareResult.Failure.Unexpected(e.message ?: "Zugriff auf die Bilddateien wurde verweigert.")
} catch (e: Exception) {
PdfImageShareResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler beim Versenden der Bilder.")
}
}
}
@@ -0,0 +1,89 @@
package de.medapp.wunddoku.pdf
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import java.io.File
/**
* Öffnet eine bereits erzeugte PDF-Datei im auf dem Gerät verfügbaren Standard-PDF-Reader
* (PDF-005). Bewusst getrennt von [DocumentationPdfExporter]: PDF-Erzeugung (reine
* Datei-/Domain-Logik) und PDF-Anzeige (Android-Intent/Activity-Start) bleiben unterschiedliche
* Verantwortlichkeiten - der Exporter startet niemals eine Activity.
*
* VulneraDoc implementiert ausdrücklich KEINEN eigenen PDF-Viewer; welche konkrete App den
* `ACTION_VIEW`-Intent verarbeitet, entscheidet ausschließlich Android/der Nutzer.
*/
interface PdfOpener {
fun open(file: File): PdfOpenResult
}
sealed interface PdfOpenResult {
data object Success : PdfOpenResult
sealed interface Failure : PdfOpenResult {
/** Die referenzierte Datei existiert nicht mehr (z. B. zwischenzeitlich extern entfernt). */
data object FileNotFound : Failure
/** FileProvider konnte für die Datei keine content://-Uri erzeugen. */
data class UriResolutionFailed(val message: String) : Failure
/** Kein installierter Reader kann application/pdf verarbeiten. */
data object NoViewerAvailable : Failure
data class Unexpected(val message: String) : Failure
}
}
/** Fachliche, für die Anzeige (z. B. Snackbar) geeignete Übersetzung eines [PdfOpenResult.Failure]. */
fun PdfOpenResult.Failure.toUserMessage(): String = when (this) {
PdfOpenResult.Failure.FileNotFound ->
"Die PDF-Datei wurde nicht gefunden. Bitte die Dokumentation erneut exportieren."
is PdfOpenResult.Failure.UriResolutionFailed ->
"Die PDF-Datei konnte nicht zur Anzeige freigegeben werden."
PdfOpenResult.Failure.NoViewerAvailable ->
"Auf diesem Gerät ist keine App zum Anzeigen von PDF-Dateien installiert."
is PdfOpenResult.Failure.Unexpected ->
"Die PDF-Datei konnte nicht geöffnet werden."
}
class AndroidPdfOpener(private val context: Context) : PdfOpener {
override fun open(file: File): PdfOpenResult {
if (!file.exists()) {
return PdfOpenResult.Failure.FileNotFound
}
val authority = "${context.packageName}.fileprovider"
val uri = try {
FileProvider.getUriForFile(context, authority, file)
} catch (e: IllegalArgumentException) {
// Wird von FileProvider geworfen, wenn die Datei außerhalb der in file_paths.xml
// freigegebenen Verzeichnisse liegt oder die Authority nicht passt.
return PdfOpenResult.Failure.UriResolutionFailed(e.message ?: "Uri konnte nicht erzeugt werden.")
}
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/pdf")
// Ausschließlich Lese-, KEINE Schreibberechtigung für den externen Reader.
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
return try {
context.startActivity(intent)
PdfOpenResult.Success
} catch (e: ActivityNotFoundException) {
// Bewusst KEIN vorheriges intent.resolveActivity(...) - das würde auf Android 11+
// durch Package-Visibility-Einschränkungen ohne zusätzliche <queries>-Deklaration
// im Manifest fälschlich "kein Reader" melden können, auch wenn tatsächlich einer
// installiert ist. Der eigentliche startActivity()-Aufruf unterliegt dieser
// Einschränkung nicht - ActivityNotFoundException ist hier die zuverlässige Quelle.
PdfOpenResult.Failure.NoViewerAvailable
} catch (e: SecurityException) {
PdfOpenResult.Failure.Unexpected(e.message ?: "Zugriff auf die PDF-Datei wurde verweigert.")
} catch (e: Exception) {
PdfOpenResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler beim Öffnen der PDF-Datei.")
}
}
}
@@ -0,0 +1,336 @@
package de.medapp.wunddoku.pdf
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.pdf.PdfDocument
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
/**
* Zeichnet den Seitenkopf einer (neuen) Seite und liefert die dafür belegte Höhe zurück
* (0f, wenn auf dieser Seite kein Seitenkopf gezeichnet wird, z. B. Seite 1 mit dem großen
* Dokumentkopf). [left]/[top] sind die obere linke Ecke des Inhaltsbereichs, [width] dessen Breite.
*/
internal fun interface PageHeaderDrawer {
fun draw(canvas: Canvas, pageNumber: Int, left: Float, top: Float, width: Float): Float
}
/**
* Verwaltet Seiten, Zeichenfläche und die aktuelle vertikale Schreibposition ("Cursor") beim
* Erzeugen eines [PdfDocument]. Kapselt die komplette Seitenumbruch-Logik an einer Stelle, damit
* [AndroidPdfDocumentExporter] selbst nur noch "was" gezeichnet werden soll beschreiben muss.
*
* SEITENUMBRUCH-STRATEGIE: [drawText] zerlegt einen Text NICHT durch manuelles Verschieben/
* Clippen einzelner Zeilen eines einmal gebauten [StaticLayout]. Stattdessen wird pro
* Seiten-Abschnitt ein EIGENES, frisch aufgebautes [StaticLayout] für genau den Textausschnitt
* erzeugt, der auf die aktuelle Seite passt (Ausschnittgrenzen liegen dabei immer exakt auf
* bereits von Android selbst bestimmten Zeilengrenzen). Jeder Seiten-Abschnitt berechnet damit
* seine Baselines/Zeilenhöhen selbst und korrekt aus der StaticLayout-API - es gibt keine
* manuelle Koordinatenverschiebung einzelner Zeilen, die zu Baseline-/Zeilenhöhenfehlern führen
* könnte. Das erlaubt einen Seitenumbruch exakt zwischen zwei Zeilen und behandelt "Block
* größer als eine Seite" automatisch mit, ohne Sonderfall-Code.
*/
internal class PdfPageCursor(
private val pdfDocument: PdfDocument,
private val pageWidth: Int,
private val pageHeight: Int,
private val marginLeft: Float,
private val marginRight: Float,
private val marginTop: Float,
private val marginBottom: Float,
/** Optionaler Seitenkopf, der auf JEDER neu begonnenen Seite gezeichnet wird (Änderungswunsch:
* Patientenname und Erstellungsdatum auf jeder Seite). */
private val pageHeaderDrawer: PageHeaderDrawer? = null,
) {
private var pageNumber = 0
/** Y-Position, ab der auf der aktuellen Seite Inhalt beginnt (nach einem evtl. Seitenkopf). */
private var pageContentTop: Float = marginTop
private var currentPage: PdfDocument.Page? = null
private var canvas: Canvas? = null
private var cursorY: Float = marginTop
private val dividerPaint = Paint().apply {
color = Color.LTGRAY
strokeWidth = 1f
}
private val footerPaint = Paint().apply {
color = Color.GRAY
textSize = 9f
textAlign = Paint.Align.CENTER
isAntiAlias = true
}
val contentWidth: Int
get() = (pageWidth - marginLeft - marginRight).toInt()
/** Verbleibende Höhe auf der aktuellen Seite ab der aktuellen Cursor-Position (PDF-004:
* Grundlage für die Einpassung des Fotos nach einer bereits gezeichneten Bildunterschrift). */
val remainingHeight: Float
get() {
startIfNeeded()
return (contentBottom() - cursorY).coerceAtLeast(0f)
}
private fun contentBottom(): Float = pageHeight - marginBottom
private fun startIfNeeded() {
if (currentPage == null) startNewPage()
}
private fun startNewPage() {
finishCurrentPageIfOpen()
pageNumber++
val info = PdfDocument.PageInfo.Builder(pageWidth, pageHeight, pageNumber).create()
val page = pdfDocument.startPage(info)
currentPage = page
canvas = page.canvas
val headerHeight = pageHeaderDrawer?.draw(
canvas = page.canvas,
pageNumber = pageNumber,
left = marginLeft,
top = marginTop,
width = pageWidth - marginLeft - marginRight,
) ?: 0f
pageContentTop = marginTop + headerHeight
cursorY = pageContentTop
}
private fun finishCurrentPageIfOpen() {
val page = currentPage ?: return
// Seitenzahl wird direkt beim Fertigstellen dieser Seite gezeichnet (kein "Seite X von
// Y", da die Gesamtseitenzahl an dieser Stelle noch nicht bekannt ist).
canvas?.drawText("Seite $pageNumber", pageWidth / 2f, pageHeight - marginBottom / 2f, footerPaint)
pdfDocument.finishPage(page)
currentPage = null
canvas = null
}
/**
* Erzwingt den Beginn einer neuen Seite, unabhängig davon, wie viel Platz auf der aktuellen
* Seite noch verfügbar wäre (PDF-004: jedes Foto beginnt zwingend auf einer eigenen neuen
* Seite, nie im verbleibenden Platz einer Dokumentations- oder vorherigen Fotoseite). Die
* Seitennummerierung läuft dabei fortlaufend weiter, da [startNewPage] intern lediglich
* `pageNumber` erhöht, nie zurücksetzt.
*/
fun forceNewPage() {
startNewPage()
}
/**
* Beginnt eine neue Seite, außer die aktuelle Seite ist noch völlig leer (Cursor steht am
* Inhaltsanfang) - so entstehen keine Leerseiten, wenn ein Block, der immer auf einer
* eigenen Seite beginnt, ohnehin ganz oben stünde.
*/
fun startNewPageUnlessEmpty() {
if (currentPage == null || cursorY > pageContentTop) startNewPage()
}
/**
* Zeichnet [bitmap] proportional eingepasst (kein Zuschneiden, keine Verzerrung) horizontal
* über die volle Inhaltsbreite und vertikal zentriert im aktuell verbleibenden Platz der
* Seite. Setzt voraus, dass die Seite bereits begonnen wurde (i. d. R. direkt nach
* [forceNewPage] plus einer optionalen Bildunterschrift über [drawText]) - diese Methode
* selbst löst KEINEN Seitenumbruch aus, da ein Foto nicht über zwei Seiten verteilt werden
* darf; reicht der verbleibende Platz nicht aus, wird das Bild in den vorhandenen Rest
* hineinskaliert (kleiner, nie abgeschnitten).
*/
fun drawBitmapFitted(bitmap: Bitmap, paint: Paint, sizeFactor: Float = 1f) {
startIfNeeded()
val availableWidth = contentWidth.toFloat()
val availableHeight = remainingHeight
if (availableWidth <= 0f || availableHeight <= 0f || bitmap.width <= 0 || bitmap.height <= 0) return
// [sizeFactor] < 1 (Änderungswunsch: Fotoanhang auf 50 %): das Bild wird zuerst wie
// gewohnt in den verfügbaren Platz eingepasst und dann um diesen Faktor verkleinert
// (Seitenverhältnis bleibt). Das verkleinerte Bild steht horizontal zentriert direkt
// unter der Bildunterschrift (oben ausgerichtet) statt mitten in der Restfläche.
val scale = minOf(availableWidth / bitmap.width, availableHeight / bitmap.height) * sizeFactor
val drawWidth = bitmap.width * scale
val drawHeight = bitmap.height * scale
val left = marginLeft + (availableWidth - drawWidth) / 2f
val top = if (sizeFactor < 1f) cursorY else cursorY + (availableHeight - drawHeight) / 2f
canvas!!.drawBitmap(bitmap, null, RectF(left, top, left + drawWidth, top + drawHeight), paint)
cursorY = contentBottom()
}
/** Schließt die aktuell offene Seite ab. Muss am Ende des gesamten Exports genau einmal
* aufgerufen werden. */
fun finish() {
finishCurrentPageIfOpen()
}
fun drawDivider(spacingBefore: Float = 0f, spacingAfter: Float = 0f) {
startIfNeeded()
ensureSpace(spacingBefore + 1f)
cursorY += spacingBefore
canvas!!.drawLine(marginLeft, cursorY, pageWidth - marginRight, cursorY, dividerPaint)
cursorY += 1f + spacingAfter
}
/** Reserviert [height] vertikalen Platz, beginnt bei Bedarf eine neue Seite. */
private fun ensureSpace(height: Float) {
startIfNeeded()
if (cursorY + height > contentBottom()) {
startNewPage()
}
}
/**
* Öffentliches Gegenstück zu [ensureSpace] für ATOMARE Blöcke, die aus mehreren
* Einzelzeichnungen bestehen (PDF-003: ein Signaturblock aus Wundversorgername, Bild, Linie
* und Namenszeile darf laut Aufgabenstellung Abschnitt 28 nicht zwischen zwei Seiten
* zerrissen werden). Der Aufrufer berechnet VOR dem eigentlichen Zeichnen die
* Gesamthöhe des Blocks (s. [measureTextHeight] für die enthaltenen Textanteile) und
* reserviert sie hier in einem Rutsch - reicht der verbleibende Platz nicht, beginnt eine
* komplett neue Seite, BEVOR auch nur die erste Teilzeichnung des Blocks beginnt. Anders als
* [drawText] (das einen zu langen Text selbst auf mehrere Seiten verteilen würde) gibt es
* hier keine interne Aufteilung - das ist hier gewünscht, kein Sonderfall.
*/
fun ensureBlockSpace(height: Float) {
ensureSpace(height)
}
/**
* Berechnet die Gesamthöhe, die [text] beim Zeichnen mit [drawText] belegen würde, OHNE
* etwas zu zeichnen oder den Cursor zu verändern. Verwendet zur Berechnung dasselbe
* [buildLayout] wie [drawText] selbst (identischer Text/Paint/Breite), damit die hier
* gemessene und die später tatsächlich gezeichnete Höhe für ein und denselben Aufruf
* garantiert übereinstimmen - Voraussetzung dafür, dass eine vorab über [ensureBlockSpace]
* reservierte Blockhöhe beim tatsächlichen Zeichnen exakt ausreicht.
*/
fun measureTextHeight(text: String, paint: TextPaint, indent: Float = 0f): Float {
val width = (contentWidth - indent).toInt().coerceAtLeast(1)
return buildLayout(text, paint, width).height.toFloat()
}
/**
* Zeichnet [bitmap] proportional in eine FESTE Zielbreite [targetWidth] eingepasst (Höhe
* ergibt sich aus dem Seitenverhältnis von [bitmap] - kein Zuschneiden, keine Verzerrung),
* linksbündig ab der aktuellen Cursor-Position.
*
* Bewusst ein GRUNDSÄTZLICH ANDERES Verhalten als [drawBitmapFitted] (PDF-004: füllt gezielt
* den GESAMTEN verbleibenden Platz der Seite - richtig für "genau ein Foto pro Seite"): Hier
* wird NICHT der restliche Seitenplatz ausgefüllt, sondern exakt die aus [targetWidth]
* abgeleitete Zielhöhe gezeichnet und der Cursor auch nur genau um diese Höhe
* weiterbewegt - Voraussetzung dafür, dass mehrere Signaturblöcke hintereinander auf
* derselben Seite stehen können, statt dass jeder einzelne Block wie ein Foto seine ganze
* Seite beansprucht (Aufgabenstellung Abschnitt 28: "Mehrere vollständige Signaturblöcke
* dürfen auf derselben Seite stehen, sofern genug Platz vorhanden ist").
*
* Setzt voraus, dass für den vollständigen Block (inkl. dieses Bildes) bereits vorher
* [ensureBlockSpace] aufgerufen wurde - diese Methode selbst löst KEINEN Seitenumbruch aus.
*/
fun drawBitmapFixedWidth(bitmap: Bitmap, targetWidth: Float, paint: Paint, spacingAfter: Float = 0f): Float {
startIfNeeded()
if (bitmap.width <= 0 || bitmap.height <= 0) return 0f
val drawWidth = targetWidth.coerceAtMost(contentWidth.toFloat())
val drawHeight = drawWidth * bitmap.height / bitmap.width
val left = marginLeft
val top = cursorY
canvas!!.drawBitmap(bitmap, null, RectF(left, top, left + drawWidth, top + drawHeight), paint)
cursorY += drawHeight + spacingAfter
return drawHeight
}
/**
* Kurze horizontale Linie fester Breite [width] (z. B. eine Unterschriftslinie), linksbündig
* ab der aktuellen Cursor-Position - im Gegensatz zu [drawDivider] NICHT über die volle
* Inhaltsbreite der Seite gezogen. Setzt wie [drawBitmapFixedWidth] ein vorheriges
* [ensureBlockSpace] für den gesamten umgebenden Block voraus.
*/
fun drawShortLine(width: Float, spacingBefore: Float = 0f, spacingAfter: Float = 0f) {
startIfNeeded()
cursorY += spacingBefore
canvas!!.drawLine(marginLeft, cursorY, marginLeft + width, cursorY, dividerPaint)
cursorY += spacingAfter
}
/**
* Zeichnet [text] mehrzeilig (automatischer Umbruch anhand [paint]/Zeilenbreite), paginiert
* über beliebig viele Seiten. [indent] verschiebt den gesamten Textblock zusätzlich nach
* rechts (z. B. für eingerückten Freitext unter einer Checkbox-Zeile).
*
* [leadingMark], falls gesetzt, wird EINMALIG unmittelbar vor der allerersten gezeichneten
* Textzeile aufgerufen - Aufrufparameter sind (Canvas, linke X-Position vor dem Einzug,
* Geräte-Y-Oberkante, Geräte-Y-Unterkante) der ersten Zeile. Dient z. B. dazu, links neben
* dem Text ein Checkbox-Symbol zu zeichnen (s. [AndroidPdfDocumentExporter]). Kästchen und
* erste Zeile werden dabei als untrennbare Einheit behandelt: Reicht der Platz auf der
* aktuellen Seite nicht einmal für die erste Zeile, wird vorher auf eine neue Seite
* gewechselt - das Kästchen kann also niemals von seiner Zeile getrennt auf eine andere
* Seite geraten.
*/
fun drawText(
text: String,
paint: TextPaint,
indent: Float = 0f,
spacingAfter: Float = 0f,
leadingMark: ((canvas: Canvas, left: Float, top: Float, bottom: Float) -> Unit)? = null,
) {
val width = (contentWidth - indent).toInt().coerceAtLeast(1)
val fullLayout = buildLayout(text, paint, width)
var lineIndex = 0
val lineCount = fullLayout.lineCount
var isFirstLine = true
while (lineIndex < lineCount) {
startIfNeeded()
val remaining = contentBottom() - cursorY
// Ermittelt, wie viele aufeinanderfolgende Zeilen ab lineIndex noch auf die
// aktuelle Seite passen (Ausschnittgrenzen liegen exakt auf Zeilengrenzen).
var endLineExclusive = lineIndex
var usedHeight = 0f
while (endLineExclusive < lineCount) {
val lineHeight = (fullLayout.getLineBottom(endLineExclusive) - fullLayout.getLineTop(endLineExclusive)).toFloat()
if (usedHeight + lineHeight > remaining) break
usedHeight += lineHeight
endLineExclusive++
}
if (endLineExclusive == lineIndex) {
// Nicht einmal eine einzige Zeile passt mehr auf die aktuelle Seite -> neue
// Seite beginnen und denselben Ausschnitt erneut versuchen.
startNewPage()
continue
}
val charStart = fullLayout.getLineStart(lineIndex)
val charEnd = fullLayout.getLineEnd(endLineExclusive - 1)
// Frisches StaticLayout NUR für diesen Seiten-Abschnitt - Android berechnet Zeilen/
// Baselines dafür vollständig neu, beginnend bei lokal y = 0 für die erste Zeile
// dieses Abschnitts. Keine manuelle Koordinatenübernahme aus fullLayout nötig.
val chunkLayout = buildLayout(text.substring(charStart, charEnd), paint, width)
val c = canvas!!
c.save()
c.translate(marginLeft + indent, cursorY)
chunkLayout.draw(c)
c.restore()
if (isFirstLine && leadingMark != null) {
val firstLineHeight = (chunkLayout.getLineBottom(0) - chunkLayout.getLineTop(0)).toFloat()
leadingMark(c, marginLeft, cursorY, cursorY + firstLineHeight)
isFirstLine = false
}
cursorY += usedHeight
lineIndex = endLineExclusive
}
cursorY += spacingAfter
}
private fun buildLayout(text: String, paint: TextPaint, width: Int): StaticLayout {
val safeWidth = width.coerceAtLeast(1)
return StaticLayout.Builder.obtain(text, 0, text.length, paint, safeWidth)
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
.setLineSpacing(0f, 1.05f)
.setIncludePad(false)
.build()
}
}
@@ -0,0 +1,72 @@
package de.medapp.wunddoku.pdf
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import java.io.File
/**
* Übergibt die fertige PDF-Datei selbst (statt in JPEG-Seiten umgewandelt, s.
* [PdfToImagesConverter]/[PdfImageSharer]) an den Android-"Senden an"-Dialog
* (`ACTION_SEND`, `application/pdf`) - Änderungswunsch: Nutzer soll beim Antippen des
* Teilen-Buttons wählen können, ob als JPG ODER als PDF geteilt wird.
*
* Bewusst getrennt von [PdfOpener] (öffnet dieselbe Datei stattdessen per `ACTION_VIEW` in
* einem PDF-Reader) - unterschiedliche Nutzerabsicht (ansehen vs. weitergeben), auch wenn
* beide dieselbe Datei über denselben FileProvider-Pfad freigeben.
*/
interface PdfSharer {
fun share(pdfFile: File): PdfShareResult
}
sealed interface PdfShareResult {
data object Success : PdfShareResult
sealed interface Failure : PdfShareResult {
data class UriResolutionFailed(val message: String) : Failure
data object NoAppAvailable : Failure
data class Unexpected(val message: String) : Failure
}
}
fun PdfShareResult.Failure.toUserMessage(): String = when (this) {
is PdfShareResult.Failure.UriResolutionFailed ->
"Die PDF-Datei konnte nicht zum Versenden freigegeben werden."
PdfShareResult.Failure.NoAppAvailable ->
"Auf diesem Gerät ist keine App zum Versenden von PDFs installiert."
is PdfShareResult.Failure.Unexpected ->
"Die PDF-Datei konnte nicht versendet werden."
}
class AndroidPdfSharer(private val context: Context) : PdfSharer {
override fun share(pdfFile: File): PdfShareResult {
val authority = "${context.packageName}.fileprovider"
val uri = try {
FileProvider.getUriForFile(context, authority, pdfFile)
} catch (e: IllegalArgumentException) {
return PdfShareResult.Failure.UriResolutionFailed(e.message ?: "Uri konnte nicht erzeugt werden.")
}
val intent = Intent(Intent.ACTION_SEND).apply {
type = "application/pdf"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(intent, "PDF senden an")
return try {
context.startActivity(chooser)
PdfShareResult.Success
} catch (e: ActivityNotFoundException) {
// Bewusst KEIN vorheriges intent.resolveActivity(...) - s. Begründung in
// AndroidPdfOpener.open() (Package-Visibility-Einschränkungen ab Android 11).
PdfShareResult.Failure.NoAppAvailable
} catch (e: SecurityException) {
PdfShareResult.Failure.Unexpected(e.message ?: "Zugriff auf die PDF-Datei wurde verweigert.")
} catch (e: Exception) {
PdfShareResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler beim Versenden der PDF-Datei.")
}
}
}
@@ -0,0 +1,137 @@
package de.medapp.wunddoku.pdf
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.pdf.PdfRenderer
import android.os.ParcelFileDescriptor
import de.medapp.wunddoku.domain.error.FailureCategory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import kotlin.math.roundToInt
/**
* Rendert eine bereits fertig erzeugte PDF-Datei (s. [DocumentationPdfExporter]) seitenweise in
* einzelne JPEG-Dateien - Änderungswunsch: "PDF zusätzlich als JPEG speichern/senden können".
*
* Bewusst als eigener Schritt NACH dem PDF-Export statt als Alternative dazu: Es gibt weiterhin
* genau eine Quelle der Wahrheit für das Seitenlayout (der bestehende [PdfPageCursor]-basierte
* Export) - dieser Konverter rastert lediglich das bereits fertige Ergebnis, statt eine zweite,
* parallele Layout-Logik zu pflegen.
*
* Verwendet [android.graphics.pdf.PdfRenderer] (Android-Bordmittel seit API 21, unproblematisch
* bei minSdk 26) - keine zusätzliche Bibliothek nötig. Eine mehrseitige Wunddokumentation (Kopf/
* Blöcke, Signaturen, ggf. 30+ Fotoseiten) ergibt dabei bewusst EIN JPEG PRO SEITE, kein
* einzelnes Sammelbild - beim späteren Teilen (s. [PdfImageSharer]) werden alle Seiten
* gemeinsam als Bilder-Mehrfachauswahl übergeben.
*/
interface PdfToImagesConverter {
suspend fun convert(documentationId: String, pdfFile: File): PdfToImagesResult
}
sealed interface PdfToImagesResult {
data class Success(val imageFiles: List<File>) : PdfToImagesResult
sealed interface Failure : PdfToImagesResult {
/** Die übergebene PDF-Datei existiert nicht (mehr). */
data object FileNotFound : Failure
/** PdfRenderer meldet 0 Seiten - sollte bei einem selbst erzeugten PDF nie vorkommen. */
data object EmptyDocument : Failure
data class Unexpected(val message: String) : Failure
/** Arbeitsspeicher reichte zum Rendern der Seiten nicht aus. */
data object OutOfMemory : Failure
/** Nicht genug freier Speicherplatz für die Bilddateien. */
data object InsufficientStorage : Failure
}
}
fun PdfToImagesResult.Failure.toUserMessage(): String = when (this) {
PdfToImagesResult.Failure.FileNotFound ->
"Die PDF-Datei wurde nicht gefunden. Bitte zuerst die Dokumentation erneut exportieren."
PdfToImagesResult.Failure.EmptyDocument ->
"Die PDF-Datei enthält keine Seiten."
is PdfToImagesResult.Failure.Unexpected ->
"Die PDF-Datei konnte nicht in Bilder umgewandelt werden."
PdfToImagesResult.Failure.OutOfMemory ->
"Nicht genügend Arbeitsspeicher zum Umwandeln in Bilder."
PdfToImagesResult.Failure.InsufficientStorage ->
"Nicht genügend freier Speicherplatz für die Bilder."
}
class AndroidPdfToImagesConverter(private val context: Context) : PdfToImagesConverter {
override suspend fun convert(documentationId: String, pdfFile: File): PdfToImagesResult =
withContext(Dispatchers.IO) {
if (!pdfFile.exists()) return@withContext PdfToImagesResult.Failure.FileNotFound
val outputDir = File(File(context.filesDir, "pdf_images"), documentationId)
// Bilder eines vorherigen Exports zuerst entfernen, damit bei einer inzwischen
// kürzeren PDF (z. B. Fotos gelöscht) keine veralteten, zu vielen Seiten-Dateien
// liegen bleiben.
outputDir.deleteRecursively()
outputDir.mkdirs()
try {
ParcelFileDescriptor.open(pdfFile, ParcelFileDescriptor.MODE_READ_ONLY).use { fd ->
PdfRenderer(fd).use { renderer ->
if (renderer.pageCount == 0) {
return@withContext PdfToImagesResult.Failure.EmptyDocument
}
val imageFiles = mutableListOf<File>()
for (pageIndex in 0 until renderer.pageCount) {
renderer.openPage(pageIndex).use { page ->
val scale = RENDER_DPI / POINTS_PER_INCH
val width = (page.width * scale).roundToInt().coerceAtLeast(1)
val height = (page.height * scale).roundToInt().coerceAtLeast(1)
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
// WICHTIG: PdfRenderer zeichnet nur die tatsächlich im PDF
// vorhandenen Inhalte - unbedeckte Flächen bleiben transparent
// (Alpha 0). JPEG kennt keine Transparenz; ohne dieses
// Vorab-Weißfüllen würde jede unbedeckte Fläche beim
// Komprimieren als SCHWARZ statt als weißer Seitenhintergrund
// enden (bekannte PdfRenderer-Falle, s. offizielle Android-
// Beispiele).
bitmap.eraseColor(Color.WHITE)
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
val imageFile = File(outputDir, "seite_%02d.jpg".format(pageIndex + 1))
FileOutputStream(imageFile).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out)
}
bitmap.recycle()
imageFiles.add(imageFile)
}
}
PdfToImagesResult.Success(imageFiles)
}
}
} catch (e: Throwable) {
// Abbruch der Coroutine nie verschlucken.
if (e is kotlinx.coroutines.CancellationException) throw e
// Halbfertigen Bildsatz entfernen: ein unvollständiger Satz darf nie geteilt werden.
outputDir.deleteRecursively()
when (FailureCategory.of(e)) {
FailureCategory.OUT_OF_MEMORY -> PdfToImagesResult.Failure.OutOfMemory
FailureCategory.OUT_OF_STORAGE -> PdfToImagesResult.Failure.InsufficientStorage
FailureCategory.OTHER ->
PdfToImagesResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler bei der Bildkonvertierung.")
}
}
}
companion object {
/** PDF-Koordinaten sind in Punkten (1/72 Zoll) angegeben - diese Ziel-Auflösung ergibt
* ein für Ausdruck/Weitergabe ausreichend scharfes, aber nicht unnötig großes Bild. */
private const val RENDER_DPI = 200f
private const val POINTS_PER_INCH = 72f
private const val JPEG_QUALITY = 90
}
}
@@ -0,0 +1,29 @@
package de.medapp.wunddoku.signature
import android.graphics.Bitmap
/**
* Zweite, von der UI unabhängige Absicherung gegen das Speichern einer leeren Signatur
* (Aufgabenstellung Abschnitt 12: "Daten-/Storage-Ebene: keine leere PNG-Datei als gültige
* Signatur persistieren"). Die erste Ebene ist der deaktivierte "Speichern"-Button in der UI,
* solange keine Striche gezeichnet wurden - diese Prüfung greift zusätzlich unabhängig davon in
* [de.medapp.wunddoku.data.repository.DocumentationRepository.saveSignature], damit ein Aufruf,
* der die UI-Absicherung umgeht (Programmierfehler, zukünftiger zweiter Aufrufer o. Ä.), nicht
* trotzdem eine leere Signatur persistiert.
*/
object SignatureBitmapValidation {
/**
* Liefert `true`, wenn [bitmap] ausschließlich reinweiße Pixel enthält, also keine
* tatsächlich gezeichnete Signatur darstellt. Die Signaturzeichenfläche rendert auf einen
* weißen Hintergrund; jeder gezeichnete Strich (auch nur teilweise, dank Anti-Aliasing an
* den Rändern) erzeugt mindestens einen Pixel, der von reinem Weiß abweicht.
*/
fun isBlank(bitmap: Bitmap): Boolean {
if (bitmap.width <= 0 || bitmap.height <= 0) return true
val pixels = IntArray(bitmap.width * bitmap.height)
bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
val opaqueWhite = 0xFFFFFFFF.toInt()
return pixels.none { pixel -> pixel != opaqueWhite }
}
}
@@ -0,0 +1,142 @@
package de.medapp.wunddoku.signature
import de.medapp.wunddoku.media.SafePaths
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
/**
* Verwaltet die physischen Signatur-PNGs im internen App-Speicher, organisiert pro
* Dokumentation - exakt dieselbe Struktur-Idee wie
* [de.medapp.wunddoku.media.PhotoStorage]:
*
* ```
* filesDir/
* └── signatures/
* └── <documentationId>/
* └── <signatureId>.png
* ```
*
* Ersetzt den ursprünglichen Platzhalter `SignatureFileStorage`, der noch flach unter
* `filesDir/signatures/<uuid>.png` speicherte (ohne Bezug zur jeweiligen Dokumentation). Ein
* eigenes Unterverzeichnis pro Dokumentation hat denselben Vorteil wie bei Fotos: [deleteAll]
* kann beim Löschen einer kompletten Dokumentation das gesamte Verzeichnis in einem Schritt
* entfernen, statt Einzeldateien anhand von DB-Zeilen durchzugehen (Aufgabenstellung
* Abschnitt 13/18).
*
* Persistiert wird ausschließlich das fertige, bereits auf das logische 1000x400-Format
* normalisierte Signaturbild als PNG (verlustfrei) - Stroke-/Pfad-Daten sind ausschließlich
* Editor-State und werden hier nie entgegengenommen (Aufgabenstellung Abschnitt 11). Alle
* Operationen laufen über [Dispatchers.IO], nie auf dem Hauptthread.
*/
interface SignatureStorage {
/**
* Speichert [bitmap] als neue PNG-Datei im dokumentationsspezifischen Verzeichnis. Liefert
* den relativen Dateinamen (ausschließlich der documentationId, s. [resolve]) - kein
* absoluter, geräteabhängiger Pfad wird persistiert (Aufgabenstellung Abschnitt 15/20).
*/
suspend fun save(documentationId: String, bitmap: Bitmap): String
/** Löscht eine einzelne physische Signaturdatei. Wirft KEINE Exception, wenn die Datei
* nicht (mehr) existiert - analog [de.medapp.wunddoku.media.PhotoStorage.delete]. */
suspend fun delete(documentationId: String, fileName: String)
/**
* Löscht das gesamte Signaturverzeichnis einer Dokumentation samt aller enthaltenen Dateien
* in einem Schritt (analog [de.medapp.wunddoku.media.PhotoStorage.deleteAll]). Verwendet
* beim Löschen der kompletten Dokumentation - unabhängig davon, welche DB-Zeilen zu diesem
* Zeitpunkt noch bekannt sind, damit keine verwaisten PNG-Dateien zurückbleiben.
*/
suspend fun deleteAll(documentationId: String)
/** Liefert die absolute [File] für einen gespeicherten relativen Dateinamen. */
fun resolve(documentationId: String, fileName: String): File
/**
* Decodiert die gespeicherte PNG-Datei in voller Auflösung. Kein Downsampling nötig - das
* logische Signaturformat ist mit 1000x400px bereits klein (siehe Zeichenfläche), anders
* als importierte Fotos. Liefert `null`, wenn die Datei fehlt oder nicht decodiert werden
* kann - PDF-003 (Aufgabenstellung Abschnitt 31) zeigt in diesem Fall einen neutralen
* Platzhalter statt abzustürzen.
*/
suspend fun decode(documentationId: String, fileName: String): Bitmap?
}
/**
* Generische Implementierung, organisiert pro "Schlüssel" (bei dokumentationsbezogenen
* Signaturen: `documentationId`) in einem eigenen Wurzelverzeichnis [rootDirName]. Wird
* ZWEIMAL instanziiert (s. [de.medapp.wunddoku.di.AppContainer]):
*
* - einmal für die historischen, unveränderlichen Signaturen einzelner Dokumentationen
* (`rootDirName = "signatures"`, Schlüssel = documentationId),
* - einmal für das Wundversorger-Register mit jeweils nur der AKTUELLSTEN Signatur je Person
* (`rootDirName = "wound_carer_signatures"`, Schlüssel = woundCarerId) - s.
* [de.medapp.wunddoku.data.repository.WoundCarerRepository].
*
* Beide Verwendungen sind vom Namen her identisch aufgebaut (`filesDir/<rootDirName>/<key>/`),
* aber vollständig unabhängige Verzeichnisbäume - eine gelöschte Dokumentation räumt z. B. NIE
* das Wundversorger-Register mit auf, und umgekehrt.
*/
class InternalSignatureStorage(
private val context: Context,
private val rootDirName: String = "signatures",
) : SignatureStorage {
private val rootDir: File by lazy {
File(context.filesDir, rootDirName).apply { mkdirs() }
}
private fun documentationDirPath(documentationId: String): File =
SafePaths.resolveChild(rootDir, documentationId)
private fun ensureDocumentationDir(documentationId: String): File =
documentationDirPath(documentationId).apply { mkdirs() }
override fun resolve(documentationId: String, fileName: String): File =
SafePaths.resolveChild(documentationDirPath(documentationId), fileName)
override suspend fun save(documentationId: String, bitmap: Bitmap): String =
withContext(Dispatchers.IO) {
val fileName = "${UUID.randomUUID()}.png"
val targetDir = ensureDocumentationDir(documentationId)
val targetFile = File(targetDir, fileName)
try {
val written = FileOutputStream(targetFile).use { out ->
// Qualität 100 ist bei PNG (verlustfreies Format) bedeutungslos - wird trotzdem
// explizit angegeben, da Bitmap.compress() den Parameter unabhängig vom Format
// verlangt.
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
}
// compress() meldet Schreibfehler (z. B. Speicher voll) nur über `false`.
if (!written) throw java.io.IOException("Signatur konnte nicht gespeichert werden.")
} catch (e: Throwable) {
// Keine leere/halbe PNG-Datei als scheinbar gültige Signatur zurücklassen.
targetFile.delete()
throw e
}
fileName
}
override suspend fun delete(documentationId: String, fileName: String) = withContext(Dispatchers.IO) {
resolve(documentationId, fileName).delete()
Unit
}
override suspend fun deleteAll(documentationId: String) = withContext(Dispatchers.IO) {
documentationDirPath(documentationId).deleteRecursively()
Unit
}
override suspend fun decode(documentationId: String, fileName: String): Bitmap? =
withContext(Dispatchers.IO) {
val file = resolve(documentationId, fileName)
if (!file.exists()) return@withContext null
runCatching { BitmapFactory.decodeFile(file.absolutePath) }.getOrNull()
}
}
@@ -1,5 +1,6 @@
package de.medapp.wunddoku.ui.documentation.edit package de.medapp.wunddoku.ui.documentation.edit
import android.content.ActivityNotFoundException
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -7,24 +8,34 @@ import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.BrokenImage import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.PictureAsPdf
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
@@ -34,9 +45,16 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedIconButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@@ -45,25 +63,44 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.core.content.FileProvider
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import de.medapp.wunddoku.R import de.medapp.wunddoku.R
import de.medapp.wunddoku.domain.catalog.DocumentationCatalog
import de.medapp.wunddoku.domain.model.CheckItem import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.InputFieldDef
import de.medapp.wunddoku.domain.model.InputSpecCodec
import de.medapp.wunddoku.domain.model.DocumentationBlock import de.medapp.wunddoku.domain.model.DocumentationBlock
import de.medapp.wunddoku.domain.model.Photo import de.medapp.wunddoku.domain.model.Photo
import de.medapp.wunddoku.domain.model.Signature
import de.medapp.wunddoku.pdf.AndroidPdfImageSharer
import de.medapp.wunddoku.pdf.AndroidPdfOpener
import de.medapp.wunddoku.pdf.AndroidPdfSharer
import de.medapp.wunddoku.pdf.PdfImageShareResult
import de.medapp.wunddoku.pdf.PdfOpenResult
import de.medapp.wunddoku.pdf.PdfShareResult
import de.medapp.wunddoku.pdf.toUserMessage
import de.medapp.wunddoku.ui.documentation.edit.signature.AddSignatureDialog
import java.io.File
/** /**
* Bearbeitungsansicht für eine Wunddokumentation. Zeigt einen Block nach dem anderen mit * Bearbeitungsansicht für eine Wunddokumentation. Zeigt einen Block nach dem anderen mit
@@ -85,6 +122,13 @@ fun DocumentationEditScreen(
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// Vollbildansicht eines Fotos beim Antippen einer Kachel (Änderungswunsch) - rein lokaler
// UI-Zustand, bewusst nicht im ViewModel gehalten (analog zu den transient geladenen
// Thumbnail-/Signatur-Bitmaps, s. Kommentare an PhotoThumbnail/loadPhotoThumbnail): welches
// Foto gerade großformatig angezeigt wird, ist reine Präsentationslogik ohne fachliche
// Bedeutung, die einen Prozesstod überdauern müsste.
var viewedPhoto by remember { mutableStateOf<Photo?>(null) }
// Best-effort-Flush ausstehender Freitext-Änderungen, wenn der Screen den sichtbaren // Best-effort-Flush ausstehender Freitext-Änderungen, wenn der Screen den sichtbaren
// Bereich verlässt (App gewechselt, Bildschirm gesperrt, ...). KEINE Garantie bei hartem // Bereich verlässt (App gewechselt, Bildschirm gesperrt, ...). KEINE Garantie bei hartem
// Prozessabbruch, aber sinnvolle zusätzliche Absicherung für den häufigen Fall. // Prozessabbruch, aber sinnvolle zusätzliche Absicherung für den häufigen Fall.
@@ -109,15 +153,124 @@ fun DocumentationEditScreen(
contract = ActivityResultContracts.PickMultipleVisualMedia(), contract = ActivityResultContracts.PickMultipleVisualMedia(),
) { uris: List<Uri> -> viewModel.onPhotosPicked(uris) } ) { uris: List<Uri> -> viewModel.onPhotosPicked(uris) }
// Für die Erfolgs-/Fehlermeldung nach einem PDF-Exportversuch (PDF-001/002) inkl. optionaler
// "ANZEIGEN"-Aktion (PDF-005). PdfOpener wird bewusst hier in der Compose-Schicht instanziiert
// (mit dem Activity-Context aus LocalContext, analog zum Photo-Picker-Launcher oben) - das
// Starten einer externen Activity ist eine UI-/Plattform-Aktion, keine Repository-Aufgabe.
val context = LocalContext.current
val pdfOpener = remember(context) { AndroidPdfOpener(context) }
val pdfImageSharer = remember(context) { AndroidPdfImageSharer(context) }
val pdfSharer = remember(context) { AndroidPdfSharer(context) }
val snackbarHostState = remember { SnackbarHostState() }
// Kamerabutton im Fotobereich (Änderungswunsch): nimmt mehrere Fotos hintereinander auf.
// Es wird die System-Kamera-App über ACTION_IMAGE_CAPTURE (TakePicture-Contract) genutzt -
// dafür ist KEINE CAMERA-Berechtigung nötig. Nach jeder erfolgreichen Aufnahme öffnet sich
// die Kamera automatisch erneut; Abbrechen/Zurück beendet die Serie. Die Aufnahme landet
// zunächst in einer temporären Cache-Datei (FileProvider-Pfad "camera/") und wird dann wie
// eine Picker-Auswahl in die Dokumentation importiert (Downsampling, EXIF, interne Ablage).
// Der Pfad der gerade laufenden Aufnahme ist rememberSaveable, damit er einen Wechsel der
// Konfiguration (Drehen) oder Prozesstod während der Kamera-App übersteht.
var pendingCameraFilePath by rememberSaveable { mutableStateOf<String?>(null) }
var cameraRelaunchRequested by remember { mutableStateOf(false) }
val cameraLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture(),
) { success: Boolean ->
val file = pendingCameraFilePath?.let(::File)
pendingCameraFilePath = null
if (file != null && success && file.length() > 0L) {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
viewModel.onCameraPhotoCaptured(uri) { file.delete() }
// Nächste Aufnahme der Serie.
cameraRelaunchRequested = true
} else {
// Abgebrochen (oder leere Datei): Serie beendet, temporäre Datei aufräumen.
file?.delete()
}
}
fun startCamera() {
val captureDir = File(context.cacheDir, "camera").apply { mkdirs() }
val file = File.createTempFile("capture_", ".jpg", captureDir)
pendingCameraFilePath = file.absolutePath
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
try {
cameraLauncher.launch(uri)
} catch (e: ActivityNotFoundException) {
pendingCameraFilePath = null
file.delete()
viewModel.onCameraUnavailable()
}
}
LaunchedEffect(cameraRelaunchRequested) {
if (cameraRelaunchRequested) {
cameraRelaunchRequested = false
startCamera()
}
}
LaunchedEffect(uiState.pdfExportMessage) {
val message = uiState.pdfExportMessage ?: return@LaunchedEffect
val exportedFile = uiState.lastExportedPdfFile
// "ANZEIGEN" wird ausschließlich angeboten, wenn tatsächlich eine erfolgreich erzeugte
// Datei vorliegt - bei einem fehlgeschlagenen Export ist exportedFile null und die
// Snackbar zeigt keine Aktion an.
val result = snackbarHostState.showSnackbar(
message = message,
actionLabel = if (exportedFile != null) "ANZEIGEN" else null,
)
if (result == SnackbarResult.ActionPerformed && exportedFile != null) {
when (val openResult = pdfOpener.open(exportedFile)) {
PdfOpenResult.Success -> Unit
is PdfOpenResult.Failure -> snackbarHostState.showSnackbar(openResult.toUserMessage())
}
}
viewModel.dismissPdfExportMessage()
}
// Änderungswunsch: erzeugte Bilder direkt über den Android-"Senden an"-Dialog verschicken
// können. Sobald das ViewModel frische JPEG-Seiten bereitstellt (s.
// DocumentationEditViewModel.shareAsImages), öffnet dieser Effekt den System-
// Share-Dialog - Intent/Activity-Start ist bewusst UI-Aufgabe, nicht Sache des ViewModels.
LaunchedEffect(uiState.imagesToShare) {
val images = uiState.imagesToShare ?: return@LaunchedEffect
when (val shareResult = pdfImageSharer.share(images)) {
PdfImageShareResult.Success -> Unit
is PdfImageShareResult.Failure -> snackbarHostState.showSnackbar(shareResult.toUserMessage())
}
viewModel.onImageShareHandled()
}
// Analog zum Bilderversand oben, nur für die Format-Auswahl "PDF" (s.
// DocumentationEditViewModel.shareAsPdf).
LaunchedEffect(uiState.pdfToShare) {
val pdfFile = uiState.pdfToShare ?: return@LaunchedEffect
when (val shareResult = pdfSharer.share(pdfFile)) {
PdfShareResult.Success -> Unit
is PdfShareResult.Failure -> snackbarHostState.showSnackbar(shareResult.toUserMessage())
}
viewModel.onPdfShareHandled()
}
LaunchedEffect(uiState.shareMessage) {
val message = uiState.shareMessage ?: return@LaunchedEffect
snackbarHostState.showSnackbar(message)
viewModel.dismissShareMessage()
}
// Speicherfehler beim Autosave/Löschen: der Nutzer muss erfahren, dass eine Änderung NICHT
// gespeichert wurde (sonst entsteht unbemerkter Datenverlust).
LaunchedEffect(uiState.saveError) {
val message = uiState.saveError ?: return@LaunchedEffect
snackbarHostState.showSnackbar(message)
viewModel.dismissSaveError()
}
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = {
if (uiState.blockCount > 0) { if (uiState.blockCount > 0) {
Text( Text(
stringResource( stringResource(
R.string.documentation_edit_position, R.string.documentation_edit_progress,
uiState.currentBlockIndex + 1, uiState.editedBlockCount,
uiState.blockCount, uiState.blockCount,
) )
) )
@@ -126,9 +279,27 @@ fun DocumentationEditScreen(
} }
}, },
actions = { actions = {
// PDF-Export/Unterschrift folgen in einer späteren Phase. "Fertig" beendet // PDF-005 (Öffnen) ist umgesetzt (s. LaunchedEffect oben); dieser Button
// die Bearbeitung bereits jetzt (Inhalte sind ohnehin per Autosave // erzeugt die Datei im internen Speicher (PDF-001/002).
// gesichert, kein expliziter Speichern-Button nötig). if (uiState.pdfExportInProgress) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
} else {
IconButton(onClick = viewModel::exportPdf) {
Icon(Icons.Filled.PictureAsPdf, contentDescription = "PDF erzeugen")
}
}
// Änderungswunsch: PDF zusätzlich als JPEG speichern/versenden, wahlweise
// auch als PDF selbst - Format wird per Dialog abgefragt (s. weiter unten),
// BEVOR irgendetwas exportiert wird.
if (uiState.shareInProgress) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
} else {
IconButton(onClick = viewModel::requestShareFormatChoice) {
Icon(Icons.Filled.Share, contentDescription = "Teilen")
}
}
// "Fertig" beendet die Bearbeitung bereits jetzt (Inhalte sind ohnehin per
// Autosave gesichert, kein expliziter Speichern-Button nötig).
Button(onClick = onFinished) { Text("Fertig") } Button(onClick = onFinished) { Text("Fertig") }
}, },
) )
@@ -147,22 +318,20 @@ fun DocumentationEditScreen(
else -> { else -> {
val documentation = uiState.documentation val documentation = uiState.documentation
val currentBlock = documentation?.blocks?.getOrNull(uiState.currentBlockIndex) if (documentation == null || documentation.blocks.isEmpty()) {
if (documentation == null || currentBlock == null) {
Column(modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp)) { Column(modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp)) {
Text("Diese Dokumentation enthält keine Blöcke.") Text("Diese Dokumentation enthält keine Blöcke.")
} }
} else { } else {
DocumentationEditContent( DocumentationEditContent(
modifier = Modifier.fillMaxSize().padding(padding), modifier = Modifier.fillMaxSize().padding(padding),
block = currentBlock, blocks = documentation.blocks,
expandedBlockId = uiState.expandedBlockId,
onToggleBlockExpanded = viewModel::toggleBlockExpanded,
photos = documentation.photos, photos = documentation.photos,
signatures = documentation.signatures,
onCheckedChanged = viewModel::onCheckedChanged, onCheckedChanged = viewModel::onCheckedChanged,
onFreeTextChanged = viewModel::onFreeTextChanged, onFreeTextChanged = viewModel::onFreeTextChanged,
canGoBack = uiState.currentBlockIndex > 0,
canGoNext = uiState.currentBlockIndex < uiState.blockCount - 1,
onPrevious = viewModel::onPreviousBlock,
onNext = viewModel::onNextBlock,
photoImportInProgress = uiState.photoImportInProgress, photoImportInProgress = uiState.photoImportInProgress,
photoImportError = uiState.photoImportError, photoImportError = uiState.photoImportError,
onDismissPhotoImportError = viewModel::dismissPhotoImportError, onDismissPhotoImportError = viewModel::dismissPhotoImportError,
@@ -171,8 +340,13 @@ fun DocumentationEditScreen(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
) )
}, },
onTakePhotosClick = ::startCamera,
onDeletePhotoRequested = viewModel::requestDeletePhoto, onDeletePhotoRequested = viewModel::requestDeletePhoto,
loadThumbnail = viewModel::loadPhotoThumbnail, loadThumbnail = viewModel::loadPhotoThumbnail,
onPhotoClick = { viewedPhoto = it },
onAddSignatureClick = viewModel::requestAddSignature,
onDeleteSignatureRequested = viewModel::requestDeleteSignature,
loadSignatureBitmap = viewModel::loadSignatureBitmap,
) )
} }
} }
@@ -197,64 +371,216 @@ fun DocumentationEditScreen(
}, },
) )
} }
// MEDIA-003: Erfassungsdialog, ausschließlich innerhalb dieses Screens (keine eigene
// Navigationsroute, s. Aufgabenstellung Abschnitt 7).
if (uiState.showAddSignatureDialog) {
AddSignatureDialog(
onDismiss = viewModel::dismissAddSignature,
onSave = viewModel::saveSignature,
loadDefaultWoundCarer = viewModel::getDefaultWoundCarer,
searchWoundCarers = viewModel::searchWoundCarers,
loadWoundCarerSignatureBitmap = viewModel::loadWoundCarerSignatureBitmap,
)
}
// Zweite, von der UI-Button-Deaktivierung unabhängige Absicherungsebene (Aufgabenstellung
// Abschnitt 12) - greift in der Praxis kaum, da der Dialog "Speichern" bereits vorher
// deaktiviert, solange Name oder Zeichnung leer sind.
val signatureError = uiState.signatureError
if (signatureError != null) {
AlertDialog(
onDismissRequest = viewModel::dismissSignatureError,
title = { Text(stringResource(R.string.documentation_edit_signatures_title)) },
text = { Text(signatureError) },
confirmButton = {
TextButton(onClick = viewModel::dismissSignatureError) {
Text(stringResource(R.string.action_confirm))
}
},
)
}
val pendingDeleteSignatureId = uiState.pendingDeleteSignatureId
if (pendingDeleteSignatureId != null) {
AlertDialog(
onDismissRequest = viewModel::dismissDeleteSignature,
title = { Text(stringResource(R.string.documentation_edit_delete_signature_confirm_title)) },
text = { Text(stringResource(R.string.documentation_edit_delete_signature_confirm_text)) },
confirmButton = {
TextButton(onClick = viewModel::confirmDeleteSignature) {
Text(stringResource(R.string.action_confirm))
}
},
dismissButton = {
TextButton(onClick = viewModel::dismissDeleteSignature) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
// Änderungswunsch: Foto in Originalgröße anzeigen.
val photoToView = viewedPhoto
if (photoToView != null) {
PhotoViewerDialog(
photo = photoToView,
loadFullSize = viewModel::loadPhotoFullSize,
onDismiss = { viewedPhoto = null },
)
}
// Änderungswunsch: Format-Auswahl beim Antippen des Teilen-Buttons.
if (uiState.showShareFormatDialog) {
ShareFormatDialog(
onDismiss = viewModel::dismissShareFormatDialog,
onChooseImages = viewModel::shareAsImages,
onChoosePdf = viewModel::shareAsPdf,
)
}
} }
/** /**
* Gesamter scrollbarer Inhalt der Dokumentationsbearbeitung: aktueller Block (Titel, Optionen, * Fragt beim Antippen des Teilen-Buttons ab, ob als JPG (Bilder, eine Datei pro Seite) oder als
* Freitext-Option, Vor-/Zurück-Navigation) gefolgt vom dokumentationsweiten Fotobereich. Als * PDF (die Datei selbst) geteilt werden soll (Änderungswunsch). Erst NACH dieser Auswahl wird
* überhaupt exportiert (s. [DocumentationEditViewModel.shareAsImages]/[DocumentationEditViewModel.shareAsPdf]) -
* kein unnötiger Export, falls der Nutzer den Dialog stattdessen abbricht.
*/
@Composable
private fun ShareFormatDialog(
onDismiss: () -> Unit,
onChooseImages: () -> Unit,
onChoosePdf: () -> Unit,
) {
Dialog(onDismissRequest = onDismiss) {
Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surface) {
Column(modifier = Modifier.fillMaxWidth().padding(20.dp)) {
Text("Teilen als", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(
"Als Bilder (JPG) oder als PDF teilen?",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
Spacer(Modifier.width(8.dp))
OutlinedButton(onClick = onChooseImages) { Text("JPG") }
Spacer(Modifier.width(8.dp))
Button(onClick = onChoosePdf) { Text("PDF") }
}
}
}
}
}
/**
* Gesamter scrollbarer Inhalt der Dokumentationsbearbeitung: Akkordeon aller Blöcke
* (Änderungswunsch) - zunächst stehen nur die Überschriften untereinander, Antippen einer
* Überschrift klappt genau diesen Block auf und schließt dabei automatisch einen zuvor
* aufgeklappten (s. [DocumentationEditViewModel.toggleBlockExpanded] - IMMER höchstens ein
* Block gleichzeitig offen) - gefolgt vom dokumentationsweiten Signatur- und Fotobereich. Als
* einzelne [LazyColumn] umgesetzt, damit Foto-Zeilen erst decodiert werden, wenn sie sichtbar * einzelne [LazyColumn] umgesetzt, damit Foto-Zeilen erst decodiert werden, wenn sie sichtbar
* sind/werden. * sind/werden.
*/ */
@Composable @Composable
private fun DocumentationEditContent( private fun DocumentationEditContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
block: DocumentationBlock, blocks: List<DocumentationBlock>,
expandedBlockId: String?,
onToggleBlockExpanded: (blockStateId: String) -> Unit,
photos: List<Photo>, photos: List<Photo>,
signatures: List<Signature>,
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit, onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit, onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
canGoBack: Boolean,
canGoNext: Boolean,
onPrevious: () -> Unit,
onNext: () -> Unit,
photoImportInProgress: Boolean, photoImportInProgress: Boolean,
photoImportError: String?, photoImportError: String?,
onDismissPhotoImportError: () -> Unit, onDismissPhotoImportError: () -> Unit,
onAddPhotosClick: () -> Unit, onAddPhotosClick: () -> Unit,
onTakePhotosClick: () -> Unit,
onDeletePhotoRequested: (photoId: String) -> Unit, onDeletePhotoRequested: (photoId: String) -> Unit,
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?, loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
onPhotoClick: (Photo) -> Unit,
onAddSignatureClick: () -> Unit,
onDeleteSignatureRequested: (signatureId: String) -> Unit,
loadSignatureBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?,
) { ) {
val listState = rememberLazyListState()
// Änderungswunsch: Beim Aufklappen eines Blocks dorthin scrollen, damit sein erster Eintrag
// sichtbar wird - wichtig bei langen Formularen, wenn ein weiter unten liegender Block
// aufgeklappt wird und sein Inhalt sonst unterhalb des sichtbaren Bereichs läge. Reagiert
// NUR auf tatsächliches Aufklappen (expandedBlockId wechselt zu einer konkreten Block-ID),
// nicht auf das Zuklappen (expandedBlockId wird null) - dort gibt es nichts, wohin gescrollt
// werden müsste. Da jeder Block GENAU EIN LazyColumn-Element ist (Kopfzeile + bei Bedarf
// aufgeklappter Inhalt gemeinsam, s. [BlockAccordionItem]) und die Block-Liste die ERSTEN
// Elemente der LazyColumn sind, landet der Block-Index 1:1 auf dem passenden
// LazyColumn-Index - der Block erscheint dadurch ganz oben im sichtbaren Bereich, sein
// erster Eintrag direkt darunter.
LaunchedEffect(expandedBlockId) {
val targetIndex = blocks.indexOfFirst { it.blockStateId == expandedBlockId }
if (targetIndex >= 0) {
listState.animateScrollToItem(targetIndex)
}
}
LazyColumn( LazyColumn(
modifier = modifier, modifier = modifier,
state = listState,
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
item(key = "block_title") { items(blocks, key = { it.blockStateId }) { block ->
Text(block.title, style = MaterialTheme.typography.headlineSmall) BlockAccordionItem(
} block = block,
expanded = block.blockStateId == expandedBlockId,
items(block.items, key = { it.checkItemStateId }) { item -> onHeaderClick = { onToggleBlockExpanded(block.blockStateId) },
if (item.isFreeText) {
FreeTextCheckItemRow(
item = item,
onCheckedChanged = onCheckedChanged, onCheckedChanged = onCheckedChanged,
onFreeTextChanged = onFreeTextChanged, onFreeTextChanged = onFreeTextChanged,
) )
} else { }
CheckItemRow(item = item, onCheckedChanged = onCheckedChanged)
// MEDIA-003/004: dokumentationsweiter Signaturbereich, analog zum Fotobereich direkt
// darunter. Bewusst VOR den Fotos platziert - spiegelt dieselbe Reihenfolge wie im
// späteren PDF-Export (Dokumentationsblöcke -> Signaturen -> Fotoanhang, PDF-003).
item(key = "signatures_header") {
Column {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
stringResource(R.string.documentation_edit_signatures_title),
style = MaterialTheme.typography.titleMedium,
)
OutlinedButton(onClick = onAddSignatureClick) {
Icon(Icons.Filled.Add, contentDescription = null)
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.documentation_edit_add_signature))
}
}
} }
} }
item(key = "block_navigation") { if (signatures.isEmpty()) {
Row( item(key = "signatures_empty") {
modifier = Modifier.fillMaxWidth().padding(top = 4.dp), Text(
horizontalArrangement = Arrangement.SpaceBetween, stringResource(R.string.documentation_edit_no_signatures),
) { style = MaterialTheme.typography.bodySmall,
OutlinedButton(onClick = onPrevious, enabled = canGoBack) { )
Text(stringResource(R.string.documentation_edit_previous))
}
Button(onClick = onNext, enabled = canGoNext) {
Text(stringResource(R.string.documentation_edit_next))
} }
} else {
items(signatures, key = { it.id }) { signature ->
SignatureRow(
signature = signature,
loadBitmap = loadSignatureBitmap,
onDeleteRequested = { onDeleteSignatureRequested(signature.id) },
)
} }
} }
@@ -271,6 +597,10 @@ private fun DocumentationEditContent(
if (photoImportInProgress) { if (photoImportInProgress) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} }
// Kamera: mehrere Aufnahmen hintereinander (s. startCamera).
OutlinedIconButton(onClick = onTakePhotosClick) {
Icon(Icons.Filled.PhotoCamera, contentDescription = "Fotos aufnehmen")
}
OutlinedButton(onClick = onAddPhotosClick, enabled = !photoImportInProgress) { OutlinedButton(onClick = onAddPhotosClick, enabled = !photoImportInProgress) {
Icon(Icons.Filled.Add, contentDescription = null) Icon(Icons.Filled.Add, contentDescription = null)
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
@@ -308,7 +638,12 @@ private fun DocumentationEditContent(
} }
} else { } else {
items(photos.chunked(PHOTO_GRID_COLUMNS), key = { row -> row.joinToString(separator = "_") { it.id } }) { row -> items(photos.chunked(PHOTO_GRID_COLUMNS), key = { row -> row.joinToString(separator = "_") { it.id } }) { row ->
PhotoRow(photos = row, loadThumbnail = loadThumbnail, onDeleteRequested = onDeletePhotoRequested) PhotoRow(
photos = row,
loadThumbnail = loadThumbnail,
onDeleteRequested = onDeletePhotoRequested,
onPhotoClick = onPhotoClick,
)
} }
} }
} }
@@ -321,6 +656,7 @@ private fun PhotoRow(
photos: List<Photo>, photos: List<Photo>,
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?, loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
onDeleteRequested: (photoId: String) -> Unit, onDeleteRequested: (photoId: String) -> Unit,
onPhotoClick: (Photo) -> Unit,
) { ) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
photos.forEach { photo -> photos.forEach { photo ->
@@ -328,6 +664,7 @@ private fun PhotoRow(
photo = photo, photo = photo,
loadThumbnail = loadThumbnail, loadThumbnail = loadThumbnail,
onDeleteRequested = { onDeleteRequested(photo.id) }, onDeleteRequested = { onDeleteRequested(photo.id) },
onClick = { onPhotoClick(photo) },
) )
} }
} }
@@ -340,12 +677,17 @@ private fun PhotoRow(
* (siehe Kommentar an [DocumentationEditViewModel.loadPhotoThumbnail]). Verlässt die Kachel die * (siehe Kommentar an [DocumentationEditViewModel.loadPhotoThumbnail]). Verlässt die Kachel die
* Komposition (z. B. weil die Dokumentation viele Fotos hat und diese Zeile aktuell nicht * Komposition (z. B. weil die Dokumentation viele Fotos hat und diese Zeile aktuell nicht
* sichtbar ist), wird der Zustand automatisch freigegeben. * sichtbar ist), wird der Zustand automatisch freigegeben.
*
* Ein Antippen der Kachel (außerhalb des Löschen-Buttons) öffnet die Vollbildansicht
* (Änderungswunsch) - [onClick] statt eines eigenen Ladevorgangs hier, da die Kachel selbst nur
* das kleine, bereits geladene Thumbnail kennt.
*/ */
@Composable @Composable
private fun PhotoThumbnail( private fun PhotoThumbnail(
photo: Photo, photo: Photo,
loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?, loadThumbnail: suspend (documentationId: String, fileName: String) -> Bitmap?,
onDeleteRequested: () -> Unit, onDeleteRequested: () -> Unit,
onClick: () -> Unit,
) { ) {
var bitmap by remember(photo.id) { mutableStateOf<Bitmap?>(null) } var bitmap by remember(photo.id) { mutableStateOf<Bitmap?>(null) }
var loadFailed by remember(photo.id) { mutableStateOf(false) } var loadFailed by remember(photo.id) { mutableStateOf(false) }
@@ -360,7 +702,8 @@ private fun PhotoThumbnail(
modifier = Modifier modifier = Modifier
.size(96.dp) .size(96.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant), .background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(enabled = bitmap != null, onClick = onClick),
) { ) {
val currentBitmap = bitmap val currentBitmap = bitmap
when { when {
@@ -396,6 +739,150 @@ private fun PhotoThumbnail(
} }
} }
/**
* Eine Zeile je gespeicherter Signatur: Name des Wundversorgers, Vorschaubild und
* Löschen-Button - sinngemäß wie in der Aufgabenstellung (Abschnitt 16) skizziert. Lädt ihre
* Vorschau selbst, transient über [LaunchedEffect]/`remember` (an `signature.id` gebunden) -
* analog [PhotoThumbnail], siehe dortigen Kommentar zur bewussten Nicht-Haltung im ViewModel.
* Da Signaturen nach dem Speichern unveränderlich sind (Aufgabenstellung Abschnitt 17), gibt es
* hier bewusst KEINE Bearbeiten-Aktion - nur Anzeigen und Löschen.
*/
@Composable
private fun SignatureRow(
signature: Signature,
loadBitmap: suspend (documentationId: String, fileName: String) -> Bitmap?,
onDeleteRequested: () -> Unit,
) {
var bitmap by remember(signature.id) { mutableStateOf<Bitmap?>(null) }
var loadFailed by remember(signature.id) { mutableStateOf(false) }
LaunchedEffect(signature.id) {
val result = runCatching { loadBitmap(signature.documentationId, signature.fileName) }.getOrNull()
bitmap = result
loadFailed = result == null
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.width(96.dp)
.size(96.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.White),
) {
val currentBitmap = bitmap
when {
currentBitmap != null -> Image(
bitmap = currentBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
)
loadFailed -> Icon(
Icons.Filled.BrokenImage,
contentDescription = null,
modifier = Modifier.align(Alignment.Center),
)
else -> CircularProgressIndicator(
modifier = Modifier.size(24.dp).align(Alignment.Center),
strokeWidth = 2.dp,
)
}
}
Text(
text = signature.signerName,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onDeleteRequested) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.documentation_action_delete),
)
}
}
}
/**
* Ein einzelner Block im Akkordeon (Änderungswunsch): eine antippbare Kopfzeile mit Titel,
* einem kleinen "bearbeitet"-Indikator (grüner Haken, s. [DocumentationBlock.isEdited]) und
* einem Pfeil-Icon, das den Auf-/Zugeklappt-Zustand anzeigt. Der eigentliche Inhalt (Checkboxen
* inkl. Freitext-Option) wird ausschließlich gerendert, wenn [expanded] `true` ist - das
* entspricht fachlich einem Dropdown/Expansion-Panel, technisch einfach bedingtes Komponieren
* ohne eigene Lazy-Liste (ein Block hat nur wenige Optionen).
*/
@Composable
private fun BlockAccordionItem(
block: DocumentationBlock,
expanded: Boolean,
onHeaderClick: () -> Unit,
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onHeaderClick)
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (block.isEdited) {
Icon(
Icons.Filled.CheckCircle,
contentDescription = stringResource(R.string.documentation_edit_block_edited),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(8.dp))
}
Text(
block.title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
Icon(
if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = null,
)
}
if (expanded) {
Column(
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
for (item in block.items) {
when {
item.isStaticHint -> StaticHintRow(item = item)
item.inputSpec.isNotEmpty() -> StructuredInputRow(
item = item,
onCheckedChanged = onCheckedChanged,
onFreeTextChanged = onFreeTextChanged,
)
item.isFreeText -> FreeTextCheckItemRow(
item = item,
onCheckedChanged = onCheckedChanged,
onFreeTextChanged = onFreeTextChanged,
)
else -> CheckItemRow(item = item, onCheckedChanged = onCheckedChanged)
}
}
}
}
HorizontalDivider()
}
}
@Composable @Composable
private fun CheckItemRow( private fun CheckItemRow(
item: CheckItem, item: CheckItem,
@@ -421,7 +908,79 @@ private fun FreeTextCheckItemRow(
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit, onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit, onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
) { ) {
// Änderungswunsch: Ist ein generischer "Freitext"-Eintrag angehakt, wird der Schriftzug
// "Freitext" nicht mehr angezeigt (nur Häkchenkästchen + Eingabefeld). Fachlich benannte
// Freitext-Einträge (z. B. "Wundrandschutz", "Wechselintervall") behalten ihre Bezeichnung.
val hideName = item.checked && item.label == DocumentationCatalog.FREE_TEXT_LABEL
val fieldLabel: (@Composable () -> Unit)? =
if (hideName) null else ({ Text(stringResource(R.string.documentation_edit_free_text_label)) })
Column { Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = item.checked,
onCheckedChange = { checked -> onCheckedChanged(item.checkItemStateId, checked) },
)
if (!hideName) Text(item.label)
}
OutlinedTextField(
value = item.freeTextValue,
onValueChange = { text -> onFreeTextChanged(item.checkItemStateId, text) },
enabled = item.checked,
label = fieldLabel,
modifier = Modifier.fillMaxWidth().padding(start = 40.dp),
minLines = 2,
)
}
}
/**
* Ein rein anzeigender, nicht editierbarer Hinweistext (z. B. "+ bei Bedarf" bei "Empfohlene
* Wundversorgung") - keine Checkbox, keine Interaktion, [item.label] ist direkt der
* anzuzeigende Text selbst. Optisch dezent (kursiv, gedämpfte Farbe) abgesetzt von echten,
* ausfüllbaren Optionen, damit auf den ersten Blick klar ist, dass hier nichts einzugeben ist.
*/
@Composable
private fun StaticHintRow(item: CheckItem) {
Text(
item.label,
style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
/**
* Ein Eintrag mit strukturierten Eingabefeldern statt eines einfachen Häkchens (Änderungswunsch,
* z. B. "Von [ ] bis [ ] Uhr" oder "Länge/Breite/Tiefe") - s. [de.medapp.wunddoku.domain.model.InputSpec].
* Anders als bei [FreeTextCheckItemRow] gibt es hier KEINE eigene Checkbox zum Freischalten: die
* Felder sind immer direkt ausfüllbar, `checked` wird automatisch gesetzt, sobald mindestens ein
* Feld einen Wert hat - dieselbe Checkbox-Semantik wie überall sonst (BlockEditedPolicy, PDF-
* Filter), nur ohne zusätzlichen Tap zum "Freischalten" der Felder.
*/
@Composable
private fun StructuredInputRow(
item: CheckItem,
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
) {
val spec = remember(item.inputSpec) { InputSpecCodec.decode(item.inputSpec) } ?: return
val values = remember(item.freeTextValue, spec) { spec.decodeValues(item.freeTextValue) }
// Eintrag mit eigener Checkbox (z. B. "Wie oft pro Woche"): Felder nur bei angehakter
// Checkbox aktiv, das Häkchen setzt allein der Nutzer. Sonst bisheriges Verhalten.
val ownCheckbox = spec.requiresOwnCheckbox
val fieldsEnabled = !ownCheckbox || item.checked
fun updateValue(fieldIndex: Int, newValue: String) {
val newValues = values.toMutableList().apply { this[fieldIndex] = newValue }
val encoded = spec.encodeValues(newValues)
onFreeTextChanged(item.checkItemStateId, encoded)
if (!ownCheckbox) {
onCheckedChanged(item.checkItemStateId, spec.hasAnyValue(encoded))
}
}
Column(modifier = Modifier.fillMaxWidth()) {
if (ownCheckbox) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox( Checkbox(
checked = item.checked, checked = item.checked,
@@ -429,13 +988,79 @@ private fun FreeTextCheckItemRow(
) )
Text(item.label) Text(item.label)
} }
OutlinedTextField( } else {
value = item.freeTextValue, Text(item.label, style = MaterialTheme.typography.bodyMedium)
onValueChange = { text -> onFreeTextChanged(item.checkItemStateId, text) }, }
enabled = item.checked, Spacer(Modifier.height(4.dp))
label = { Text(stringResource(R.string.documentation_edit_free_text_label)) }, Row(
modifier = Modifier.fillMaxWidth().padding(start = 40.dp), modifier = Modifier.fillMaxWidth(),
minLines = 2, horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
spec.fields.forEachIndexed { index, field ->
Box(modifier = Modifier.weight(1f)) {
when (field) {
is InputFieldDef.Number -> OutlinedTextField(
value = values.getOrElse(index) { "" },
onValueChange = { raw ->
var seenComma = false
val filtered = buildString {
for (c in raw) {
when {
c.isDigit() -> append(c)
field.allowDecimal && (c == ',' || c == '.') && !seenComma -> {
append(',')
seenComma = true
}
}
}
}.take(field.maxLength)
updateValue(index, filtered)
},
label = { Text(field.label) },
singleLine = true,
enabled = fieldsEnabled,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
is InputFieldDef.Choice -> ChoiceField(
field = field,
selected = values.getOrElse(index) { "" },
enabled = fieldsEnabled,
onSelected = { updateValue(index, it) },
) )
} }
}
}
}
}
}
/** Einfache Auswahlliste (Tippen öffnet ein Dropdown) für [InputFieldDef.Choice]-Felder - bewusst
* ein schlichter Button + [DropdownMenu] statt `ExposedDropdownMenuBox`, um ohne zusätzliche
* experimentelle Material3-APIs auszukommen. */
@Composable
private fun ChoiceField(
field: InputFieldDef.Choice,
selected: String,
enabled: Boolean = true,
onSelected: (String) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedButton(onClick = { expanded = true }, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
Text(selected.ifBlank { field.label }, maxLines = 1)
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
field.options.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
onSelected(option)
expanded = false
},
)
}
}
}
} }
@@ -1,5 +1,6 @@
package de.medapp.wunddoku.ui.documentation.edit package de.medapp.wunddoku.ui.documentation.edit
import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.createSavedStateHandle import androidx.lifecycle.createSavedStateHandle
@@ -8,9 +9,17 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import androidx.navigation.toRoute import androidx.navigation.toRoute
import de.medapp.wunddoku.data.repository.DocumentationRepository import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.data.repository.WoundCarerRepository
import de.medapp.wunddoku.di.wunddokuApplication import de.medapp.wunddoku.di.wunddokuApplication
import de.medapp.wunddoku.domain.error.FailureCategory
import de.medapp.wunddoku.domain.error.runCatchingCancellable
import de.medapp.wunddoku.domain.model.Documentation import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.WoundCarer
import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.pdf.toUserMessage
import de.medapp.wunddoku.ui.navigation.Screen import de.medapp.wunddoku.ui.navigation.Screen
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -19,10 +28,18 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
import java.util.concurrent.atomic.AtomicInteger
data class DocumentationEditUiState( data class DocumentationEditUiState(
val documentation: Documentation? = null, val documentation: Documentation? = null,
val currentBlockIndex: Int = 0, /** ID des BlockState, der gerade im Akkordeon aufgeklappt ist - `null`, wenn keiner
* aufgeklappt ist (Änderungswunsch: zunächst nur alle Überschriften untereinander, Antippen
* einer Überschrift klappt sie auf). Es ist IMMER höchstens ein Block gleichzeitig
* aufgeklappt, s. [DocumentationEditViewModel.toggleBlockExpanded]. */
val expandedBlockId: String? = null,
val isLoading: Boolean = true, val isLoading: Boolean = true,
val error: String? = null, val error: String? = null,
/** true, während ausgewählte Fotos importiert werden (z. B. für einen Ladeindikator). */ /** true, während ausgewählte Fotos importiert werden (z. B. für einen Ladeindikator). */
@@ -32,8 +49,62 @@ data class DocumentationEditUiState(
val photoImportError: String? = null, val photoImportError: String? = null,
/** != null, solange die Löschbestätigung für ein Foto angezeigt werden soll. */ /** != null, solange die Löschbestätigung für ein Foto angezeigt werden soll. */
val pendingDeletePhotoId: String? = null, val pendingDeletePhotoId: String? = null,
/** true, solange der "Signatur hinzufügen"-Dialog angezeigt wird (MEDIA-003). */
val showAddSignatureDialog: Boolean = false,
/** Fehlermeldung aus einer fehlgeschlagenen Signatur-Validierung (zweite, von der UI
* unabhängige Absicherungsebene in [DocumentationRepository.saveSignature] - im Normalfall
* verhindert bereits der deaktivierte "Speichern"-Button im Dialog diesen Fall). */
val signatureError: String? = null,
/** != null, solange die Löschbestätigung für eine Signatur angezeigt werden soll. */
val pendingDeleteSignatureId: String? = null,
/** true, während ein PDF-Export läuft (Ladeindikator, Button währenddessen deaktiviert). */
val pdfExportInProgress: Boolean = false,
/** Kurze, fachlich übersetzte Erfolgs-/Fehlermeldung nach einem Exportversuch - einmalig als
* Snackbar angezeigt (s. Screen), danach über [DocumentationEditViewModel.dismissPdfExportMessage] geleert. */
val pdfExportMessage: String? = null,
/**
* Die zuletzt erfolgreich erzeugte PDF-Datei dieser Dokumentation (PDF-005: "ANZEIGEN" in
* der Snackbar). `null` sowohl vor dem ersten Export als auch nach einem fehlgeschlagenen
* Export - "ANZEIGEN" wird nur angeboten, wenn hier tatsächlich eine Datei vorliegt. Wird
* bei jedem neuen Exportversuch zunächst zurückgesetzt, damit niemals eine veraltete Datei
* referenziert werden kann, während ein neuer Export läuft.
*/
val lastExportedPdfFile: File? = null,
/** true, während PDF-Export + Konvertierung für den Versand laufen - gemeinsamer
* Ladezustand für BEIDE Formate (JPG über [DocumentationEditViewModel.shareAsImages],
* PDF über [DocumentationEditViewModel.shareAsPdf]), da immer zuerst frisch exportiert
* wird. */
val shareInProgress: Boolean = false,
/** Fehlermeldung, falls Export oder Konvertierung fehlgeschlagen sind - ein erfolgreicher
* Export löst dagegen KEINE Meldung aus, sondern direkt [imagesToShare] bzw. [pdfToShare]. */
val shareMessage: String? = null,
/** true, solange die Auswahl "Als JPG oder PDF teilen?" angezeigt wird (Änderungswunsch:
* Format-Auswahl beim Antippen des Teilen-Buttons, bevor irgendetwas exportiert wird). */
val showShareFormatDialog: Boolean = false,
/**
* Frisch erzeugte JPEG-Seiten, bereit zum Teilen (Änderungswunsch: "alle generierten Bilder
* über 'Senden an' direkt verschicken können"). Wird vom Screen per LaunchedEffect
* beobachtet, der daraufhin den System-"Senden an"-Dialog öffnet und anschließend
* [DocumentationEditViewModel.onImageShareHandled] aufruft - das Teilen selbst (Intent/
* Activity-Start) ist bewusst UI-Aufgabe, nicht Sache des ViewModels.
*/
val imagesToShare: List<File>? = null,
/** Analog [imagesToShare], nur für die Variante "als PDF teilen" - Gegenstück
* [DocumentationEditViewModel.onPdfShareHandled]. */
val pdfToShare: File? = null,
/** Einmalige Fehlermeldung, wenn eine Änderung (Haken, Freitext, Löschen) NICHT in der
* Datenbank gespeichert werden konnte - z. B. bei vollem Speicher. Die Anzeige zeigt dann
* wieder den tatsächlich gespeicherten Stand, nie einen nur scheinbar gespeicherten. */
val saveError: String? = null,
) { ) {
val blockCount: Int get() = documentation?.blocks?.size ?: 0 val blockCount: Int get() = documentation?.blocks?.size ?: 0
/** Anzahl bereits bearbeiteter Blöcke (s. [de.medapp.wunddoku.domain.usecase.BlockEditedPolicy])
* - ersetzt die frühere "X / Y"-Positionsanzeige, die sich auf den einzelnen, gerade
* angezeigten Block bezog; im Akkordeon-Layout sind ALLE Blöcke gleichzeitig sichtbar
* (nur der Inhalt ist ein-/ausklappbar), daher ist ein Fortschritt sinnvoller als eine
* Position. */
val editedBlockCount: Int get() = documentation?.blocks?.count { it.isEdited } ?: 0
} }
/** /**
@@ -52,8 +123,13 @@ data class DocumentationEditUiState(
class DocumentationEditViewModel( class DocumentationEditViewModel(
private val documentationId: String, private val documentationId: String,
private val repository: DocumentationRepository, private val repository: DocumentationRepository,
private val woundCarerRepository: WoundCarerRepository,
) : ViewModel() { ) : ViewModel() {
/** Serialisiert Fotoimporte (s. [importPhotos]) und zählt laufende Importe für den Ladeindikator. */
private val photoImportMutex = Mutex()
private val pendingPhotoImports = AtomicInteger(0)
private val _uiState = MutableStateFlow(DocumentationEditUiState()) private val _uiState = MutableStateFlow(DocumentationEditUiState())
val uiState: StateFlow<DocumentationEditUiState> = _uiState.asStateFlow() val uiState: StateFlow<DocumentationEditUiState> = _uiState.asStateFlow()
@@ -70,7 +146,14 @@ class DocumentationEditViewModel(
private suspend fun loadDocumentation() { private suspend fun loadDocumentation() {
_uiState.update { it.copy(isLoading = true, error = null) } _uiState.update { it.copy(isLoading = true, error = null) }
val documentation = repository.getFullDocumentation(documentationId) val documentation = try {
repository.getFullDocumentation(documentationId)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, error = "Dokumentation konnte nicht geladen werden.") }
return
}
_uiState.update { _uiState.update {
if (documentation != null) { if (documentation != null) {
it.copy(documentation = documentation, isLoading = false) it.copy(documentation = documentation, isLoading = false)
@@ -90,11 +173,48 @@ class DocumentationEditViewModel(
} }
private suspend fun persistFreeText(checkItemStateId: String, text: String) { private suspend fun persistFreeText(checkItemStateId: String, text: String) {
repository.updateFreeText(checkItemStateId, text) // Fehler (z. B. voller Speicher) dürfen weder die Debounce-Schleife beenden noch die App
// abstürzen lassen. Der Wert bleibt in [pendingFreeText] und wird beim nächsten Flush
// (ON_STOP) bzw. bei der nächsten Eingabe erneut geschrieben - kein stiller Datenverlust.
val result = runCatchingCancellable { repository.updateFreeText(checkItemStateId, text) }
if (result.isFailure) {
reportSaveFailure(result.exceptionOrNull())
return
}
// Nur entfernen, wenn zwischenzeitlich kein neuerer Wert eingetroffen ist. // Nur entfernen, wenn zwischenzeitlich kein neuerer Wert eingetroffen ist.
if (pendingFreeText[checkItemStateId] == text) pendingFreeText.remove(checkItemStateId) if (pendingFreeText[checkItemStateId] == text) pendingFreeText.remove(checkItemStateId)
} }
private fun reportSaveFailure(cause: Throwable?, fallback: String = "Die Änderung konnte nicht gespeichert werden.") {
val message = when (cause?.let { FailureCategory.of(it) }) {
FailureCategory.OUT_OF_STORAGE -> "Nicht genügend Speicherplatz - die Änderung konnte nicht gespeichert werden."
FailureCategory.OUT_OF_MEMORY -> "Nicht genügend Arbeitsspeicher - die Änderung konnte nicht gespeichert werden."
else -> fallback
}
_uiState.update { it.copy(saveError = message) }
}
/** Lädt den tatsächlich gespeicherten Stand neu (best effort), z. B. nach einem
* fehlgeschlagenen Schreibvorgang, damit die Anzeige nie vom Datenbankstand abweicht. */
private suspend fun refreshFromDatabase() {
val refreshed = runCatchingCancellable { repository.getFullDocumentation(documentationId) }.getOrNull()
if (refreshed != null) {
// Noch nicht gespeicherte Freitexte (pendingFreeText) bleiben in der Anzeige erhalten.
val merged = if (pendingFreeText.isEmpty()) refreshed else refreshed.copy(
blocks = refreshed.blocks.map { block ->
block.copy(items = block.items.map { item ->
pendingFreeText[item.checkItemStateId]?.let { item.copy(freeTextValue = it) } ?: item
})
},
)
_uiState.update { it.copy(documentation = merged) }
}
}
fun dismissSaveError() {
_uiState.update { it.copy(saveError = null) }
}
/** /**
* [checkItemStateId] referenziert die Freitext-Option eines Blocks (CheckItem.isFreeText = * [checkItemStateId] referenziert die Freitext-Option eines Blocks (CheckItem.isFreeText =
* true), NICHT den Block selbst - der Freitext ist seit der Umstellung auf eine * true), NICHT den Block selbst - der Freitext ist seit der Umstellung auf eine
@@ -132,20 +252,26 @@ class DocumentationEditViewModel(
state.copy(documentation = documentation.copy(blocks = updatedBlocks)) state.copy(documentation = documentation.copy(blocks = updatedBlocks))
} }
// 2) sofort persistieren (kein Debounce nötig, einzelnes günstiges UPDATE) // 2) sofort persistieren (kein Debounce nötig, einzelnes günstiges UPDATE)
viewModelScope.launch { repository.setChecked(checkItemStateId, checked) } viewModelScope.launch {
val result = runCatchingCancellable { repository.setChecked(checkItemStateId, checked) }
if (result.isFailure) {
reportSaveFailure(result.exceptionOrNull())
refreshFromDatabase()
} }
fun onNextBlock() {
_uiState.update { state ->
val lastIndex = state.blockCount - 1
if (lastIndex < 0) return@update state
state.copy(currentBlockIndex = (state.currentBlockIndex + 1).coerceAtMost(lastIndex))
} }
} }
fun onPreviousBlock() { /**
* Klappt den Block mit [blockStateId] auf bzw. zu (Änderungswunsch: Akkordeon statt
* Vor-/Zurück-Navigation). Antippen eines bereits aufgeklappten Blocks klappt ihn zu;
* Antippen eines anderen Blocks klappt diesen auf und ersetzt dabei automatisch einen zuvor
* aufgeklappten Block - es ist also IMMER höchstens ein Block gleichzeitig aufgeklappt,
* ganz ohne zusätzliche Schließen-Logik für den vorherigen.
*/
fun toggleBlockExpanded(blockStateId: String) {
_uiState.update { state -> _uiState.update { state ->
state.copy(currentBlockIndex = (state.currentBlockIndex - 1).coerceAtLeast(0)) val newExpandedId = if (state.expandedBlockId == blockStateId) null else blockStateId
state.copy(expandedBlockId = newExpandedId)
} }
} }
@@ -161,36 +287,88 @@ class DocumentationEditViewModel(
* UI konsistent den tatsächlichen Datenbankstand zeigt. * UI konsistent den tatsächlichen Datenbankstand zeigt.
*/ */
fun onPhotosPicked(uris: List<Uri>) { fun onPhotosPicked(uris: List<Uri>) {
if (uris.isEmpty()) return importPhotos(uris, cleanup = {})
}
/**
* Übernimmt ein mit der Kamera aufgenommenes Foto (Kamerabutton im Fotobereich, s.
* DocumentationEditScreen). Läuft über denselben Importweg wie die Photo-Picker-Auswahl
* (Downsampling, EXIF-Korrektur, interne Ablage). [cleanup] löscht anschließend die
* temporäre Aufnahmedatei im Cache - unabhängig davon, ob der Import gelungen ist.
*/
fun onCameraPhotoCaptured(uri: Uri, cleanup: () -> Unit) {
importPhotos(listOf(uri), cleanup)
}
/**
* Gemeinsamer Importweg für Picker-Auswahl und Kameraaufnahmen. Importe laufen über einen
* [photoImportMutex] strikt nacheinander: Bei einer Kameraserie kann die nächste Aufnahme
* eintreffen, während die vorige noch importiert wird - ohne Sperre könnten zwei Importe
* dieselbe nächste orderIndex vergeben.
*/
private fun importPhotos(uris: List<Uri>, cleanup: () -> Unit) {
if (uris.isEmpty()) {
runCatching { cleanup() }
return
}
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(photoImportInProgress = true, photoImportError = null) } _uiState.update { it.copy(photoImportInProgress = true, photoImportError = null) }
pendingPhotoImports.incrementAndGet()
try {
photoImportMutex.withLock {
var failureCount = 0 var failureCount = 0
var outOfMemoryCount = 0
var outOfStorageCount = 0
for (uri in uris) { for (uri in uris) {
try { try {
repository.addPhoto(documentationId, uri) repository.addPhoto(documentationId, uri)
} catch (e: Exception) { } catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
// Throwable (statt Exception): OutOfMemoryError beim Dekodieren großer
// Fotos darf die App nicht beenden - das Bild gilt als fehlgeschlagen,
// die übrigen Bilder werden weiter importiert.
failureCount++ failureCount++
// Bewusst OHNE Uri/Dateipfad geloggt (Datenschutz-Vorgabe: keine Bild-Uris/ if (FailureCategory.of(e) == FailureCategory.OUT_OF_MEMORY) outOfMemoryCount++
// Dateipfade in Logs) - die Exception-Nachricht selbst genügt zur Diagnose. if (FailureCategory.of(e) == FailureCategory.OUT_OF_STORAGE) outOfStorageCount++
// Datenschutz: Es wird bewusst NUR der Exception-Typ geloggt. Die
// Exception-Nachricht (e.message) enthält bei Dateifehlern häufig den
// Dateipfad bzw. die Bild-Uri und darf deshalb nie in Logs landen.
android.util.Log.w( android.util.Log.w(
"DocumentationEdit", "DocumentationEdit",
"Fotoimport fehlgeschlagen: ${e::class.simpleName}: ${e.message}", "Fotoimport fehlgeschlagen: ${e::class.simpleName}",
) )
} }
} }
val refreshed = repository.getFullDocumentation(documentationId) val refreshed = runCatchingCancellable { repository.getFullDocumentation(documentationId) }.getOrNull()
val reason = when {
outOfStorageCount > 0 -> " Nicht genügend Speicherplatz."
outOfMemoryCount > 0 -> " Nicht genügend Arbeitsspeicher."
else -> ""
}
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
documentation = refreshed ?: state.documentation, documentation = refreshed ?: state.documentation,
photoImportInProgress = false,
photoImportError = if (failureCount > 0) { photoImportError = if (failureCount > 0) {
"$failureCount von ${uris.size} Bild(ern) konnten nicht importiert werden." "$failureCount von ${uris.size} Bild(ern) konnten nicht importiert werden.$reason"
} else { } else {
null state.photoImportError
}, },
) )
} }
} }
} finally {
runCatching { cleanup() }
if (pendingPhotoImports.decrementAndGet() == 0) {
_uiState.update { it.copy(photoImportInProgress = false) }
}
}
}
}
/** Meldet, dass die Kamera auf dem Gerät nicht gestartet werden konnte (keine Kamera-App). */
fun onCameraUnavailable() {
_uiState.update { it.copy(photoImportError = "Es konnte keine Kamera-App gestartet werden.") }
} }
fun dismissPhotoImportError() { fun dismissPhotoImportError() {
@@ -210,8 +388,9 @@ class DocumentationEditViewModel(
fun confirmDeletePhoto() { fun confirmDeletePhoto() {
val photoId = _uiState.value.pendingDeletePhotoId ?: return val photoId = _uiState.value.pendingDeletePhotoId ?: return
viewModelScope.launch { viewModelScope.launch {
repository.removePhoto(photoId) val result = runCatchingCancellable { repository.removePhoto(photoId) }
val refreshed = repository.getFullDocumentation(documentationId) if (result.isFailure) reportSaveFailure(result.exceptionOrNull(), "Das Foto konnte nicht gelöscht werden.")
val refreshed = runCatchingCancellable { repository.getFullDocumentation(documentationId) }.getOrNull()
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
documentation = refreshed ?: state.documentation, documentation = refreshed ?: state.documentation,
@@ -221,6 +400,229 @@ class DocumentationEditViewModel(
} }
} }
/** Öffnet den "Signatur hinzufügen"-Dialog (MEDIA-003, Aufgabenstellung Abschnitt 7). */
fun requestAddSignature() {
_uiState.update { it.copy(showAddSignatureDialog = true) }
}
fun dismissAddSignature() {
_uiState.update { it.copy(showAddSignatureDialog = false) }
}
/**
* Speichert eine neu erfasste Signatur. [bitmap] ist bereits die fertige, auf das logische
* 1000x400-Format normalisierte PNG-Bitmap ([de.medapp.wunddoku.ui.documentation.edit.signature.renderSignatureBitmap]).
* Die eigentliche Validierung (nicht-leerer Name, nicht-leere Zeichnung) findet bewusst
* NICHT hier, sondern im Repository statt (zweite, von der UI unabhängige Ebene, s.
* [DocumentationEditUiState.signatureError]) - der Dialog selbst deaktiviert "Speichern"
* bereits vorher, sodass dieser Fehlerfall in der Praxis kaum auftreten sollte.
*/
fun saveSignature(signerName: String, bitmap: Bitmap) {
viewModelScope.launch {
try {
repository.saveSignature(documentationId, signerName, bitmap)
val refreshed = repository.getFullDocumentation(documentationId)
_uiState.update { state ->
state.copy(
documentation = refreshed ?: state.documentation,
showAddSignatureDialog = false,
)
}
} catch (e: IllegalArgumentException) {
_uiState.update { it.copy(signatureError = e.message ?: "Signatur konnte nicht gespeichert werden.") }
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
// Speicherfehler o. Ä.: Dialog bleibt offen, die gezeichnete Signatur geht nicht
// verloren und kann nach Freigabe von Speicher erneut gespeichert werden.
val message = when (FailureCategory.of(e)) {
FailureCategory.OUT_OF_STORAGE -> "Nicht genügend Speicherplatz - die Signatur konnte nicht gespeichert werden."
FailureCategory.OUT_OF_MEMORY -> "Nicht genügend Arbeitsspeicher - die Signatur konnte nicht gespeichert werden."
FailureCategory.OTHER -> "Die Signatur konnte nicht gespeichert werden."
}
_uiState.update { it.copy(signatureError = message) }
}
}
}
fun dismissSignatureError() {
_uiState.update { it.copy(signatureError = null) }
}
/** Öffnet die Löschbestätigung für eine Signatur (Aufgabenstellung Abschnitt 17: Signaturen
* sind unveränderlich - "korrigieren" bedeutet immer löschen + neu erfassen). */
fun requestDeleteSignature(signatureId: String) {
_uiState.update { it.copy(pendingDeleteSignatureId = signatureId) }
}
fun dismissDeleteSignature() {
_uiState.update { it.copy(pendingDeleteSignatureId = null) }
}
fun confirmDeleteSignature() {
val signatureId = _uiState.value.pendingDeleteSignatureId ?: return
viewModelScope.launch {
val result = runCatchingCancellable { repository.removeSignature(signatureId) }
if (result.isFailure) reportSaveFailure(result.exceptionOrNull(), "Die Signatur konnte nicht gelöscht werden.")
val refreshed = runCatchingCancellable { repository.getFullDocumentation(documentationId) }.getOrNull()
_uiState.update { state ->
state.copy(
documentation = refreshed ?: state.documentation,
pendingDeleteSignatureId = null,
)
}
}
}
/**
* Löst den PDF-Export an (PDF-001/002). Rein zum manuellen Testen der Export-Funktionalität
* gedacht - Öffnen in einem externen Reader (PDF-005) und Teilen (PDF-006) sind explizit
* nicht Teil dieses Schritts, die erzeugte Datei liegt ausschließlich im internen
* App-Speicher. Keine rohe Exception erreicht die UI: [PdfExportResult] wird hier in eine
* kurze, verständliche Meldung übersetzt.
*/
fun exportPdf() {
viewModelScope.launch {
_uiState.update {
it.copy(pdfExportInProgress = true, pdfExportMessage = null, lastExportedPdfFile = null)
}
val result = repository.exportPdf(documentationId)
_uiState.update {
when (result) {
is PdfExportResult.Success -> it.copy(
pdfExportInProgress = false,
pdfExportMessage = "PDF erfolgreich erstellt",
lastExportedPdfFile = result.file,
)
is PdfExportResult.Failure -> it.copy(
pdfExportInProgress = false,
pdfExportMessage = "PDF-Erzeugung fehlgeschlagen: ${result.error.toUserMessage()}",
lastExportedPdfFile = null,
)
}
}
}
}
fun dismissPdfExportMessage() {
_uiState.update { it.copy(pdfExportMessage = null) }
}
/**
* Öffnet die Auswahl "Als JPG oder PDF teilen?" (Änderungswunsch: Format-Auswahl beim
* Antippen des Teilen-Buttons, BEVOR irgendetwas exportiert wird - vorher wurde beim
* Antippen sofort und ausschließlich als JPG geteilt).
*/
fun requestShareFormatChoice() {
_uiState.update { it.copy(showShareFormatDialog = true) }
}
fun dismissShareFormatDialog() {
_uiState.update { it.copy(showShareFormatDialog = false) }
}
/** Nutzer hat sich für "Als JPG teilen" entschieden. */
fun shareAsImages() {
_uiState.update { it.copy(showShareFormatDialog = false) }
exportAndShareAsImages()
}
/** Nutzer hat sich für "Als PDF teilen" entschieden. */
fun shareAsPdf() {
_uiState.update { it.copy(showShareFormatDialog = false) }
exportAndSharePdf()
}
/**
* Exportiert die Dokumentation frisch als PDF und wandelt das Ergebnis anschließend
* seitenweise in JPEG-Bilder um (Änderungswunsch: "PDF zusätzlich als JPEG speichern/senden
* können"). Bewusst ein eigener, vollständiger Export-Lauf statt Wiederverwendung von
* [uiState.lastExportedPdfFile]: so spiegeln die Bilder IMMER den aktuellen Stand wider,
* unabhängig davon, ob/wann zuvor schon einmal "PDF erzeugen" angetippt wurde.
*
* Das eigentliche Teilen (Android-"Senden an"-Dialog) übernimmt NICHT diese Methode, sondern
* der Screen anhand von [DocumentationEditUiState.imagesToShare] - Intent/Activity-Start ist
* UI-Aufgabe (s. [de.medapp.wunddoku.pdf.PdfImageSharer]).
*/
private fun exportAndShareAsImages() {
viewModelScope.launch {
_uiState.update {
it.copy(shareInProgress = true, shareMessage = null, imagesToShare = null)
}
val exportResult = repository.exportPdf(documentationId)
val pdfFile = when (exportResult) {
is PdfExportResult.Success -> exportResult.file
is PdfExportResult.Failure -> {
_uiState.update {
it.copy(
shareInProgress = false,
shareMessage = "PDF-Erzeugung fehlgeschlagen: ${exportResult.error.toUserMessage()}",
)
}
return@launch
}
}
when (val imagesResult = repository.convertPdfToImages(documentationId, pdfFile)) {
is PdfToImagesResult.Success -> _uiState.update {
it.copy(shareInProgress = false, imagesToShare = imagesResult.imageFiles)
}
is PdfToImagesResult.Failure -> _uiState.update {
it.copy(
shareInProgress = false,
shareMessage = "Bildexport fehlgeschlagen: ${imagesResult.toUserMessage()}",
)
}
}
}
}
/**
* Exportiert die Dokumentation frisch als PDF und stellt sie zum Teilen bereit
* (Änderungswunsch: Format-Auswahl "PDF" statt "JPG") - ohne die JPEG-Konvertierung, die
* geteilte Datei ist die PDF selbst. Das eigentliche Teilen übernimmt der Screen anhand von
* [DocumentationEditUiState.pdfToShare] (s. [de.medapp.wunddoku.pdf.PdfSharer]).
*/
private fun exportAndSharePdf() {
viewModelScope.launch {
_uiState.update {
it.copy(shareInProgress = true, shareMessage = null, pdfToShare = null)
}
when (val exportResult = repository.exportPdf(documentationId)) {
is PdfExportResult.Success -> _uiState.update {
it.copy(shareInProgress = false, pdfToShare = exportResult.file)
}
is PdfExportResult.Failure -> _uiState.update {
it.copy(
shareInProgress = false,
shareMessage = "PDF-Erzeugung fehlgeschlagen: ${exportResult.error.toUserMessage()}",
)
}
}
}
}
/** Wird vom Screen aufgerufen, nachdem der "Senden an"-Dialog für [DocumentationEditUiState.imagesToShare]
* geöffnet wurde (erfolgreich oder nicht) - verhindert ein erneutes Öffnen bei der nächsten
* Recomposition, solange keine neuen Bilder erzeugt wurden. */
fun onImageShareHandled() {
_uiState.update { it.copy(imagesToShare = null) }
}
/** Analog [onImageShareHandled], nur für [DocumentationEditUiState.pdfToShare]. */
fun onPdfShareHandled() {
_uiState.update { it.copy(pdfToShare = null) }
}
fun dismissShareMessage() {
_uiState.update { it.copy(shareMessage = null) }
}
/** /**
* Lädt eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter. * Lädt eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter.
* Bewusst NICHT im zentralen UiState gehalten (kein `Bitmap`-Feld in * Bewusst NICHT im zentralen UiState gehalten (kein `Bitmap`-Feld in
@@ -233,6 +635,29 @@ class DocumentationEditViewModel(
suspend fun loadPhotoThumbnail(documentationId: String, fileName: String) = suspend fun loadPhotoThumbnail(documentationId: String, fileName: String) =
repository.loadPhotoThumbnail(documentationId, fileName) repository.loadPhotoThumbnail(documentationId, fileName)
/** Für die Vollbildansicht beim Antippen einer Foto-Kachel (Änderungswunsch). */
suspend fun loadPhotoFullSize(documentationId: String, fileName: String, maxWidthPx: Int, maxHeightPx: Int) =
repository.loadPhotoFullSize(documentationId, fileName, maxWidthPx, maxHeightPx)
/** Lädt eine Signatur für die Vorschau in der Dokumentationsbearbeitung - analog
* [loadPhotoThumbnail], siehe dortigen Kommentar zur bewussten Nicht-Haltung im UiState. */
suspend fun loadSignatureBitmap(documentationId: String, fileName: String) =
repository.loadSignatureBitmap(documentationId, fileName)
/** Für die Vorbelegung des "Signatur hinzufügen"-Dialogs beim Öffnen: der zuletzt
* verwendete Wundversorger app-weit (nicht auf diese Dokumentation beschränkt) - `null`,
* wenn noch nie eine Signatur gespeichert wurde. Weiterhin änderbar (Änderungswunsch). */
suspend fun getDefaultWoundCarer(): WoundCarer? = woundCarerRepository.getMostRecentlyUsed()
/** Namensvorschläge für die Autovervollständigung im Signatur-Dialog. */
suspend fun searchWoundCarers(query: String): List<WoundCarer> = woundCarerRepository.search(query)
/** Lädt die hinterlegte Signatur eines bekannten Wundversorgers (Vorbelegung bzw. nach Tippen
* auf einen Namensvorschlag) - unabhängig von [loadSignatureBitmap], das historische, pro
* Dokumentation gespeicherte Signaturen lädt. */
suspend fun loadWoundCarerSignatureBitmap(woundCarerId: String, fileName: String) =
woundCarerRepository.loadSignatureBitmap(woundCarerId, fileName)
/** /**
* Best-effort-Flush ausstehender, noch gedebouncter Freitext-Änderungen. Aufgerufen vom * Best-effort-Flush ausstehender, noch gedebouncter Freitext-Änderungen. Aufgerufen vom
* Screen bei `Lifecycle.Event.ON_STOP` – siehe Klassenkommentar zur Nicht-Garantie bei * Screen bei `Lifecycle.Event.ON_STOP` – siehe Klassenkommentar zur Nicht-Garantie bei
@@ -241,9 +666,10 @@ class DocumentationEditViewModel(
fun flushPendingChanges() { fun flushPendingChanges() {
if (pendingFreeText.isEmpty()) return if (pendingFreeText.isEmpty()) return
val toFlush = pendingFreeText.toMap() val toFlush = pendingFreeText.toMap()
pendingFreeText.clear()
viewModelScope.launch { viewModelScope.launch {
toFlush.forEach { (checkItemStateId, text) -> repository.updateFreeText(checkItemStateId, text) } // Einträge werden erst NACH erfolgreichem Schreiben aus pendingFreeText entfernt -
// scheitert das Schreiben, bleibt der Text für den nächsten Versuch erhalten.
toFlush.forEach { (checkItemStateId, text) -> persistFreeText(checkItemStateId, text) }
} }
} }
@@ -261,6 +687,7 @@ class DocumentationEditViewModel(
DocumentationEditViewModel( DocumentationEditViewModel(
documentationId = route.documentationId, documentationId = route.documentationId,
repository = container.documentationRepository, repository = container.documentationRepository,
woundCarerRepository = container.woundCarerRepository,
) )
} }
} }
@@ -0,0 +1,124 @@
package de.medapp.wunddoku.ui.documentation.edit
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import de.medapp.wunddoku.R
import de.medapp.wunddoku.domain.model.Photo
/**
* Vollbild-Ansicht eines einzelnen Fotos in Originalgröße (Änderungswunsch: "Fotos in der App
* durch Antippen in Originalgröße angezeigt werden"). Lädt das Foto über [loadFullSize] separat
* von der kleinen Grid-Vorschau ([DocumentationEditViewModel.loadPhotoThumbnail]) - dieselbe
* Trennung wie bei den Signatur-Vorschauen: die kleine Kachel und die Vollbildansicht sind
* unabhängige Ladevorgänge mit unterschiedlicher Zielauflösung.
*
* "Originalgröße" bedeutet hier: so groß wie der Bildschirm es zulässt, ohne unnötig über die
* tatsächliche Displayauflösung hinaus zu decodieren (s. Kommentar an
* [de.medapp.wunddoku.data.repository.DocumentationRepository.loadPhotoFullSize]) - eine echte
* 1:1-Pixel-Darstellung würde bei modernen Fotoauflösungen ohnehin über die Bildschirmgröße
* hinausgehen und wäre nur per Zoom/Scroll sichtbar, was hier nicht gefordert ist.
*
* Volle Bildschirmbreite/-höhe via [DialogProperties.usePlatformDefaultWidth] = false, da der
* Standard-Dialog-Rahmen von Compose sonst deutlich schmaler als der Bildschirm wäre. Schließen
* über den Schließen-Button ODER Antippen des dunklen Hintergrunds.
*/
@Composable
fun PhotoViewerDialog(
photo: Photo,
loadFullSize: suspend (documentationId: String, fileName: String, maxWidthPx: Int, maxHeightPx: Int) -> Bitmap?,
onDismiss: () -> Unit,
) {
var bitmap by remember(photo.id) { mutableStateOf<Bitmap?>(null) }
var loadFailed by remember(photo.id) { mutableStateOf(false) }
val configuration = LocalConfiguration.current
val density = LocalDensity.current
LaunchedEffect(photo.id) {
val maxWidthPx = with(density) { configuration.screenWidthDp.dp.roundToPx() }
val maxHeightPx = with(density) { configuration.screenHeightDp.dp.roundToPx() }
val result = runCatching {
loadFullSize(photo.documentationId, photo.fileName, maxWidthPx, maxHeightPx)
}.getOrNull()
bitmap = result
loadFailed = result == null
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.clickable(onClick = onDismiss),
) {
val currentBitmap = bitmap
when {
currentBitmap != null -> Image(
bitmap = currentBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize().padding(16.dp),
contentScale = ContentScale.Fit,
)
loadFailed -> Box(modifier = Modifier.fillMaxSize()) {
Icon(
Icons.Filled.BrokenImage,
contentDescription = null,
tint = Color.White,
modifier = Modifier.align(Alignment.Center).size(48.dp),
)
Text(
stringResource(R.string.documentation_edit_photo_load_failed),
color = Color.White,
modifier = Modifier.align(Alignment.BottomCenter).padding(24.dp),
)
}
else -> CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center),
color = Color.White,
)
}
IconButton(
onClick = onDismiss,
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
) {
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.action_cancel), tint = Color.White)
}
}
}
}
@@ -0,0 +1,235 @@
package de.medapp.wunddoku.ui.documentation.edit.signature
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import de.medapp.wunddoku.R
import de.medapp.wunddoku.domain.model.WoundCarer
import kotlinx.coroutines.launch
/**
* Dialog zum Erfassen einer neuen Signatur (MEDIA-003): Name des Wundversorgers + Zeichenfläche
* ([SignaturePad]), wie in der Aufgabenstellung (Abschnitt 7) als bevorzugte UX beschrieben -
* bewusst als Dialog INNERHALB von [de.medapp.wunddoku.ui.documentation.edit.DocumentationEditScreen],
* keine eigene Navigationsroute.
*
* Änderungswunsch (Wundversorger-Register): Beim Öffnen wird automatisch der ZULETZT verwendete
* Wundversorger samt seiner zuletzt gespeicherten Signatur vorbelegt ([loadDefaultWoundCarer]) -
* bleibt aber änderbar. Während der Eingabe werden nach den ersten Buchstaben passende bekannte
* Namen als antippbare Vorschläge angezeigt ([searchWoundCarers]); ein Tippen darauf übernimmt
* Name UND zuletzt gespeicherte Signatur dieses Wundversorgers ([loadWoundCarerSignatureBitmap]).
* In diesem "Vorschau-Modus" wird die geladene Signatur als fertiges Bild angezeigt (nicht mehr
* frei bearbeitbar) - über "Neu zeichnen" kann jederzeit zu einer frischen, freihändigen
* Zeichnung für denselben Namen gewechselt werden. Wird der Name manuell so verändert, dass er
* nicht mehr zum gerade geladenen Vorschlag passt, wird die Vorschau verworfen (verhindert, dass
* versehentlich die Signatur einer anderen Person unter einem neuen Namen gespeichert wird).
*
* "Speichern" ist erst aktiv, wenn BEIDE Bedingungen erfüllt sind (erste Absicherungsebene gegen
* leere Signaturen bzw. leeren Namen, s. Aufgabenstellung Abschnitt 12 - die zweite, unabhängige
* Ebene sitzt serverseitig in
* [de.medapp.wunddoku.data.repository.DocumentationRepository.saveSignature]):
* - der (getrimmte) Name ist nicht leer
* - es liegt entweder eine geladene Vorschau-Signatur ODER eine frisch gezeichnete Signatur vor
*/
@Composable
fun AddSignatureDialog(
onDismiss: () -> Unit,
onSave: (signerName: String, bitmap: Bitmap) -> Unit,
loadDefaultWoundCarer: suspend () -> WoundCarer?,
searchWoundCarers: suspend (query: String) -> List<WoundCarer>,
loadWoundCarerSignatureBitmap: suspend (woundCarerId: String, fileName: String) -> Bitmap?,
) {
var signerName by remember { mutableStateOf("") }
var strokes by remember { mutableStateOf<List<SignatureStroke>>(emptyList()) }
var canvasSize by remember { mutableStateOf(Size.Zero) }
// != null => Vorschau-Modus: eine bereits bekannte Signatur wird als fertiges Bild gezeigt
// statt einer leeren, frei bearbeitbaren Zeichenfläche.
var previewBitmap by remember { mutableStateOf<Bitmap?>(null) }
// Name, zu dem [previewBitmap] gehört - dient ausschließlich dazu, die Vorschau zu verwerfen,
// sobald der Name manuell auf etwas anderes geändert wird (s. Klassenkommentar).
var previewOwnerName by remember { mutableStateOf<String?>(null) }
var suggestions by remember { mutableStateOf<List<WoundCarer>>(emptyList()) }
val scope = rememberCoroutineScope()
// Einmalige Vorbelegung beim Öffnen mit dem zuletzt verwendeten Wundversorger.
LaunchedEffect(Unit) {
val default = loadDefaultWoundCarer() ?: return@LaunchedEffect
signerName = default.name
previewOwnerName = default.name
previewBitmap = loadWoundCarerSignatureBitmap(default.id, default.signatureFileName)
}
// Vorschläge nachladen UND Vorschau verwerfen, sobald der Name vom Vorschau-Eigentümer
// abweicht (z. B. weil der Nutzer den vorbelegten Namen überschreibt).
LaunchedEffect(signerName) {
if (previewOwnerName != null && signerName != previewOwnerName) {
previewBitmap = null
previewOwnerName = null
}
suggestions = if (previewOwnerName == signerName) {
emptyList()
} else {
searchWoundCarers(signerName)
}
}
val canSave = signerName.trim().isNotEmpty() && (previewBitmap != null || strokes.isNotEmpty())
Dialog(onDismissRequest = onDismiss) {
Surface(shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surface) {
Column(modifier = Modifier.fillMaxWidth().padding(20.dp)) {
Text(
stringResource(R.string.signature_dialog_title),
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = signerName,
onValueChange = { signerName = it },
label = { Text(stringResource(R.string.signature_dialog_name_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
if (suggestions.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(suggestions, key = { it.id }) { suggestion ->
AssistChip(
onClick = {
signerName = suggestion.name
previewOwnerName = suggestion.name
suggestions = emptyList()
scope.launch {
previewBitmap = loadWoundCarerSignatureBitmap(
suggestion.id,
suggestion.signatureFileName,
)
}
},
label = { Text(suggestion.name) },
)
}
}
}
Spacer(Modifier.height(12.dp))
val currentPreview = previewBitmap
if (currentPreview != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp))
.background(Color.White, RoundedCornerShape(8.dp)),
) {
Image(
bitmap = currentPreview.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxWidth().height(180.dp).padding(8.dp),
contentScale = ContentScale.Fit,
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = {
previewBitmap = null
strokes = emptyList()
}) {
Text(stringResource(R.string.signature_dialog_redraw))
}
}
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp))
.background(Color.White, RoundedCornerShape(8.dp))
.onSizeChanged { size -> canvasSize = Size(size.width.toFloat(), size.height.toFloat()) },
) {
SignaturePad(
strokes = strokes,
onStrokesChanged = { strokes = it },
modifier = Modifier.fillMaxWidth().height(180.dp),
)
}
Text(
stringResource(R.string.signature_dialog_single_stroke_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
Row(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = { strokes = emptyList() }, enabled = strokes.isNotEmpty()) {
Text(stringResource(R.string.signature_dialog_reset))
}
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
Spacer(Modifier.width(8.dp))
Button(
onClick = {
val bitmap = previewBitmap ?: renderSignatureBitmap(strokes, canvasSize)
onSave(signerName.trim(), bitmap)
},
enabled = canSave,
) {
Text(stringResource(R.string.signature_dialog_save))
}
}
}
}
}
}
@@ -0,0 +1,156 @@
package de.medapp.wunddoku.ui.documentation.edit.signature
import android.graphics.Bitmap
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
/** Logisches Signaturformat, unabhängig von der tatsächlichen Größe der Compose-Zeichenfläche
* auf einem konkreten Gerät (Aufgabenstellung Abschnitt 10). NUR für die persistierte Bitmap
* relevant - die UI selbst darf/soll responsiv sein. */
const val SIGNATURE_LOGICAL_WIDTH = 1000
const val SIGNATURE_LOGICAL_HEIGHT = 400
/** Strichbreite in Bildschirm-dp während des Zeichnens. Die tatsächliche, im PNG persistierte
* Strichbreite ergibt sich aus [SIGNATURE_LOGICAL_STROKE_WIDTH] nach der Normalisierung. */
private const val SIGNATURE_UI_STROKE_WIDTH_PX = 5f
private const val SIGNATURE_LOGICAL_STROKE_WIDTH = 6f
/**
* Eine einzelne, zusammenhängende Zeichenbewegung (ein "Strich") in tatsächlichen
* Canvas-Pixelkoordinaten (nicht normalisiert).
*/
typealias SignatureStroke = List<Offset>
/**
* Compose-basierte Signaturzeichenfläche (MEDIA-003). Unterstützt Finger UND Stylus ohne
* Sonderbehandlung - Compose liefert beide über dieselbe Zeigereingabe-Pipeline
* ([pointerInput]/[detectDragGestures]), es gibt auf Android keinen technischen Unterschied
* zwischen einer Finger- und einer Stylus-Berührung an dieser Stelle.
*
* FACHLICHE EINSCHRÄNKUNG (Änderungswunsch): Eine gültige Signatur besteht aus GENAU EINEM
* durchgehenden Strich - ein erneutes Ansetzen (zweiter Touch-Down, nachdem bereits ein Strich
* gezeichnet wurde) verwirft die komplette bisherige Zeichnung automatisch und beginnt neu mit
* genau diesem neuen Strich. Es kann also beliebig oft neu versucht werden, aber niemals mehrere
* getrennte Striche zu EINER Signatur zusammengesetzt werden. Umgesetzt, indem [onDragEnd] die
* nach oben gemeldete Strichliste immer komplett ERSETZT (`listOf(currentStroke)`) statt an
* [strokes] anzuhängen - dadurch kann zu jedem Zeitpunkt höchstens ein abgeschlossener Strich in
* [strokes] stehen, unabhängig von der Reihenfolge der Ereignisse.
*
* Zustandslos (state hoisting): [strokes] kommt vom Aufrufer, jede Änderung wird über
* [onStrokesChanged] nach oben gemeldet.
*/
@Composable
fun SignaturePad(
strokes: List<SignatureStroke>,
onStrokesChanged: (List<SignatureStroke>) -> Unit,
modifier: Modifier = Modifier,
) {
var currentStroke by remember { mutableStateOf<SignatureStroke>(emptyList()) }
Canvas(
modifier = modifier
.background(Color.White)
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset -> currentStroke = listOf(offset) },
onDrag = { change, _ ->
change.consume()
currentStroke = currentStroke + change.position
},
onDragEnd = {
// Ein "Tap" ohne tatsächliche Bewegung (< 2 Punkte) erzeugt bewusst
// keinen sichtbaren Strich - eine bereits vorhandene, gültige Signatur
// bleibt in diesem Fall unangetastet erhalten. Ein ECHTER Strich ersetzt
// dagegen immer die komplette bisherige Zeichnung (s. Klassenkommentar).
if (currentStroke.size > 1) {
onStrokesChanged(listOf(currentStroke))
}
currentStroke = emptyList()
},
onDragCancel = { currentStroke = emptyList() },
)
},
) {
for (stroke in strokes) {
drawSignatureStroke(stroke)
}
if (currentStroke.size > 1) {
drawSignatureStroke(currentStroke)
}
}
}
private fun DrawScope.drawSignatureStroke(points: SignatureStroke) {
if (points.size < 2) return
val path = Path().apply {
moveTo(points.first().x, points.first().y)
for (index in 1 until points.size) {
lineTo(points[index].x, points[index].y)
}
}
drawPath(
path = path,
color = Color.Black,
style = Stroke(
width = SIGNATURE_UI_STROKE_WIDTH_PX,
cap = StrokeCap.Round,
join = StrokeJoin.Round,
),
)
}
/**
* Rendert die erfassten [strokes] (in tatsächlichen Canvas-Pixelkoordinaten der Größe
* [canvasSize]) auf ein neues, weißes Bitmap im logischen Signaturformat
* ([SIGNATURE_LOGICAL_WIDTH] x [SIGNATURE_LOGICAL_HEIGHT]) - unabhängig von der tatsächlichen
* Gerätegröße der Zeichenfläche (Aufgabenstellung Abschnitt 10: Normalisierung). Persistiert
* wird ausschließlich dieses fertige Bitmap, niemals die Stroke-Rohdaten selbst
* (Aufgabenstellung Abschnitt 11).
*/
fun renderSignatureBitmap(strokes: List<SignatureStroke>, canvasSize: Size): Bitmap {
val bitmap = Bitmap.createBitmap(SIGNATURE_LOGICAL_WIDTH, SIGNATURE_LOGICAL_HEIGHT, Bitmap.Config.ARGB_8888)
val canvas = android.graphics.Canvas(bitmap)
canvas.drawColor(android.graphics.Color.WHITE)
if (canvasSize.width <= 0f || canvasSize.height <= 0f) return bitmap
val scaleX = SIGNATURE_LOGICAL_WIDTH / canvasSize.width
val scaleY = SIGNATURE_LOGICAL_HEIGHT / canvasSize.height
val paint = android.graphics.Paint().apply {
isAntiAlias = true
color = android.graphics.Color.BLACK
style = android.graphics.Paint.Style.STROKE
strokeWidth = SIGNATURE_LOGICAL_STROKE_WIDTH
strokeCap = android.graphics.Paint.Cap.ROUND
strokeJoin = android.graphics.Paint.Join.ROUND
}
for (stroke in strokes) {
if (stroke.size < 2) continue
val path = android.graphics.Path()
path.moveTo(stroke.first().x * scaleX, stroke.first().y * scaleY)
for (index in 1 until stroke.size) {
path.lineTo(stroke[index].x * scaleX, stroke[index].y * scaleY)
}
canvas.drawPath(path, paint)
}
return bitmap
}
@@ -9,6 +9,7 @@ import androidx.navigation.toRoute
import de.medapp.wunddoku.data.repository.DocumentationRepository import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.data.repository.PatientRepository import de.medapp.wunddoku.data.repository.PatientRepository
import de.medapp.wunddoku.di.wunddokuApplication import de.medapp.wunddoku.di.wunddokuApplication
import de.medapp.wunddoku.domain.error.FailureCategory
import de.medapp.wunddoku.domain.model.Patient import de.medapp.wunddoku.domain.model.Patient
import de.medapp.wunddoku.domain.model.PatientListItem import de.medapp.wunddoku.domain.model.PatientListItem
import de.medapp.wunddoku.ui.components.format.DATE_MAX_DIGITS import de.medapp.wunddoku.ui.components.format.DATE_MAX_DIGITS
@@ -18,6 +19,7 @@ import de.medapp.wunddoku.ui.components.format.filterDigits
import de.medapp.wunddoku.ui.components.format.germanDateDigitsToLocalDateOrNull import de.medapp.wunddoku.ui.components.format.germanDateDigitsToLocalDateOrNull
import de.medapp.wunddoku.ui.components.format.toGermanDateDigits import de.medapp.wunddoku.ui.components.format.toGermanDateDigits
import de.medapp.wunddoku.ui.navigation.Screen import de.medapp.wunddoku.ui.navigation.Screen
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -187,7 +189,7 @@ class PatientFormViewModel(
fun useExistingPatient() { fun useExistingPatient() {
val existing = _uiState.value.duplicateWarning ?: return val existing = _uiState.value.duplicateWarning ?: return
_uiState.update { it.copy(duplicateWarning = null) } _uiState.update { it.copy(duplicateWarning = null) }
viewModelScope.launch { finish(existing.id) } viewModelScope.launch { guardedFinish(existing.id) }
} }
/** Der Nutzer entscheidet sich im Duplikat-Dialog, trotzdem einen neuen Patienten anzulegen. */ /** Der Nutzer entscheidet sich im Duplikat-Dialog, trotzdem einen neuen Patienten anzulegen. */
@@ -241,10 +243,37 @@ class PatientFormViewModel(
createdAt = Instant.EPOCH, // wird von PatientRepository.upsert() korrekt gesetzt createdAt = Instant.EPOCH, // wird von PatientRepository.upsert() korrekt gesetzt
updatedAt = Instant.EPOCH, updatedAt = Instant.EPOCH,
) )
val saved = repository.upsert(patient) val saved = try {
_uiState.update { it.copy(validationError = null) } repository.upsert(patient)
finish(saved.id) } catch (e: CancellationException) {
throw e
} catch (e: Exception) {
reportPersistenceFailure(e, "Der Patient konnte nicht gespeichert werden.")
return@launch
} }
_uiState.update { it.copy(validationError = null) }
guardedFinish(saved.id)
}
}
/** Wie [finish], fängt aber Datenbankfehler (z. B. voller Speicher) ab: Das Formular bleibt
* mit allen Eingaben offen und zeigt eine Meldung, statt dass die App abstürzt. */
private suspend fun guardedFinish(resolvedPatientId: String) {
try {
finish(resolvedPatientId)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
reportPersistenceFailure(e, "Die Dokumentation konnte nicht angelegt werden.")
}
}
private fun reportPersistenceFailure(cause: Throwable, fallback: String) {
val message = when (FailureCategory.of(cause)) {
FailureCategory.OUT_OF_STORAGE -> "Nicht genügend Speicherplatz - bitte Speicher freigeben und erneut versuchen."
else -> fallback
}
_uiState.update { it.copy(validationError = message) }
} }
/** /**
+16 -3
View File
@@ -32,17 +32,30 @@
<string name="patient_form_phone3">Telefonnummer 3 (optional)</string> <string name="patient_form_phone3">Telefonnummer 3 (optional)</string>
<string name="patient_form_save">Speichern</string> <string name="patient_form_save">Speichern</string>
<string name="documentation_edit_position">%1$d / %2$d</string> <string name="documentation_edit_progress">%1$d / %2$d bearbeitet</string>
<string name="documentation_edit_previous">Zurück</string> <string name="documentation_edit_block_edited">Block bearbeitet</string>
<string name="documentation_edit_next">Weiter</string>
<string name="documentation_edit_free_text_label">Freitext</string> <string name="documentation_edit_free_text_label">Freitext</string>
<string name="documentation_edit_photos_title">Fotos</string> <string name="documentation_edit_photos_title">Fotos</string>
<string name="documentation_edit_add_photos">Hinzufügen</string> <string name="documentation_edit_add_photos">Hinzufügen</string>
<string name="documentation_edit_no_photos">Noch keine Fotos vorhanden.</string> <string name="documentation_edit_no_photos">Noch keine Fotos vorhanden.</string>
<string name="documentation_edit_photo_load_failed">Foto konnte nicht geladen werden.</string>
<string name="documentation_edit_delete_photo_confirm_title">Foto löschen?</string> <string name="documentation_edit_delete_photo_confirm_title">Foto löschen?</string>
<string name="documentation_edit_delete_photo_confirm_text">Dieses Foto wird endgültig aus der Dokumentation entfernt. Diese Aktion kann nicht rückgängig gemacht werden.</string> <string name="documentation_edit_delete_photo_confirm_text">Dieses Foto wird endgültig aus der Dokumentation entfernt. Diese Aktion kann nicht rückgängig gemacht werden.</string>
<string name="documentation_edit_signatures_title">Unterschriften</string>
<string name="documentation_edit_add_signature">Signatur hinzufügen</string>
<string name="documentation_edit_no_signatures">Noch keine Unterschriften vorhanden.</string>
<string name="documentation_edit_delete_signature_confirm_title">Unterschrift löschen?</string>
<string name="documentation_edit_delete_signature_confirm_text">Diese Unterschrift wird endgültig aus der Dokumentation entfernt. Diese Aktion kann nicht rückgängig gemacht werden.</string>
<string name="signature_dialog_title">Signatur hinzufügen</string>
<string name="signature_dialog_name_label">Name des Wundversorgers</string>
<string name="signature_dialog_reset">Zurücksetzen</string>
<string name="signature_dialog_save">Speichern</string>
<string name="signature_dialog_redraw">Neu zeichnen</string>
<string name="signature_dialog_single_stroke_hint">Bitte in einem Zug unterschreiben, ohne abzusetzen - bei erneutem Ansetzen wird die Zeichnung zurückgesetzt.</string>
<string name="action_cancel">Abbrechen</string> <string name="action_cancel">Abbrechen</string>
<string name="action_confirm">Bestätigen</string> <string name="action_confirm">Bestätigen</string>
</resources> </resources>
+9 -4
View File
@@ -1,12 +1,17 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
Gibt ausschließlich Unterordner des privaten internen App-Speichers (context.filesDir) Gibt ausschließlich Unterordner des privaten internen App-Speichers (context.filesDir)
für die Freigabe über den FileProvider frei. Es gibt bewusst keinen <external-path>- für die Freigabe über den FileProvider frei - und zwar NUR das, was die App tatsächlich
nach außen gibt (PDF-Export, Seitenbilder, Kameraaufnahme). Die Ordner photos/ und
signatures/ (Originalfotos, Unterschriften) sind bewusst NICHT freigegeben (Prinzip der
minimalen Freigabe; Datenschutz-Review QA-006). Es gibt bewusst keinen <external-path>-
oder <external-files-path>-Eintrag: Fotos, Signaturen und PDFs liegen grundsätzlich oder <external-files-path>-Eintrag: Fotos, Signaturen und PDFs liegen grundsätzlich
intern (Abschnitt "Storage-Strategie" der Architektur, v3). intern (nur die kurzlebigen Kameraaufnahmen zusätzlich im internen Cache) (Abschnitt "Storage-Strategie" der Architektur, v3).
--> -->
<paths xmlns:android="http://schemas.android.com/apk/res/android"> <paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="pdf_exports" path="pdf/" /> <files-path name="pdf_exports" path="pdf/" />
<files-path name="photos" path="photos/" /> <files-path name="pdf_images" path="pdf_images/" />
<files-path name="signatures" path="signatures/" /> <!-- Temporäre Kameraaufnahmen (Kamerabutton im Fotobereich): nur bis zum Import in die
Dokumentation, danach sofort gelöscht. -->
<cache-path name="camera_captures" path="camera/" />
</paths> </paths>
@@ -0,0 +1,78 @@
package de.medapp.wunddoku.domain.catalog
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Strukturprüfungen für den statischen Katalog sowie die fachlichen Änderungswünsche
* (Wundart/Wundgrund geteilt, Wundrandschutz, Granudacyn).
*/
class DocumentationCatalogTest {
private fun items(blockId: String) = DocumentationCatalog.findBlock(blockId)!!.items
@Test
fun alle_checkitem_ids_sind_katalogweit_eindeutig() {
val ids = DocumentationCatalog.blocks.flatMap { b -> b.items.map { it.id } }
assertEquals(ids.size, ids.toSet().size)
}
@Test
fun order_ist_je_block_eindeutig_und_aufsteigend() {
for (block in DocumentationCatalog.blocks) {
val orders = block.items.map { it.order }
assertEquals("Block ${block.id}", orders.sorted(), orders)
assertEquals("Block ${block.id}", orders.size, orders.toSet().size)
}
}
@Test
fun wundart_postoperativ_und_dehiszenz_sind_getrennte_punkte() {
val wundart = items("wundart")
assertEquals("Post-operative Wundheilungsstörung", wundart.single { it.id == "wundart_post_operative_wundheilungsstoerung" }.label)
assertEquals("Dehiszenz", wundart.single { it.id == "wundart_dehiszenz" }.label)
assertTrue(wundart.none { it.label.contains("/ Dehiszenz") })
}
@Test
fun wundgrund_unregelmaessig_und_landkartenartig_sind_getrennte_punkte() {
val wundgrund = items("wundgrund_erscheinung")
assertEquals("Unregelmäßig", wundgrund.single { it.id == "wundgrund_unregelmaessig" }.label)
assertEquals("Landkartenartig/amorph", wundgrund.single { it.id == "wundgrund_landkartenartig_amorph" }.label)
assertNull(wundgrund.firstOrNull { it.id == "wundgrund_unregelmaessig_landkartenartig" })
}
@Test
fun wundrandschutz_ist_aus_ziel_entfernt() {
assertTrue(items("ziel").none { it.label.contains("Wundrandschutz") })
}
@Test
fun wundrandschutz_ist_eine_einzelne_freitext_box_ohne_ja_nein() {
val ev = items("empfohlene_wundversorgung")
val wrs = ev.single { it.id == "empfohlene_wundversorgung_wundrandschutz" }
assertEquals("Wundrandschutz", wrs.label)
assertTrue(wrs.isFreeText)
assertNull(ev.firstOrNull { it.id == "empfohlene_wundversorgung_hinweis_wundrandschutz" })
assertNull(ev.firstOrNull { it.id == "empfohlene_wundversorgung_wundrandschutz_ja" })
assertNull(ev.firstOrNull { it.id == "empfohlene_wundversorgung_wundrandschutz_nein" })
// "Wie oft pro Woche" bleibt als eigene Checkbox erhalten.
assertNotNull(ev.firstOrNull { it.id == "empfohlene_wundversorgung_wundrandschutz_tage_pro_woche" })
}
@Test
fun granudacyn_ist_einfache_checkbox_und_freitext_ein_eigener_punkt_direkt_darunter() {
val ev = items("empfohlene_wundversorgung")
val granudacyn = ev.single { it.id == "empfohlene_wundversorgung_granudacyn" }
val freitext = ev.single { it.id == "empfohlene_wundversorgung_granudacyn_freitext" }
assertFalse(granudacyn.isFreeText)
assertTrue(freitext.isFreeText)
assertEquals(DocumentationCatalog.FREE_TEXT_LABEL, freitext.label)
assertEquals(granudacyn.order + 1, freitext.order)
}
}
@@ -0,0 +1,91 @@
package de.medapp.wunddoku.domain.error
import kotlinx.coroutines.CancellationException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import java.io.FileNotFoundException
import java.io.IOException
/**
* Reine JVM-Tests für die Fehlereinordnung (Speichermangel erkennen) und
* [runCatchingCancellable].
*/
class FailureCategoryTest {
// --- Arbeitsspeicher -----------------------------------------------------------------
@Test
fun out_of_memory_error_wird_erkannt() {
assertEquals(FailureCategory.OUT_OF_MEMORY, FailureCategory.of(OutOfMemoryError("Failed to allocate")))
}
@Test
fun out_of_memory_error_wird_auch_als_ursache_erkannt() {
val wrapped = RuntimeException("Export fehlgeschlagen", OutOfMemoryError())
assertEquals(FailureCategory.OUT_OF_MEMORY, FailureCategory.of(wrapped))
}
// --- Speicherplatz -------------------------------------------------------------------
@Test
fun enospc_ioexception_wird_als_speicher_voll_erkannt() {
val e = IOException("write failed: ENOSPC (No space left on device)")
assertEquals(FailureCategory.OUT_OF_STORAGE, FailureCategory.of(e))
}
@Test
fun sqlite_disk_full_meldung_wird_als_speicher_voll_erkannt() {
val e = RuntimeException("database or disk is full (code 13 SQLITE_FULL)")
assertEquals(FailureCategory.OUT_OF_STORAGE, FailureCategory.of(e))
}
@Test
fun erkennung_ist_unabhaengig_von_gross_und_kleinschreibung() {
assertEquals(FailureCategory.OUT_OF_STORAGE, FailureCategory.of(IOException("NO SPACE LEFT ON DEVICE")))
}
@Test
fun speicher_voll_in_tiefer_ursachenkette_wird_erkannt() {
val root = IOException("No space left on device")
val chain = IllegalStateException("a", RuntimeException("b", RuntimeException("c", root)))
assertEquals(FailureCategory.OUT_OF_STORAGE, FailureCategory.of(chain))
}
// --- Sonstige Fehler -----------------------------------------------------------------
@Test
fun gewoehnliche_fehler_sind_other() {
assertEquals(FailureCategory.OTHER, FailureCategory.of(FileNotFoundException("weg")))
assertEquals(FailureCategory.OTHER, FailureCategory.of(IllegalStateException()))
assertEquals(FailureCategory.OTHER, FailureCategory.of(RuntimeException(null as String?)))
}
@Test
fun zyklische_ursachenkette_fuehrt_nicht_zur_endlosschleife() {
val a = RuntimeException("a")
val b = RuntimeException("b", a)
a.initCause(b)
assertEquals(FailureCategory.OTHER, FailureCategory.of(a))
}
// --- runCatchingCancellable ----------------------------------------------------------
@Test
fun runCatchingCancellable_liefert_erfolg_und_fehler_als_result() {
assertEquals(42, runCatchingCancellable { 42 }.getOrNull())
val failure = runCatchingCancellable<Int> { throw IOException("x") }
assertTrue(failure.exceptionOrNull() is IOException)
}
@Test
fun runCatchingCancellable_reicht_coroutine_abbruch_weiter() {
try {
runCatchingCancellable<Unit> { throw CancellationException("abgebrochen") }
fail("CancellationException darf nicht verschluckt werden")
} catch (expected: CancellationException) {
assertEquals("abgebrochen", expected.message)
}
}
}
@@ -0,0 +1,151 @@
package de.medapp.wunddoku.domain.usecase
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.DocumentationBlock
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Blockstatus-Tests: Wann gilt ein Dokumentationsblock als "bearbeitet"?
* Diese Regel steuert Fortschrittsanzeige UND PDF-Export und muss deshalb exakt stimmen.
*/
class BlockEditedPolicyTest {
private var idCounter = 0
private fun item(
checked: Boolean,
isFreeText: Boolean = false,
freeTextValue: String = "",
isStaticHint: Boolean = false,
inputSpec: String = "",
): CheckItem {
idCounter++
return CheckItem(
checkItemStateId = "state-$idCounter",
definitionId = "def-$idCounter",
label = "Eintrag $idCounter",
order = idCounter,
checked = checked,
isFreeText = isFreeText,
freeTextValue = freeTextValue,
isStaticHint = isStaticHint,
inputSpec = inputSpec,
)
}
private fun block(vararg items: CheckItem) = DocumentationBlock(
blockStateId = "block-state",
definitionId = "block-def",
title = "Testblock",
order = 1,
items = items.toList(),
)
// --- nicht bearbeitet ----------------------------------------------------------------
@Test
fun block_ohne_eintraege_ist_nicht_bearbeitet() {
assertFalse(BlockEditedPolicy.isEdited(block()))
}
@Test
fun block_ohne_angehakten_eintrag_ist_nicht_bearbeitet() {
val b = block(item(checked = false), item(checked = false), item(checked = false, isFreeText = true))
assertFalse(BlockEditedPolicy.isEdited(b))
}
@Test
fun nicht_angehakter_freitext_mit_text_zaehlt_nicht_als_bearbeitung() {
// Das Freitextfeld ist in der UI gesperrt, solange die Checkbox nicht angehakt ist.
val b = block(item(checked = false, isFreeText = true, freeTextValue = "Text ohne Haken"))
assertFalse(BlockEditedPolicy.isEdited(b))
}
@Test
fun nur_hinweistexte_machen_einen_block_nicht_bearbeitet() {
// Hinweistexte sind technisch immer checked = true, aber keine Nutzereingabe.
val b = block(
item(checked = true, isStaticHint = true),
item(checked = true, isStaticHint = true),
)
assertFalse(BlockEditedPolicy.isEdited(b))
}
@Test
fun hinweistexte_plus_nicht_angehakte_eintraege_sind_nicht_bearbeitet() {
val b = block(
item(checked = true, isStaticHint = true),
item(checked = false),
item(checked = false, isFreeText = true),
)
assertFalse(BlockEditedPolicy.isEdited(b))
}
// --- bearbeitet ----------------------------------------------------------------------
@Test
fun ein_angehakter_normaler_eintrag_macht_den_block_bearbeitet() {
assertTrue(BlockEditedPolicy.isEdited(block(item(checked = true))))
}
@Test
fun angehakter_freitext_zaehlt_auch_ohne_eingegebenen_text() {
val b = block(item(checked = true, isFreeText = true, freeTextValue = ""))
assertTrue(BlockEditedPolicy.isEdited(b))
}
@Test
fun angehakter_freitext_mit_text_ist_bearbeitet() {
val b = block(item(checked = true, isFreeText = true, freeTextValue = "Rötung am Wundrand"))
assertTrue(BlockEditedPolicy.isEdited(b))
}
@Test
fun angehakter_eintrag_mit_strukturierter_eingabe_ist_bearbeitet() {
val b = block(item(checked = true, freeTextValue = "3", inputSpec = "spec"))
assertTrue(BlockEditedPolicy.isEdited(b))
}
@Test
fun hinweistext_wird_ignoriert_sobald_ein_echter_eintrag_angehakt_ist() {
val b = block(
item(checked = true, isStaticHint = true),
item(checked = true),
)
assertTrue(BlockEditedPolicy.isEdited(b))
}
@Test
fun mehrere_angehakte_eintraege_sind_bearbeitet() {
val b = block(item(checked = true), item(checked = true), item(checked = false))
assertTrue(BlockEditedPolicy.isEdited(b))
}
// --- Zusammenspiel mit DocumentationBlock.isEdited ------------------------------------
@Test
fun documentation_block_isEdited_delegiert_an_die_policy() {
val edited = block(item(checked = true))
val untouched = block(item(checked = false), item(checked = true, isStaticHint = true))
assertEquals(BlockEditedPolicy.isEdited(edited), edited.isEdited)
assertEquals(BlockEditedPolicy.isEdited(untouched), untouched.isEdited)
assertTrue(edited.isEdited)
assertFalse(untouched.isEdited)
}
@Test
fun isEdited_wird_aus_dem_aktuellen_inhalt_abgeleitet_und_nicht_gespeichert() {
val untouched = block(item(checked = false))
assertFalse(untouched.isEdited)
val nowChecked = untouched.copy(items = untouched.items.map { it.copy(checked = true) })
assertTrue(nowChecked.isEdited)
val uncheckedAgain = nowChecked.copy(items = nowChecked.items.map { it.copy(checked = false) })
assertFalse(uncheckedAgain.isEdited)
}
}
@@ -0,0 +1,80 @@
package de.medapp.wunddoku.media
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Reine JVM-Tests für [ImageDownsampler.computeInSampleSize] - der Kern der speicherschonenden
* Bilddekodierung (Schutz vor OutOfMemoryError bei großen Kamerafotos) und der Robustheit
* gegenüber ungültigen Abmessungen defekter Bilddateien.
*/
class ImageDownsamplerTest {
private val target = ImageDownsampler.MAX_STORED_DIMENSION_PX
// --- ungültige / defekte Abmessungen -------------------------------------------------
@Test
fun ungueltige_abmessungen_defekter_bilder_liefern_1_statt_zu_werfen() {
assertEquals(1, ImageDownsampler.computeInSampleSize(0, 0, target))
assertEquals(1, ImageDownsampler.computeInSampleSize(-1, -1, target))
assertEquals(1, ImageDownsampler.computeInSampleSize(4000, 0, target))
assertEquals(1, ImageDownsampler.computeInSampleSize(0, 4000, target))
assertEquals(1, ImageDownsampler.computeInSampleSize(4000, 3000, 0))
assertEquals(1, ImageDownsampler.computeInSampleSize(4000, 3000, -5))
}
// --- Bilder, die nicht verkleinert werden müssen -------------------------------------
@Test
fun kleine_bilder_werden_nicht_heruntergerechnet() {
assertEquals(1, ImageDownsampler.computeInSampleSize(100, 100, target))
assertEquals(1, ImageDownsampler.computeInSampleSize(target, target, target))
assertEquals(1, ImageDownsampler.computeInSampleSize(1, 1, target))
}
// --- Große Bilder (Speichermangel vermeiden) -----------------------------------------
@Test
fun grosse_kamerafotos_werden_deutlich_heruntergerechnet() {
// 48 MP ≈ 8000 x 6000
val sample = ImageDownsampler.computeInSampleSize(8000, 6000, target)
assertTrue("erwartet >= 2, war $sample", sample >= 2)
}
@Test
fun ergebnis_ist_immer_eine_zweierpotenz_und_haelt_die_zielgroesse_ein() {
val sizes = listOf(1, 2, 99, 100, 1999, 2000, 2001, 3999, 4000, 4001, 6000, 8000, 12000, 20000, 65535)
for (width in sizes) {
for (height in sizes) {
val sample = ImageDownsampler.computeInSampleSize(width, height, target)
val largest = maxOf(width, height)
assertTrue("Zweierpotenz erwartet bei ${width}x$height, war $sample", sample >= 1 && sample and (sample - 1) == 0)
if (largest > target) {
// Nie unter die Zielgröße dekodieren ...
assertTrue("${width}x$height: dekodiert wäre ${largest / sample}", largest / sample >= target)
// ... aber auch nie mehr als doppelt so groß wie nötig (kein Speicher-Verschwenden).
assertTrue("${width}x$height: dekodiert wäre ${largest / sample}", largest / sample < 2 * target)
} else {
assertEquals(1, sample)
}
}
}
}
@Test
fun extrem_grosse_werte_laufen_nicht_ueber() {
val sample = ImageDownsampler.computeInSampleSize(Int.MAX_VALUE, Int.MAX_VALUE, target)
assertTrue(sample >= 1)
assertTrue(sample and (sample - 1) == 0)
}
@Test
fun thumbnail_ziel_ist_kleiner_als_speicherziel() {
val full = ImageDownsampler.computeInSampleSize(2000, 1500, target)
val thumb = ImageDownsampler.computeInSampleSize(2000, 1500, ImageDownsampler.THUMBNAIL_MAX_DIMENSION_PX)
assertTrue(thumb > full)
}
}
@@ -0,0 +1,81 @@
package de.medapp.wunddoku.media
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
/** Datenschutz/Sicherheit: kein Ausbrechen aus dem privaten Ordner per Path-Traversal. */
class SafePathsTest {
@get:Rule
val temp = TemporaryFolder()
private fun assertRejected(name: String) {
val root = temp.newFolder()
try {
SafePaths.resolveChild(root, name)
fail("'$name' hätte abgelehnt werden müssen")
} catch (expected: IllegalArgumentException) {
// erwartet
}
}
@Test
fun normale_namen_werden_unterhalb_des_wurzelordners_aufgeloest() {
val root = temp.newFolder()
val result = SafePaths.resolveChild(root, "3f2a-uuid.jpg")
assertEquals(File(root, "3f2a-uuid.jpg"), result)
assertTrue(result.canonicalPath.startsWith(root.canonicalPath + File.separator))
}
@Test
fun uebliche_namen_mit_punkten_und_bindestrichen_sind_erlaubt() {
val root = temp.newFolder()
SafePaths.resolveChild(root, "2026-08-19_Max.Mustermann.v2.pdf")
SafePaths.resolveChild(root, "..hidden") // beginnt mit Punkten, ist aber kein Traversal
}
@Test
fun path_traversal_wird_abgelehnt() {
assertRejected("..")
assertRejected("../geheim")
assertRejected("../../etc/passwd")
assertRejected("a/../../b")
}
@Test
fun pfadtrenner_und_leere_namen_werden_abgelehnt() {
assertRejected("")
assertRejected(" ")
assertRejected(".")
assertRejected("unter/ordner.jpg")
assertRejected("unter\\ordner.jpg")
assertRejected("/absolut.jpg")
assertRejected("nullbyte\u0000.jpg")
}
@Test
fun symbolischer_link_aus_dem_ordner_heraus_wird_abgelehnt() {
val root = temp.newFolder()
val outside = temp.newFolder()
val link = File(root, "link")
try {
java.nio.file.Files.createSymbolicLink(link.toPath(), outside.toPath())
} catch (e: Exception) {
return // Dateisystem ohne Symlink-Unterstützung: Test nicht anwendbar
}
try {
SafePaths.resolveChild(root, "link")
fail("Symlink aus dem Ordner heraus hätte abgelehnt werden müssen")
} catch (expected: IllegalArgumentException) {
// erwartet
}
}
}
@@ -0,0 +1,391 @@
package de.medapp.wunddoku.pdf
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.DocumentationBlock
import de.medapp.wunddoku.domain.model.DocumentationStatus
import de.medapp.wunddoku.domain.model.PatientStammdatenSnapshot
import de.medapp.wunddoku.domain.model.Photo
import de.medapp.wunddoku.domain.model.Signature
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
/**
* Blockstatus im PDF-Export: Nur bearbeitete Blöcke und nur angehakte Optionen gelangen ins PDF,
* alles wird aus den historischen Snapshots gebildet und stabil sortiert.
*/
class DocumentationToPdfExportModelMapperTest {
private val snapshot = PatientStammdatenSnapshot(
firstName = "Max",
lastName = "Mustermann",
birthDate = LocalDate.of(1980, 3, 14),
street = "Musterstraße 1",
postalCode = "12345",
city = "Musterstadt",
)
// Mittag in der Systemzeitzone -> das lokale Datum ist zeitzonenunabhängig der 23.08.2026.
private val createdAt: Instant =
LocalDate.of(2026, 8, 23).atTime(12, 0).atZone(ZoneId.systemDefault()).toInstant()
private fun item(
id: String,
order: Int,
checked: Boolean,
label: String = "Label $id",
isFreeText: Boolean = false,
freeTextValue: String = "",
isStaticHint: Boolean = false,
inputSpec: String = "",
) = CheckItem(
checkItemStateId = "state-$id",
definitionId = id,
label = label,
order = order,
checked = checked,
isFreeText = isFreeText,
freeTextValue = freeTextValue,
isStaticHint = isStaticHint,
inputSpec = inputSpec,
)
private fun block(id: String, order: Int, title: String = "Titel $id", items: List<CheckItem>) =
DocumentationBlock("state-$id", id, title, order, items)
private fun documentation(
blocks: List<DocumentationBlock>,
photos: List<Photo> = emptyList(),
signatures: List<Signature> = emptyList(),
) = Documentation(
id = "doc-1",
patientId = "patient-1",
patientSnapshot = snapshot,
status = DocumentationStatus.IN_PROGRESS,
createdAt = createdAt,
updatedAt = createdAt,
continuedFromDocumentationId = null,
catalogVersion = 4,
blocks = blocks,
photos = photos,
signatures = signatures,
)
// --- Blockfilter ---------------------------------------------------------------------
@Test
fun nur_bearbeitete_bloecke_werden_exportiert() {
val doc = documentation(
listOf(
block("a", 1, items = listOf(item("a1", 1, checked = true))),
block("b", 2, items = listOf(item("b1", 1, checked = false))),
block("c", 3, items = listOf(item("c1", 1, checked = true))),
)
)
val model = DocumentationToPdfExportModelMapper.map(doc)
assertEquals(listOf("Titel a", "Titel c"), model.blocks.map { it.title })
}
@Test
fun block_mit_nur_hinweistexten_wird_nicht_exportiert() {
val doc = documentation(
listOf(
block(
"hint", 1,
items = listOf(
item("h1", 1, checked = true, isStaticHint = true),
item("h2", 2, checked = false),
),
),
)
)
assertTrue(DocumentationToPdfExportModelMapper.map(doc).blocks.isEmpty())
}
@Test
fun dokumentation_ohne_bearbeiteten_block_ergibt_leere_blockliste() {
val doc = documentation(listOf(block("a", 1, items = listOf(item("a1", 1, checked = false)))))
assertTrue(DocumentationToPdfExportModelMapper.map(doc).blocks.isEmpty())
}
// --- Itemfilter ----------------------------------------------------------------------
@Test
fun nur_angehakte_optionen_erscheinen_im_pdf() {
val doc = documentation(
listOf(
block(
"a", 1,
items = listOf(
item("a1", 1, checked = true, label = "Ja"),
item("a2", 2, checked = false, label = "Nein"),
item("a3", 3, checked = true, label = "Auch ja"),
),
),
)
)
val items = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items
assertEquals(listOf("Ja", "Auch ja"), items.map { it.label })
}
@Test
fun hinweistexte_eines_bearbeiteten_blocks_bleiben_im_pdf_erhalten() {
val doc = documentation(
listOf(
block(
"a", 1,
items = listOf(
item("hint", 1, checked = true, label = "Reinigen mit:", isStaticHint = true),
item("a2", 2, checked = true, label = "Ringer-Lösung"),
),
),
)
)
val items = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items
assertEquals(listOf("Reinigen mit:", "Ringer-Lösung"), items.map { it.label })
}
@Test
fun freitext_und_strukturierte_eingabe_werden_unveraendert_durchgereicht() {
val doc = documentation(
listOf(
block(
"a", 1,
items = listOf(
item("f", 1, checked = true, label = "Freitext", isFreeText = true, freeTextValue = "Rötung"),
item("s", 2, checked = true, label = "Maße", freeTextValue = "3|4", inputSpec = "SPEC"),
),
),
)
)
val items = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items
// Generischer "Freitext": Bezeichnung wird nicht gedruckt (showLabel = false).
assertEquals(
PdfExportItem("Freitext", isFreeText = true, freeTextValue = "Rötung", inputSpec = "", showLabel = false),
items[0],
)
assertEquals(PdfExportItem("Maße", isFreeText = false, freeTextValue = "3|4", inputSpec = "SPEC"), items[1])
}
// --- Änderungswünsche: Freitext, Hinweisgruppen, Stile, eigene Seite ------------------
@Test
fun benannter_freitext_behaelt_seine_bezeichnung() {
val doc = documentation(
listOf(
block("a", 1, items = listOf(
item("w", 1, checked = true, label = "Wundrandschutz", isFreeText = true, freeTextValue = "Cavilon"),
)),
)
)
assertTrue(DocumentationToPdfExportModelMapper.map(doc).blocks.single().items.single().showLabel)
}
@Test
fun gruppenhinweis_ohne_angehakten_eintrag_der_gruppe_entfaellt() {
// Größe: "Zu versorgendes Areal" darf nicht erscheinen, wenn nur die Maße ausgefüllt sind.
val doc = documentation(
listOf(
block("groesse", 1, items = listOf(
item("h_masse", 1, checked = true, label = "Maße", isStaticHint = true),
item("masse", 2, checked = true, label = "Maße (Länge / Breite / Tiefe)", freeTextValue = "3|4|1", inputSpec = "SPEC"),
item("h_areal", 3, checked = true, label = "Zu versorgendes Areal", isStaticHint = true),
item("areal", 4, checked = false, label = "Zu versorgendes Areal"),
item("areal_ft", 5, checked = false, label = "Freitext für zu versorgendes Areal", isFreeText = true),
// generischer Freitext des Blocks gehört zu keiner Gruppe
item("groesse_freetext", 6, checked = false, label = "Freitext", isFreeText = true),
)),
)
)
val labels = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items.map { it.label }
assertEquals(listOf("Maße", "Maße (Länge / Breite / Tiefe)"), labels)
}
@Test
fun generischer_freitext_laesst_gruppenhinweis_nicht_erscheinen() {
val doc = documentation(
listOf(
block("groesse", 1, items = listOf(
item("masse", 1, checked = true, label = "Maße", freeTextValue = "3|4|1", inputSpec = "SPEC"),
item("h_areal", 2, checked = true, label = "Zu versorgendes Areal", isStaticHint = true),
item("areal", 3, checked = false, label = "Areal"),
item("groesse_freetext", 4, checked = true, label = "Freitext", isFreeText = true, freeTextValue = "x"),
)),
)
)
val labels = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items.map { it.label }
assertEquals(listOf("Maße", "Freitext"), labels)
}
@Test
fun gruppenhinweis_mit_angehaktem_eintrag_bleibt_erhalten() {
val doc = documentation(
listOf(
block("a", 1, items = listOf(
item("h", 1, checked = true, label = "Fixieren mit:", isStaticHint = true),
item("m", 2, checked = true, label = "Mullbinde"),
item("k", 3, checked = false, label = "Klebevlies"),
)),
)
)
val labels = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items.map { it.label }
assertEquals(listOf("Fixieren mit:", "Mullbinde"), labels)
}
@Test
fun gruppenhinweis_mit_gleichnamigem_angehaktem_eintrag_wird_nicht_doppelt_gedruckt() {
val doc = documentation(
listOf(
block("a", 1, items = listOf(
item("h", 1, checked = true, label = "Zu versorgendes Areal", isStaticHint = true),
item("areal", 2, checked = true, label = "Zu versorgendes Areal"),
)),
)
)
val labels = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items.map { it.label }
assertEquals(listOf("Zu versorgendes Areal"), labels)
}
@Test
fun abschliessende_hinweise_ohne_folgende_eintraege_bleiben_immer_erhalten() {
val doc = documentation(
listOf(
block("empfohlene_wundversorgung", 1, items = listOf(
item("x", 1, checked = true, label = "Mullbinde"),
item("empfohlene_wundversorgung_hinweis_bedarf", 2, checked = true, label = "+ bei Bedarf", isStaticHint = true),
item("empfohlene_wundversorgung_hinweis_abklaerung", 3, checked = true, label = "Generell: ...", isStaticHint = true),
item("empfohlene_wundversorgung_freetext", 4, checked = false, label = "Freitext", isFreeText = true),
)),
)
)
val labels = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items.map { it.label }
assertEquals(listOf("Mullbinde", "+ bei Bedarf", "Generell: ..."), labels)
}
@Test
fun stile_werden_anhand_der_definitions_id_vergeben() {
val doc = documentation(
listOf(
block("empfohlene_wundversorgung", 1, items = listOf(
item("empfohlene_wundversorgung_hinweis_reinigen_mit", 1, checked = true, label = "Reinigen mit:", isStaticHint = true),
item("empfohlene_wundversorgung_nacl", 2, checked = true, label = "NaCl 0,9%"),
item("empfohlene_wundversorgung_primaer_abdecken_austamponieren", 3, checked = true, label = "Primär Wunde abdecken/austamponieren"),
item("empfohlene_wundversorgung_hinweis_abklaerung", 4, checked = true, label = "Generell: ...", isStaticHint = true),
)),
)
)
val styles = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items.map { it.style }
assertEquals(
listOf(PdfItemStyle.SUBHEADING, PdfItemStyle.NORMAL, PdfItemStyle.SUBHEADING, PdfItemStyle.WARNING),
styles,
)
}
@Test
fun empfohlene_wundversorgung_beginnt_auf_eigener_seite_andere_bloecke_nicht() {
val doc = documentation(
listOf(
block("weitere_ueberlegung", 1, items = listOf(item("a", 1, checked = true))),
block("empfohlene_wundversorgung", 2, items = listOf(item("b", 1, checked = true))),
block("ziel", 3, items = listOf(item("c", 1, checked = true))),
)
)
val blocks = DocumentationToPdfExportModelMapper.map(doc).blocks
assertEquals(listOf(false, true, false), blocks.map { it.startsOnNewPage })
}
// --- Sortierung ----------------------------------------------------------------------
@Test
fun bloecke_und_optionen_werden_nach_order_sortiert() {
val doc = documentation(
listOf(
block("zweiter", 2, items = listOf(item("z1", 1, checked = true))),
block(
"erster", 1,
items = listOf(
item("e2", 2, checked = true, label = "B"),
item("e1", 1, checked = true, label = "A"),
),
),
)
)
val blocks = DocumentationToPdfExportModelMapper.map(doc).blocks
assertEquals(listOf("Titel erster", "Titel zweiter"), blocks.map { it.title })
assertEquals(listOf("A", "B"), blocks.first().items.map { it.label })
}
@Test
fun fotos_und_signaturen_werden_nach_orderIndex_sortiert() {
val photos = listOf(
Photo("p2", "doc-1", "zweites.jpg", 1, createdAt),
Photo("p1", "doc-1", "erstes.jpg", 0, createdAt),
)
val signatures = listOf(
Signature("s2", "doc-1", "Berta", "b.png", 1, createdAt),
Signature("s1", "doc-1", "Anna", "a.png", 0, createdAt),
)
val model = DocumentationToPdfExportModelMapper.map(documentation(emptyList(), photos, signatures))
assertEquals(listOf("erstes.jpg", "zweites.jpg"), model.photos.map { it.fileName })
assertEquals(listOf("Anna", "Berta"), model.signatures.map { it.signerName })
assertEquals(listOf("a.png", "b.png"), model.signatures.map { it.fileName })
}
@Test
fun alle_signaturen_werden_exportiert_ohne_obergrenze() {
val signatures = (0 until 5).map { Signature("s$it", "doc-1", "Person $it", "s$it.png", it, createdAt) }
val model = DocumentationToPdfExportModelMapper.map(documentation(emptyList(), signatures = signatures))
assertEquals(5, model.signatures.size)
}
// --- Kopfdaten (Snapshot) ------------------------------------------------------------
@Test
fun kopfdaten_stammen_aus_dem_patienten_snapshot_der_dokumentation() {
val model = DocumentationToPdfExportModelMapper.map(documentation(emptyList()))
assertEquals("doc-1", model.documentationId)
assertEquals("Max Mustermann", model.patientFullName)
assertEquals(LocalDate.of(1980, 3, 14), model.patientBirthDate)
assertEquals("Musterstraße 1, 12345 Musterstadt", model.patientDisplayAddress)
assertEquals(LocalDate.of(2026, 8, 23), model.createdAt)
}
@Test
fun dateiname_folgt_dem_schema_und_nutzt_snapshot_und_erstelldatum() {
val model = DocumentationToPdfExportModelMapper.map(documentation(emptyList()))
assertEquals("2026-08-23_Max_Mustermann_1980-03-14.pdf", model.fileName)
}
}
@@ -0,0 +1,106 @@
package de.medapp.wunddoku.pdf
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Reine JVM-Tests für die Fehlermeldungen des PDF-Bereichs: jede Fehlerart hat eine
* verständliche deutsche Meldung, und technische Details (Pfade, Exception-Texte - können
* Patientennamen enthalten) gelangen nie in die Anzeige.
*/
class ErrorMessagesTest {
private val sensitive = "/data/user/0/de.medapp.wunddoku/files/pdf/2026-08-19_Max_Mustermann_1980-03-14.pdf"
private val exportErrors: List<PdfExportError> = listOf(
PdfExportError.DocumentationNotFound,
PdfExportError.DirectoryCreationFailed(sensitive),
PdfExportError.WriteFailed(sensitive),
PdfExportError.Unexpected(sensitive),
PdfExportError.OutOfMemory,
PdfExportError.InsufficientStorage,
)
private val openFailures: List<PdfOpenResult.Failure> = listOf(
PdfOpenResult.Failure.FileNotFound,
PdfOpenResult.Failure.UriResolutionFailed(sensitive),
PdfOpenResult.Failure.NoViewerAvailable,
PdfOpenResult.Failure.Unexpected(sensitive),
)
private val shareFailures: List<PdfShareResult.Failure> = listOf(
PdfShareResult.Failure.UriResolutionFailed(sensitive),
PdfShareResult.Failure.NoAppAvailable,
PdfShareResult.Failure.Unexpected(sensitive),
)
private val imageShareFailures: List<PdfImageShareResult.Failure> = listOf(
PdfImageShareResult.Failure.NoImages,
PdfImageShareResult.Failure.UriResolutionFailed(sensitive),
PdfImageShareResult.Failure.NoAppAvailable,
PdfImageShareResult.Failure.Unexpected(sensitive),
)
private val imageConversionFailures: List<PdfToImagesResult.Failure> = listOf(
PdfToImagesResult.Failure.FileNotFound,
PdfToImagesResult.Failure.EmptyDocument,
PdfToImagesResult.Failure.Unexpected(sensitive),
PdfToImagesResult.Failure.OutOfMemory,
PdfToImagesResult.Failure.InsufficientStorage,
)
private fun assertSafe(message: String) {
assertTrue("Meldung darf nicht leer sein", message.isNotBlank())
assertFalse("Meldung enthält Pfad/Patientendaten: $message", message.contains("Mustermann"))
assertFalse("Meldung enthält Pfad: $message", message.contains("/data/"))
assertFalse("Meldung enthält Dateiendung/Pfad: $message", message.contains(".pdf"))
}
@Test
fun alle_pdf_export_fehler_haben_eine_sichere_meldung() {
exportErrors.forEach { assertSafe(it.toUserMessage()) }
}
@Test
fun alle_oeffnen_fehler_haben_eine_sichere_meldung() {
openFailures.forEach { assertSafe(it.toUserMessage()) }
}
@Test
fun alle_pdf_teilen_fehler_haben_eine_sichere_meldung() {
shareFailures.forEach { assertSafe(it.toUserMessage()) }
}
@Test
fun alle_bilder_teilen_fehler_haben_eine_sichere_meldung() {
imageShareFailures.forEach { assertSafe(it.toUserMessage()) }
}
@Test
fun alle_bildkonvertierungs_fehler_haben_eine_sichere_meldung() {
imageConversionFailures.forEach { assertSafe(it.toUserMessage()) }
}
@Test
fun speichermangel_hat_eine_eigene_aussagekraeftige_meldung() {
assertNotEquals(PdfExportError.OutOfMemory.toUserMessage(), PdfExportError.InsufficientStorage.toUserMessage())
assertNotEquals(
PdfExportError.OutOfMemory.toUserMessage(),
PdfExportError.Unexpected("x").toUserMessage(),
)
assertNotEquals(
PdfToImagesResult.Failure.OutOfMemory.toUserMessage(),
PdfToImagesResult.Failure.InsufficientStorage.toUserMessage(),
)
assertTrue(PdfExportError.InsufficientStorage.toUserMessage().contains("Speicher"))
assertTrue(PdfExportError.OutOfMemory.toUserMessage().contains("Arbeitsspeicher"))
}
@Test
fun unterschiedliche_fehlerarten_erhalten_unterschiedliche_meldungen() {
val messages = exportErrors.map { it.toUserMessage() }
assertTrue(messages.toSet().size == messages.size)
}
}
@@ -0,0 +1,157 @@
package de.medapp.wunddoku.pdf
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
/**
* Datenschutz-Löschkonzept: PDF-/Bildexporte (enthalten Patientendaten) und liegengebliebene
* Kameraaufnahmen dürfen nicht dauerhaft im Speicher bleiben.
*/
class ExportedFilesCleanerTest {
@get:Rule
val temp = TemporaryFolder()
private lateinit var filesDir: File
private lateinit var cacheDir: File
private val day = 24L * 60 * 60 * 1000
private val now = 1_800_000_000_000L // fester "Jetzt"-Zeitpunkt
@Before
fun setUp() {
filesDir = temp.newFolder("files")
cacheDir = temp.newFolder("cache")
}
private fun cleaner() = InternalExportedFilesCleaner(filesDir, cacheDir, now = { now })
private fun file(path: File, ageMillis: Long = 0): File {
path.parentFile!!.mkdirs()
path.writeText("x")
path.setLastModified(now - ageMillis)
return path
}
private fun pdf(docId: String, ageMillis: Long = 0) = file(File(filesDir, "pdf/$docId/bericht.pdf"), ageMillis)
private fun image(docId: String, ageMillis: Long = 0) = file(File(filesDir, "pdf_images/$docId/seite_01.jpg"), ageMillis)
private fun capture(name: String, ageMillis: Long) = file(File(cacheDir, "camera/$name"), ageMillis)
// --- Löschen mit der Dokumentation ---------------------------------------------------
@Test
fun deleteExportsFor_entfernt_pdf_und_bildexporte_der_dokumentation() = runBlocking {
val p = pdf("doc-1")
val i = image("doc-1")
cleaner().deleteExportsFor("doc-1")
assertFalse(p.exists())
assertFalse(i.exists())
assertFalse(File(filesDir, "pdf/doc-1").exists())
}
@Test
fun deleteExportsFor_laesst_andere_dokumentationen_unberuehrt() = runBlocking {
val other = pdf("doc-2")
cleaner().deleteExportsFor("doc-1")
assertTrue(other.exists())
}
@Test
fun deleteExportsFor_ohne_vorhandene_ordner_wirft_nicht() = runBlocking {
cleaner().deleteExportsFor("gibt-es-nicht")
}
@Test
fun deleteExportsFor_loescht_nie_ausserhalb_der_exportordner() = runBlocking {
val secret = file(File(filesDir, "secret/patientendaten.db"))
val photos = file(File(filesDir, "photos/doc-1/foto.jpg"))
File(filesDir, "pdf").mkdirs()
cleaner().deleteExportsFor("../secret")
cleaner().deleteExportsFor("..")
cleaner().deleteExportsFor("../photos/doc-1")
assertTrue(secret.exists())
assertTrue(photos.exists())
}
@Test
fun deleteExportsFor_beruehrt_fotos_und_signaturen_der_dokumentation_nicht() = runBlocking {
val photo = file(File(filesDir, "photos/doc-1/foto.jpg"))
val signature = file(File(filesDir, "signatures/doc-1/sig.png"))
pdf("doc-1")
cleaner().deleteExportsFor("doc-1")
assertTrue("Löschen der Exporte ist nicht Löschen der Dokumentation", photo.exists() && signature.exists())
}
// --- Aufbewahrungsfristen ------------------------------------------------------------
@Test
fun deleteStaleFiles_entfernt_exporte_aelter_als_7_tage_und_behaelt_frische() = runBlocking {
val old = pdf("doc-old", ageMillis = 8 * day)
val oldImage = image("doc-old", ageMillis = 8 * day)
val fresh = pdf("doc-fresh", ageMillis = 1 * day)
cleaner().deleteStaleFiles()
assertFalse(old.exists())
assertFalse(oldImage.exists())
assertTrue(fresh.exists())
}
@Test
fun deleteStaleFiles_entfernt_leere_unterordner() = runBlocking {
pdf("doc-old", ageMillis = 30 * day)
cleaner().deleteStaleFiles()
assertFalse(File(filesDir, "pdf/doc-old").exists())
}
@Test
fun deleteStaleFiles_entfernt_liegengebliebene_kameraaufnahmen_nach_24_stunden() = runBlocking {
val stale = capture("capture_old.jpg", ageMillis = day + 60_000)
val recent = capture("capture_new.jpg", ageMillis = 2 * 60 * 60 * 1000)
cleaner().deleteStaleFiles()
assertFalse("Wundfoto darf nicht dauerhaft im Cache liegen bleiben", stale.exists())
assertTrue("aktuelle Aufnahme wird eventuell noch importiert", recent.exists())
}
@Test
fun deleteStaleFiles_beruehrt_dokumentationsdaten_nie() = runBlocking {
val photo = file(File(filesDir, "photos/doc-1/foto.jpg"), ageMillis = 365 * day)
val signature = file(File(filesDir, "signatures/doc-1/sig.png"), ageMillis = 365 * day)
cleaner().deleteStaleFiles()
assertTrue("Dokumentationsfotos/-signaturen sind keine Exporte", photo.exists() && signature.exists())
}
@Test
fun deleteStaleFiles_ohne_vorhandene_ordner_wirft_nicht() = runBlocking {
cleaner().deleteStaleFiles()
}
@Test
fun none_cleaner_tut_nichts() = runBlocking {
val p = pdf("doc-1", ageMillis = 100 * day)
ExportedFilesCleaner.None.deleteExportsFor("doc-1")
ExportedFilesCleaner.None.deleteStaleFiles()
assertTrue(p.exists())
}
}
@@ -0,0 +1,175 @@
package de.medapp.wunddoku.pdf
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
/**
* Reine JVM-Tests (keine Android-Abhängigkeit) für das Dateinamensschema
* `Erstelldatum_Vorname_Nachname_Geburtsdatum.pdf`.
*/
class PdfFileNameGeneratorTest {
private val created = LocalDate.of(2026, 8, 23)
private val birth = LocalDate.of(1980, 3, 14)
private fun name(
first: String,
last: String,
createdAt: LocalDate = created,
birthDate: LocalDate = birth,
): String = PdfFileNameGenerator.generate(createdAt, first, last, birthDate)
// --- Grundschema ---------------------------------------------------------------------
@Test
fun standardfall_folgt_dem_vereinbarten_schema() {
assertEquals("2026-08-23_Max_Mustermann_1980-03-14.pdf", name("Max", "Mustermann"))
}
@Test
fun datumsteile_werden_mit_fuehrenden_nullen_im_iso_format_ausgegeben() {
val result = name("Max", "Mustermann", createdAt = LocalDate.of(2026, 1, 5), birthDate = LocalDate.of(1999, 2, 3))
assertEquals("2026-01-05_Max_Mustermann_1999-02-03.pdf", result)
}
@Test
fun schaltjahr_geburtsdatum_wird_korrekt_ausgegeben() {
val result = name("Max", "Mustermann", birthDate = LocalDate.of(2000, 2, 29))
assertEquals("2026-08-23_Max_Mustermann_2000-02-29.pdf", result)
}
@Test
fun ergebnis_ist_deterministisch() {
assertEquals(name("Max", "Mustermann"), name("Max", "Mustermann"))
}
@Test
fun verschiedene_erstelldaten_fuehren_zu_verschiedenen_dateinamen() {
val a = name("Max", "Mustermann", createdAt = LocalDate.of(2026, 8, 23))
val b = name("Max", "Mustermann", createdAt = LocalDate.of(2026, 8, 24))
assertNotEquals(a, b)
}
// --- Umlaute / Diakritika ------------------------------------------------------------
@Test
fun deutsche_umlaute_werden_transliteriert_statt_entfernt() {
assertEquals("2026-08-23_Max_Mueller_1980-03-14.pdf", name("Max", "Müller"))
assertEquals("2026-08-23_Aenne_Oezdemir_1980-03-14.pdf", name("Änne", "Özdemir"))
assertEquals("2026-08-23_Uwe_Ueberall_1980-03-14.pdf", name("Uwe", "Überall"))
}
@Test
fun eszett_wird_zu_doppel_s() {
assertEquals("2026-08-23_Max_Weiss_1980-03-14.pdf", name("Max", "Weiß"))
}
@Test
fun mueller_und_mller_bleiben_unterscheidbar() {
assertNotEquals(name("Max", "Müller"), name("Max", "Mller"))
}
@Test
fun andere_diakritika_werden_entfernt() {
assertEquals("2026-08-23_Jose_Celik_1980-03-14.pdf", name("José", "Çelik"))
}
// --- Leerzeichen / Sonderzeichen -----------------------------------------------------
@Test
fun leerzeichen_werden_zu_unterstrichen() {
assertEquals("2026-08-23_Anna_Maria_Mustermann_1980-03-14.pdf", name("Anna Maria", "Mustermann"))
}
@Test
fun mehrfaches_whitespace_wird_zu_einem_unterstrich_zusammengefasst() {
assertEquals("2026-08-23_Anna_Maria_Mustermann_1980-03-14.pdf", name("Anna Maria", "Mustermann"))
assertEquals("2026-08-23_Anna_Maria_Mustermann_1980-03-14.pdf", name("Anna\t\nMaria", "Mustermann"))
}
@Test
fun mehrfache_unterstriche_werden_zusammengefasst() {
assertEquals("2026-08-23_Max_Muster_mann_1980-03-14.pdf", name("Max", "Muster__mann"))
}
@Test
fun fuehrende_und_nachgestellte_trennzeichen_werden_entfernt() {
val expected = "2026-08-23_Max_Mustermann_1980-03-14.pdf"
assertEquals(expected, name(" Max ", "Mustermann"))
assertEquals(expected, name("_Max_", "Mustermann"))
assertEquals(expected, name("-Max-", "Mustermann"))
}
@Test
fun bindestrich_im_doppelnamen_bleibt_erhalten() {
assertEquals("2026-08-23_Aenne-Marie_Meyer-Luedenscheidt_1980-03-14.pdf", name("Änne-Marie", "Meyer-Lüdenscheidt"))
}
@Test
fun apostroph_und_punkt_werden_entfernt() {
assertEquals("2026-08-23_Sean_OBrien_1980-03-14.pdf", name("Sean", "O'Brien"))
assertEquals("2026-08-23_Max_Dr_Mustermann_1980-03-14.pdf", name("Max", "Dr. Mustermann"))
}
// --- Sicherheit (Pfad-/Dateisystemzeichen) --------------------------------------------
@Test
fun pfadseparatoren_und_punkte_koennen_keinen_pfad_erzeugen() {
assertEquals(
"2026-08-23_Unbekannt_etcpasswd_1980-03-14.pdf",
name("../..", "../etc/passwd"),
)
}
@Test
fun fuer_dateinamen_ungueltige_zeichen_werden_entfernt() {
assertEquals(
"2026-08-23_MaxMuster_Mustermann_1980-03-14.pdf",
name("Max:Mus*ter?", "Mustermann"),
)
assertEquals(
"2026-08-23_Max_Mustermann_1980-03-14.pdf",
name("Ma<x>|\"", "Muster\\mann"),
)
}
@Test
fun emojis_werden_entfernt() {
assertEquals("2026-08-23_Max_Mustermann_1980-03-14.pdf", name("Max😀", "Mustermann"))
}
@Test
fun dateiname_besteht_immer_nur_aus_sicheren_zeichen_und_endet_auf_pdf() {
val inputs = listOf(
"Max" to "Mustermann",
"Änne-Marie" to "Özdemir Weiß",
"José" to "O'Brien",
"../../x" to "a\\b/c",
" " to "",
"😀" to "???",
"Ma<x>|\"" to "Muster\\mann",
)
val allowed = Regex("^[A-Za-z0-9_.-]+$")
for ((first, last) in inputs) {
val result = name(first, last)
assertTrue("Unsicherer Dateiname: $result", allowed.matches(result))
assertTrue("Falsche Endung: $result", result.endsWith(".pdf"))
}
}
// --- Platzhalter für leere Namen -----------------------------------------------------
@Test
fun leerer_oder_blanker_name_wird_zu_unbekannt() {
assertEquals("2026-08-23_Unbekannt_Mustermann_1980-03-14.pdf", name("", "Mustermann"))
assertEquals("2026-08-23_Max_Unbekannt_1980-03-14.pdf", name("Max", " "))
}
@Test
fun name_nur_aus_ungueltigen_zeichen_wird_zu_unbekannt() {
assertEquals("2026-08-23_Unbekannt_Unbekannt_1980-03-14.pdf", name("???", "***"))
}
}
@@ -0,0 +1,219 @@
package de.medapp.wunddoku.privacy
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
/**
* Datenschutz-Regressionstests (QA-006) auf Quelltext- und Konfigurationsebene.
*
* Läuft als reiner JVM-Test ohne Gerät und liest die Projektdateien direkt. Er soll verhindern,
* dass später versehentlich etwas eingebaut wird, das die Datenschutz-Zusagen der App bricht
* (Netzwerkzugriff, sensible Logs, öffentlicher Speicher, Backup, zu weite Freigaben).
* Schlägt ein Test fehl, ist das ein Hinweis zum Prüfen - nicht zwingend ein Fehler: dann
* bewusst entscheiden und die Datenschutz-Dokumentation anpassen.
*/
class PrivacySourceScanTest {
private lateinit var mainDir: File
@Before
fun locateSources() {
mainDir = listOf(File("src/main"), File("app/src/main"))
.firstOrNull { File(it, "AndroidManifest.xml").exists() }
?: File("")
assumeTrue("Quellverzeichnis nicht gefunden (Arbeitsverzeichnis: ${File(".").absolutePath})", mainDir.path.isNotEmpty())
}
// --- Hilfsfunktionen -----------------------------------------------------------------
private fun kotlinSources(): List<Pair<String, String>> =
File(mainDir, "java").walkTopDown()
.filter { it.isFile && it.extension == "kt" }
.map { it.relativeTo(mainDir).path to stripComments(it.readText()) }
.toList()
/** Entfernt Kommentare, damit Dokumentationstext keine Fehlalarme auslöst. */
private fun stripComments(source: String): String =
source
.replace(Regex("/\\*[\\s\\S]*?\\*/"), "")
.replace(Regex("//[^\\n]*"), "")
private fun findings(pattern: Regex, sources: List<Pair<String, String>> = kotlinSources()): List<String> =
sources.flatMap { (path, text) ->
pattern.findAll(text).map { "$path: ${it.value}" }.toList()
}
private fun assertNone(description: String, pattern: Regex) {
val found = findings(pattern)
assertTrue("$description - gefunden: $found", found.isEmpty())
}
private fun parseXml(relativePath: String) =
DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(File(mainDir, relativePath))
// --- Logging -------------------------------------------------------------------------
@Test
fun keine_konsolenausgaben_oder_stacktrace_ausgaben() {
assertNone(
"println/printStackTrace/System.out/Timber können Patientendaten in Logs schreiben",
Regex("""\bprintln\s*\(|printStackTrace\s*\(|System\.(out|err)\b|\bTimber\b"""),
)
}
@Test
fun log_aufrufe_enthalten_keine_nachrichten_pfade_uris_oder_personendaten() {
val callStart = Regex("""\bLog\s*\.\s*(v|d|i|w|e|wtf)\s*\(""")
val forbidden = Regex(
"""(?i)\.message\b|localizedMessage|stackTraceToString|getStackTraceString|\buri\b|\bpath\b|absolutePath|fileName|patient|firstName|lastName|birthDate|signer|street|phone"""
)
val violations = mutableListOf<String>()
for ((path, text) in kotlinSources()) {
for (match in callStart.findAll(text)) {
val openIndex = match.range.last
var depth = 0
var end = openIndex
for (i in openIndex until text.length) {
when (text[i]) {
'(' -> depth++
')' -> { depth--; if (depth == 0) { end = i; break } }
}
}
val call = text.substring(match.range.first, end + 1)
if (forbidden.containsMatchIn(call)) violations += "$path: ${call.replace(Regex("\\s+"), " ")}"
}
}
assertTrue("Log-Aufrufe mit potenziell sensiblem Inhalt: $violations", violations.isEmpty())
}
// --- Speicherorte --------------------------------------------------------------------
@Test
fun kein_zugriff_auf_oeffentlichen_oder_externen_speicher() {
assertNone(
"Patientendaten dürfen nur im privaten internen Speicher liegen",
Regex(
"""getExternalFilesDir|getExternalFilesDirs|externalCacheDir|externalMediaDirs|getExternalStorage|Environment\.getExternal|MediaStore\.|WRITE_EXTERNAL_STORAGE|READ_EXTERNAL_STORAGE|MANAGE_EXTERNAL_STORAGE|MODE_WORLD_READABLE|MODE_WORLD_WRITEABLE|DIRECTORY_(PICTURES|DOWNLOADS|DOCUMENTS)"""
),
)
}
@Test
fun keine_nutzung_der_zwischenablage() {
assertNone(
"Die Zwischenablage ist für andere Apps lesbar",
Regex("""ClipboardManager|setPrimaryClip|ClipboardManager"""),
)
}
@Test
fun keine_personendaten_als_text_oder_betreff_in_share_intents() {
assertNone(
"Share-Intents dürfen nur die PDF/Bilder (EXTRA_STREAM), keinen Text mit Patientendaten tragen",
Regex("""EXTRA_TEXT|EXTRA_SUBJECT|EXTRA_EMAIL|EXTRA_CC|EXTRA_BCC|EXTRA_HTML_TEXT"""),
)
}
// --- Netzwerk ------------------------------------------------------------------------
@Test
fun kein_netzwerkcode_im_quelltext() {
assertNone(
"Die App arbeitet offline - kein Netzwerkzugriff",
Regex("""HttpURLConnection|HttpsURLConnection|java\.net\.URL\b|URLConnection|\bSocket\s*\(|okhttp|retrofit|\bktor\b|WebView|firebase|crashlytics|analytics""", RegexOption.IGNORE_CASE),
)
}
@Test
fun keine_netzwerk_bibliotheken_in_den_abhaengigkeiten() {
val gradle = listOf(File("app/build.gradle.kts"), File("build.gradle.kts")).firstOrNull { it.exists() }
assumeTrue("build.gradle.kts nicht gefunden", gradle != null)
val dependencies = gradle!!.readLines()
.filter { it.trim().startsWith("implementation") || it.trim().startsWith("api(") }
val forbidden = Regex("""okhttp|retrofit|\bktor\b|volley|firebase|crashlytics|analytics|sentry|coil|glide|picasso|play-services""", RegexOption.IGNORE_CASE)
val found = dependencies.filter { forbidden.containsMatchIn(it) }
assertTrue("Abhängigkeiten mit Netzwerk-/Tracking-Bezug: $found", found.isEmpty())
}
// --- Manifest ------------------------------------------------------------------------
private fun manifest(): String = File(mainDir, "AndroidManifest.xml").readText()
@Test
fun manifest_fordert_keine_berechtigungen_an() {
val permissions = Regex("""<uses-permission[^>]*>""").findAll(manifest()).map { it.value }.toList()
val requested = permissions.filterNot { it.contains("tools:node=\"remove\"") }
assertTrue("Unnötige Berechtigungen im Manifest: $requested", requested.isEmpty())
}
@Test
fun manifest_entfernt_die_internet_berechtigung_explizit() {
val internet = Regex("""<uses-permission[^>]*android\.permission\.INTERNET[^>]*>""").find(manifest())
assertTrue("INTERNET muss per tools:node=\"remove\" ausgeschlossen sein", internet != null && internet.value.contains("tools:node=\"remove\""))
}
@Test
fun manifest_deaktiviert_backup_und_verlangt_keine_cleartext_oder_debug_freigaben() {
val text = manifest()
assertTrue("allowBackup muss false sein", text.contains("""android:allowBackup="false""""))
assertTrue("dataExtractionRules müssen gesetzt sein", text.contains("android:dataExtractionRules="))
assertTrue(!text.contains("""android:usesCleartextTraffic="true""""))
assertTrue(!text.contains("android:debuggable"))
assertTrue(!text.contains("""android:allowBackup="true""""))
}
@Test
fun nur_die_start_activity_ist_exportiert_und_der_fileprovider_nicht() {
val text = manifest()
assertEquals(
"Genau eine Komponente (MainActivity) darf exportiert sein",
1,
Regex("""android:exported="true"""").findAll(text).count(),
)
val provider = Regex("""<provider[\s\S]*?</provider>""").find(text)!!.value
assertTrue(provider.contains("""android:exported="false""""))
assertTrue(provider.contains("""android:grantUriPermissions="true""""))
}
// --- Backup-Regeln -------------------------------------------------------------------
@Test
fun data_extraction_rules_schliessen_alle_daten_aus_cloud_backup_und_geraetetransfer_aus() {
val doc = parseXml("res/xml/data_extraction_rules.xml")
for (section in listOf("cloud-backup", "device-transfer")) {
val nodes = doc.getElementsByTagName(section)
assertEquals("<$section> muss vorhanden sein", 1, nodes.length)
val element = nodes.item(0) as org.w3c.dom.Element
assertEquals("<$section> darf kein <include> enthalten", 0, element.getElementsByTagName("include").length)
val excluded = (0 until element.getElementsByTagName("exclude").length)
.map { (element.getElementsByTagName("exclude").item(it) as org.w3c.dom.Element).getAttribute("domain") }
.toSet()
assertTrue("<$section> muss database, file, sharedpref ausschließen, war $excluded", excluded.containsAll(setOf("database", "file", "sharedpref")))
}
}
// --- FileProvider-Freigaben ----------------------------------------------------------
@Test
fun fileprovider_gibt_nur_export_und_kameraordner_frei() {
val doc = parseXml("res/xml/file_paths.xml")
val paths = doc.getElementsByTagName("paths").item(0).childNodes
val entries = (0 until paths.length).map { paths.item(it) }
.filterIsInstance<org.w3c.dom.Element>()
.map { it.tagName to it.getAttribute("path") }
val allowed = setOf("files-path" to "pdf/", "files-path" to "pdf_images/", "cache-path" to "camera/")
val unexpected = entries.filterNot { it in allowed }
assertTrue(
"Zu weite FileProvider-Freigaben (nur $allowed erlaubt): $unexpected",
unexpected.isEmpty(),
)
}
}
+212
View File
@@ -0,0 +1,212 @@
# VulneraDoc – Datenschutz- und Datensicherheits-Dokumentation
Stand: Datenschutz-Review QA-006 (Projektstand `dev-QA004` + Maßnahmen aus diesem Review)
> **Hinweis:** Dieses Dokument beschreibt die *technische* Umsetzung des Datenschutzes in der App
> (Datenflüsse, Speicherorte, Schutzmaßnahmen). Es ist keine Rechtsberatung. Die rechtliche
> Bewertung (Rechtsgrundlage, Verzeichnis der Verarbeitungstätigkeiten, ggf. Datenschutz-
> Folgenabschätzung, Auftragsverarbeitung, Einwilligungen) muss durch die verantwortliche Stelle
> bzw. deren Datenschutzbeauftragte erfolgen. Die Abschnitte 9 und 10 liefern dafür die
> technischen Eingangsdaten.
---
## 1. Zweck und Einordnung
VulneraDoc ist eine **Offline-Android-App zur Wunddokumentation**. Pflegekräfte erfassen Patienten,
dokumentieren Wunden (Checklisten, Freitext, Fotos, Unterschriften) und erzeugen daraus ein PDF,
das sie öffnen oder teilen können.
Verarbeitet werden **Gesundheitsdaten** (Wundbefunde, Wundfotos) und weitere personenbezogene Daten.
Gesundheitsdaten zählen nach Art. 9 DSGVO zu den besonderen Kategorien personenbezogener Daten und
verlangen ein erhöhtes Schutzniveau.
**Grundprinzip der App:** Die Daten verlassen das Gerät **ausschließlich auf ausdrückliche
Handlung des Nutzers** (PDF/Bilder öffnen oder teilen). Es gibt keinen Server, keine Cloud, kein
Konto, keine Analyse- oder Absturzberichts-Dienste.
---
## 2. Verarbeitete Daten
| Datenart | Felder / Inhalt | Speicherort (App-intern) |
|---|---|---|
| Patientenstammdaten | Vorname, Nachname, Geburtsdatum, Straße, PLZ, Ort, bis zu 3 Telefonnummern | Datenbank `wunddoku.db` |
| Dokumentation | Erstelldatum, Checklisten-Zustände, Freitexte (Wundbefund, Maßnahmen) | Datenbank |
| Wundfotos | Fotos aus Kamera bzw. Photo Picker | `files/photos/<Dokumentations-ID>/` |
| Unterschriften | Name des Unterzeichners, Unterschrift als PNG | Datenbank + `files/signatures/<Dokumentations-ID>/` |
| Wundversorger-Register | Name und Unterschrift der Pflegekraft (für Wiederverwendung) | Datenbank + `files/wound_carer_signatures/` |
| PDF-Export | Vollständiger Bericht inkl. Stammdaten, Befund, Fotos, Unterschriften | `files/pdf/<Dokumentations-ID>/` |
| Bildexport | Seiten des PDFs als JPEG (zum Teilen als Bilder) | `files/pdf_images/<Dokumentations-ID>/` |
| Kameraaufnahme (temporär) | Foto bis zum Import in die Dokumentation | `cache/camera/` |
Alle Speicherorte liegen im **privaten internen App-Speicher**. Es wird kein öffentlicher
Speicher (Galerie, Downloads, SD-Karte) beschrieben.
**Hinweis Dateiname:** Das PDF heißt `Erstelldatum_Vorname_Nachname_Geburtsdatum.pdf` und enthält
damit personenbezogene Daten. Der Name wird beim Teilen/Öffnen an die gewählte Ziel-App übergeben
(siehe Abschnitt 6, Restrisiko R4).
---
## 3. Berechtigungen
Die App fordert **keine einzige Berechtigung** an.
| Funktion | Technik | Berechtigung nötig? |
|---|---|---|
| Fotos auswählen | System-Photo-Picker | Nein |
| Foto aufnehmen | System-Kamera-App über `ACTION_IMAGE_CAPTURE` (die App greift nicht selbst auf die Kamera zu) | Nein (`CAMERA` nicht deklariert) |
| PDF speichern | Privater App-Speicher | Nein |
| PDF öffnen/teilen | `FileProvider` + Android-Sharesheet | Nein |
| Netzwerk | nicht vorhanden | `INTERNET` ist im Manifest **ausdrücklich entfernt** (`tools:node="remove"`) |
Durchsetzung: Das Manifest entfernt `INTERNET` und `ACCESS_NETWORK_STATE` beim Zusammenführen mit
Bibliotheks-Manifesten. Der Test `PrivacyConfigurationTest` prüft das fertig zusammengeführte
Manifest auf dem Gerät.
---
## 4. Datenflüsse nach außen
Daten verlassen das Gerät nur in diesen Fällen – jeweils durch eine bewusste Aktion des Nutzers:
1. **PDF öffnen** – Übergabe der PDF per `content://`-URI mit *Lese*recht an die gewählte PDF-App.
2. **PDF teilen / Bilder teilen** – Übergabe per Android-Sharesheet mit *Lese*recht, nur
`EXTRA_STREAM` (die Datei). Es wird **kein** Text, Betreff oder Empfänger mit Patientendaten
vorbelegt.
3. **Fotoimport** – Fotos werden *in* die App kopiert (Photo Picker / Kamera); die Originale
verbleiben dort, wo sie der Nutzer gespeichert hat. Beim Import wird das Foto neu als JPEG
kodiert; Metadaten des Originals (z. B. GPS-Position) werden dadurch **nicht** übernommen.
Nicht vorhanden: Netzwerkzugriff, Cloud-Backup, Geräte-zu-Geräte-Transfer, Analyse/Tracking,
Absturzberichte, Werbung, Zwischenablage, Weitergabe an Dritte durch die App selbst.
**Verantwortung nach dem Teilen:** Sobald eine PDF an eine andere App übergeben wurde, liegt der
weitere Umgang außerhalb der Kontrolle von VulneraDoc (z. B. E-Mail-Programm, Messenger,
Cloud-Speicher). Das ist organisatorisch zu regeln (Abschnitt 9).
---
## 5. Technische und organisatorische Schutzmaßnahmen in der App
### 5.1 Speicherung
- Ausschließlich privater interner Speicher (`filesDir`, `cacheDir`); kein `getExternalFilesDir`,
keine `MediaStore`-Nutzung, keine `MODE_WORLD_*`-Dateien.
- Dateien sind für andere Apps nicht lesbar (Android-App-Sandbox, Dateirechte).
- **Pfadsicherheit:** Datei-/Ordnernamen werden über `SafePaths` aufgelöst (kein `../`, keine
Pfadtrenner, keine Symlink-Ausbrüche).
- **Atomares Schreiben der PDF:** Erst temporäre Datei, dann Umbenennen – kein halbfertiges PDF,
eine vorhandene PDF geht bei einem Fehler nicht verloren.
### 5.2 Freigabe über FileProvider (Prinzip der minimalen Freigabe)
Nur diese Ordner können nach außen gegeben werden:
`files/pdf/`, `files/pdf_images/` (Exporte) und `cache/camera/` (Kameraaufnahme).
**Nicht** freigegeben: Originalfotos (`photos/`), Unterschriften (`signatures/`), Datenbank,
Wundversorger-Register. Der `FileProvider` ist nicht exportiert (`exported="false"`), Rechte werden
nur pro Vorgang als Leserecht vergeben.
### 5.3 Backup
- `android:allowBackup="false"` – kein Cloud-/Auto-Backup.
- `data_extraction_rules.xml` schließt für `cloud-backup` **und** `device-transfer` die Bereiche
`database`, `file` und `sharedpref` aus; es gibt kein `<include>`.
- Folge: Beim Gerätewechsel oder nach Deinstallation sind die Daten **nicht** automatisch
wiederherstellbar. Das ist gewollt (Datenschutz), muss aber organisatorisch bekannt sein
(z. B. PDFs als Archiv in der Einrichtungsdokumentation ablegen).
### 5.4 Oberfläche
- `FLAG_SECURE` im Hauptfenster: keine Screenshots, keine Bildschirmaufnahme, keine Vorschau im
App-Wechsler.
### 5.5 Protokollierung (Logs)
- Es gibt keine Konsolenausgaben und keinen `printStackTrace`-Aufruf.
- Es existiert genau ein Log-Aufruf (Fehler beim Fotoimport). Er schreibt **nur den Typ** der
Exception, nie die Fehlermeldung (diese kann Dateipfade oder URIs enthalten), nie Namen oder
Patientendaten.
- Fehlermeldungen in der Oberfläche sind allgemein formuliert und enthalten keine Pfade oder
Exception-Texte.
### 5.6 Löschkonzept
| Auslöser | Was wird gelöscht |
|---|---|
| Dokumentation löschen | Datenbankzeilen (inkl. Checklisten, Foto- und Signatur-Einträge), Fotos, Signaturen **und** zugehörige PDF- und Bildexporte |
| Einzelnes Foto / einzelne Signatur löschen | Datenbankzeile und Datei |
| Patient löschen | Nur möglich, wenn keine Dokumentation mehr existiert |
| Automatisch beim App-Start | PDF-/Bildexporte älter als **7 Tage**; liegengebliebene Kameraaufnahmen älter als **24 Stunden** |
| Deinstallation der App | Alle App-Daten (Android entfernt den privaten Speicher) |
Die automatisch gelöschten Exporte lassen sich jederzeit aus der Dokumentation neu erzeugen.
Die Fristen stehen als Konstanten in `InternalExportedFilesCleaner` und können angepasst werden.
### 5.7 Fehlerbehandlung als Datenschutz-/Integritätsmaßnahme
Fehlerfälle (Speicher voll, defekte Bilder, PDF-Fehler) führen nicht zu Datenverlust oder zu
verwaisten Dateien (siehe Tests aus QA-004): fehlgeschlagene Importe hinterlassen keine
Halbdateien, Datenbank und Dateien bleiben konsistent, Autosave-Fehler werden dem Nutzer gemeldet.
---
## 6. Bekannte Restrisiken und Empfehlungen
| Nr. | Restrisiko | Bewertung | Empfehlung |
|---|---|---|---|
| R1 | **Datenbank und Dateien sind nicht zusätzlich verschlüsselt** (nur Android-Sandbox + Gerätespeicher-Verschlüsselung). Auf gerooteten Geräten oder bei kompromittiertem Gerät sind Daten lesbar. | mittel | Gerät mit Bildschirmsperre und aktivierter Speicherverschlüsselung betreiben (Standard seit Android 10). Bei erhöhtem Schutzbedarf Datenbankverschlüsselung (z. B. SQLCipher mit Schlüssel im Android Keystore) einplanen. |
| R2 | **Keine App-eigene Sperre** (PIN/Biometrie). Wer das entsperrte Gerät hat, sieht die Daten. | mittel | Gerätesperre verbindlich vorschreiben; optional App-Sperre mit `BiometricPrompt` einführen. |
| R3 | **Tastatur-Lernfunktion/Autofill:** Eingaben (Namen, Befund-Freitexte) können von der Bildschirmtastatur oder einem Autofill-Dienst mitgelernt werden. | gering–mittel | Geräte-Richtlinie: nur vertrauenswürdige Tastatur, Personalisierung/Cloud-Funktionen der Tastatur deaktivieren. |
| R4 | **Dateiname des PDFs enthält Name und Geburtsdatum** und ist in Ziel-Apps (Mailanhang, Messenger) sichtbar. | gering–mittel | Fachliche Entscheidung: neutralen Dateinamen (z. B. nur Dokumentations-ID/Datum) anbieten. |
| R5 | **Weitergabe über andere Apps** liegt nach dem Teilen außerhalb der App-Kontrolle. | organisatorisch | Zulässige Übertragungswege festlegen (z. B. nur dienstliches Postfach/Messenger); Mitarbeiter schulen. |
| R6 | **Release-Build ohne Code-Verkleinerung/Obfuskation** (`isMinifyEnabled = false`). | gering | Für die Veröffentlichung R8 aktivieren (bessere Härtung gegen Reverse Engineering; kein Ersatz für Datenschutzmaßnahmen). |
| R7 | **Kein automatischer Abgleich/Backup**: Geräteverlust oder Defekt = Datenverlust. | Verfügbarkeit | Organisatorisches Archiv (z. B. PDFs in die Patientenakte der Einrichtung). |
| R8 | **Kamera-App des Herstellers** speichert Aufnahmen evtl. zusätzlich in der Galerie/Cloud (bei `ACTION_IMAGE_CAPTURE` mit Ziel-URI normalerweise nicht, herstellerabhängig). | gering | Auf Testgeräten prüfen, ob Aufnahmen zusätzlich in der Galerie erscheinen. |
---
## 7. Automatisierte Datenschutz-Tests
| Test | Art | Prüft |
|---|---|---|
| `PrivacySourceScanTest` | JVM | Keine Netzwerk-/Tracking-Bibliotheken, keine `println`/`printStackTrace`, Log-Aufrufe ohne Nachricht/Pfad/Personendaten, kein externer Speicher, keine Zwischenablage, keine Texte in Share-Intents, Manifest ohne Berechtigungen, `allowBackup=false`, nur eine exportierte Komponente, Backup-Regeln, FileProvider-Freigaben |
| `SafePathsTest` | JVM | Schutz vor Path-Traversal |
| `ExportedFilesCleanerTest` | JVM | Löschen der Exporte, Aufbewahrungsfristen, kein Löschen außerhalb der Exportordner |
| `PrivacyConfigurationTest` | Gerät/Emulator | Zusammengeführtes Manifest (Berechtigungen, exportierte Komponenten), Backup, FileProvider-Freigaben, private Dateirechte, `FLAG_SECURE` |
| `DocumentationDeletionPrivacyTest` | Gerät/Emulator | Löschen einer Dokumentation entfernt auch die Exporte |
Diese Tests dienen als Regressionsschutz: Sie schlagen an, wenn künftig z. B. eine Berechtigung,
eine Netzwerkbibliothek oder eine zu weite Freigabe hinzukommt.
---
## 8. Checkliste für Releases
- [ ] Alle Tests grün (insbesondere die Datenschutz-Tests aus Abschnitt 7)
- [ ] Merged Manifest geprüft (Android Studio → `AndroidManifest.xml` → Tab *Merged Manifest*): keine neuen Berechtigungen/exportierten Komponenten
- [ ] Neue Abhängigkeiten auf Netzwerk-/Tracking-Funktionen geprüft
- [ ] Neue Log-Ausgaben enthalten keine Patientendaten, Pfade, URIs oder Exception-Nachrichten
- [ ] Neue Speicherorte liegen im privaten Speicher; neue Freigaben sind in `file_paths.xml` minimal
- [ ] Neue Datenfelder sind in Abschnitt 2 dieses Dokuments ergänzt und im Löschkonzept berücksichtigt
- [ ] Release-Build signiert, nicht debuggable
---
## 9. Organisatorische Hinweise (für die verantwortliche Stelle)
Diese Punkte kann die App nicht technisch lösen:
- **Rechtsgrundlage** der Verarbeitung (Behandlungsvertrag / Dokumentationspflicht, Art. 9 Abs. 2
lit. h DSGVO) und **Verzeichnis der Verarbeitungstätigkeiten** durch die verantwortliche Stelle
- Prüfung, ob eine **Datenschutz-Folgenabschätzung** (Art. 35 DSGVO) erforderlich ist
(Gesundheitsdaten, ggf. in größerem Umfang)
- **Geräterichtlinie:** Bildschirmsperre, Geräteverschlüsselung, Updates, ggf. MDM, Regelung
privater vs. dienstlicher Geräte
- **Übertragungswege** für das Teilen von PDFs, Aufbewahrungs- und Löschfristen im Archiv
- **Betroffenenrechte** (Auskunft, Berichtigung, Löschung): Löschung ist je Dokumentation in der
App möglich; Auskunft über den PDF-Export
- Schulung der Anwender, Meldeweg bei Geräteverlust (Meldepflicht bei Datenpannen, Art. 33 DSGVO)
## 10. Technische Eckdaten für Datenschutzdokumente
- Verarbeitung ausschließlich lokal auf dem Gerät; keine Empfänger durch die App
- Keine Auftragsverarbeiter durch die App (kein Server, keine Cloud, keine Analyse-/Crash-Dienste)
- Keine Datenübermittlung in Drittländer durch die App
- Keine Cookies, kein Tracking, keine Werbe-ID
- Speicherdauer: bis zur Löschung durch den Nutzer; Exporte automatisch nach 7 Tagen
- Zugriffsschutz: Android-App-Sandbox, Gerätesperre; `FLAG_SECURE`
+50
View File
@@ -0,0 +1,50 @@
# QA-006 – Datenschutz-Review: Befunde und Maßnahmen
Geprüft wurde der Projektstand `dev-QA004` (Manifest, Backup-Regeln, FileProvider, Quelltext,
Abhängigkeiten, Speicher-, Lösch- und Exportabläufe). Die App wurde **nicht** auf einem Gerät
dynamisch untersucht (kein Netzwerk-Mitschnitt, kein Test der Hersteller-Kamera-App).
## Bereits gut umgesetzt (keine Änderung nötig)
| Prüfpunkt | Ergebnis |
|---|---|
| Berechtigungen | Keine deklariert; Photo Picker und System-Kamera-App statt eigener Kamera-/Speicherzugriffe |
| Netzwerk | Kein Netzwerkcode, keine Netzwerk-/Tracking-Bibliotheken |
| Backup | `allowBackup=false` und `data_extraction_rules` (Cloud-Backup + Geräte-Transfer) schließen alles aus |
| Speicherort | Nur privater interner Speicher, kein öffentlicher/externer Speicher |
| Bildschirm | `FLAG_SECURE` (keine Screenshots, keine App-Wechsler-Vorschau) |
| Komponenten | Nur `MainActivity` exportiert; `FileProvider` nicht exportiert |
| Share-Intents | Nur Datei (`EXTRA_STREAM`), kein Text/Betreff mit Patientendaten |
| Datenbank | Keine destruktive Migration (kein stiller Datenverlust bei Updates) |
| Metadaten in Fotos | Beim Import neu kodiert → GPS/EXIF des Originals wird nicht übernommen |
| Fehlermeldungen in der UI | Ohne Pfade/Exception-Texte |
## Befunde und umgesetzte Maßnahmen
| Nr. | Befund | Schwere | Maßnahme | Datei(en) |
|---|---|---|---|---|
| B1 | **Log beim Fotoimport schrieb `e.message`.** Bei Dateifehlern enthält die Meldung häufig Pfad bzw. Bild-URI – entgegen dem Kommentar im Code. | mittel | Es wird nur noch der Exception-Typ geloggt. | `DocumentationEditViewModel.kt` |
| B2 | **PDF- und Bildexporte blieben nach dem Löschen einer Dokumentation im Speicher** (enthalten Name, Geburtsdatum, Anschrift, Befund, Fotos). | hoch | Löschen der Dokumentation entfernt jetzt auch `pdf/<id>/` und `pdf_images/<id>/`. | `ExportedFilesCleaner.kt` (neu), `DocumentationRepository.kt`, `AppContainer.kt` |
| B3 | **Exporte hatten keine Aufbewahrungsfrist.** Alte PDFs lagen unbegrenzt im App-Speicher. | mittel | Beim App-Start werden Exporte älter als 7 Tage gelöscht (jederzeit neu erzeugbar). | `ExportedFilesCleaner.kt`, `WunddokuApplication.kt` |
| B4 | **Kameraaufnahmen konnten im Cache liegen bleiben**, wenn die App zwischen Aufnahme und Import beendet wurde. | gering–mittel | Aufnahmen älter als 24 h werden beim App-Start gelöscht (Frist bewusst lang, damit eine noch ausstehende Aufnahme nach einem Neustart importierbar bleibt). | `ExportedFilesCleaner.kt` |
| B5 | **FileProvider gab `photos/` und `signatures/` frei**, obwohl nie darüber geteilt wird. | gering (Prinzip der minimalen Freigabe) | Freigaben auf `pdf/`, `pdf_images/`, `cache/camera/` reduziert. | `file_paths.xml` |
| B6 | **`INTERNET` konnte über eine künftige Bibliothek unbemerkt ins Manifest gelangen.** | gering (Vorsorge) | `INTERNET` und `ACCESS_NETWORK_STATE` werden beim Manifest-Merge explizit entfernt. | `AndroidManifest.xml` |
| B7 | **Pfadauflösung ohne Traversal-Schutz** (`File(dir, name)`). Namen stammen aus der eigenen DB (UUIDs), daher kein akutes Risiko. | gering (Defense-in-Depth) | `SafePaths.resolveChild` in Foto-/Signatur-Speicher und Cleaner. | `SafePaths.kt` (neu), `PhotoStorage.kt`, `SignatureStorage.kt` |
## Offene Punkte (bewusst NICHT geändert – Produktentscheidung nötig)
| Nr. | Thema | Empfehlung |
|---|---|---|
| O1 | Datenbank/Dateien nicht zusätzlich verschlüsselt (Restrisiko R1) | Bei erhöhtem Schutzbedarf SQLCipher + Android Keystore; sonst Geräteverschlüsselung + Sperre vorschreiben |
| O2 | Keine App-Sperre (PIN/Biometrie) (R2) | `BiometricPrompt` beim Start/Wiederöffnen erwägen |
| O3 | PDF-Dateiname enthält Name + Geburtsdatum (R4) | Neutralen Dateinamen als Option; würde `PdfFileNameGenerator` und dessen Tests ändern |
| O4 | Tastatur-Lernfunktion/Autofill (R3) | Geräterichtlinie; in Compose nur eingeschränkt steuerbar |
| O5 | `isMinifyEnabled = false` (R6) | R8 vor Veröffentlichung aktivieren und Release-Build testen |
| O6 | Dynamische Prüfung auf echten Geräten (Netzwerkmitschnitt, Hersteller-Kamera, Galerie-Kopien) | Vor Produktivbetrieb einmal durchführen |
## Wichtig zu den Änderungen B2/B3
B2 und B3 ändern das Verhalten: Exportierte PDFs verschwinden nach 7 Tagen und beim Löschen der
Dokumentation. Wenn die App PDFs länger vorhalten soll, `DEFAULT_EXPORT_MAX_AGE_MILLIS` in
`InternalExportedFilesCleaner` erhöhen. Bereits **geteilte** Kopien in anderen Apps sind davon
natürlich nicht betroffen.