Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8b405ef4e | |||
| 829118233b | |||
| 58f1e6fb82 | |||
| 58430a0903 | |||
| 742ef39f05 | |||
| 489c4fa070 | |||
| c5fcf27367 | |||
| 09e7224c0a | |||
| 34cd20786b | |||
| e9403801d6 | |||
| 18a7af800f | |||
| bf9fcf7b0b | |||
| 4eaf1480a2 | |||
| 4a29d653bc | |||
| 85600bfaed | |||
| 42e4b5c78c | |||
| b265f3fadb | |||
| e8ccab4f00 | |||
| 76bf0f16ec | |||
| 52a95e2b54 | |||
| 08f24aadd5 | |||
| 732c5b73c0 | |||
| f25596563c | |||
| b5ef793513 |
@@ -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:** beta1.1
|
> **Projektstatus:** beta1.4
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -398,12 +398,12 @@ werden kann.
|
|||||||
|
|
||||||
### 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
|
### Phase 7 -- Multi-Device-Synchronisation
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"version": 3,
|
||||||
|
"artifactType": {
|
||||||
|
"type": "APK",
|
||||||
|
"kind": "Directory"
|
||||||
|
},
|
||||||
|
"applicationId": "de.medapp.wunddoku",
|
||||||
|
"variantName": "release",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"type": "SINGLE",
|
||||||
|
"filters": [],
|
||||||
|
"attributes": [],
|
||||||
|
"versionCode": 1,
|
||||||
|
"versionName": "1.0",
|
||||||
|
"outputFile": "app-release.apk"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"elementType": "File",
|
||||||
|
"baselineProfiles": [
|
||||||
|
{
|
||||||
|
"minApi": 28,
|
||||||
|
"maxApi": 30,
|
||||||
|
"baselineProfiles": [
|
||||||
|
"baselineProfiles/1/app-release.dm"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"minApi": 31,
|
||||||
|
"maxApi": 2147483647,
|
||||||
|
"baselineProfiles": [
|
||||||
|
"baselineProfiles/0/app-release.dm"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"minSdkVersionForDexing": 26
|
||||||
|
}
|
||||||
+89
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
+274
@@ -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,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+114
@@ -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()
|
||||||
|
}
|
||||||
+473
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+151
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,8 @@ 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
|
||||||
@@ -27,6 +29,7 @@ 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.pdf.DocumentationPdfExporter
|
import de.medapp.wunddoku.pdf.DocumentationPdfExporter
|
||||||
|
import de.medapp.wunddoku.pdf.ExportedFilesCleaner
|
||||||
import de.medapp.wunddoku.pdf.DocumentationToPdfExportModelMapper
|
import de.medapp.wunddoku.pdf.DocumentationToPdfExportModelMapper
|
||||||
import de.medapp.wunddoku.pdf.PdfExportError
|
import de.medapp.wunddoku.pdf.PdfExportError
|
||||||
import de.medapp.wunddoku.pdf.PdfExportResult
|
import de.medapp.wunddoku.pdf.PdfExportResult
|
||||||
@@ -34,6 +37,7 @@ import de.medapp.wunddoku.pdf.PdfToImagesConverter
|
|||||||
import de.medapp.wunddoku.pdf.PdfToImagesResult
|
import de.medapp.wunddoku.pdf.PdfToImagesResult
|
||||||
import de.medapp.wunddoku.signature.SignatureBitmapValidation
|
import de.medapp.wunddoku.signature.SignatureBitmapValidation
|
||||||
import de.medapp.wunddoku.signature.SignatureStorage
|
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.io.File
|
||||||
@@ -66,6 +70,7 @@ class DocumentationRepository(
|
|||||||
private val pdfExporter: DocumentationPdfExporter,
|
private val pdfExporter: DocumentationPdfExporter,
|
||||||
private val pdfToImagesConverter: PdfToImagesConverter,
|
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>> =
|
||||||
@@ -201,21 +206,29 @@ class DocumentationRepository(
|
|||||||
// Unterschriften werden bewusst NICHT kopiert (siehe Doc-Kommentar oben).
|
// Unterschriften werden bewusst NICHT kopiert (siehe Doc-Kommentar oben).
|
||||||
|
|
||||||
if (copyPhotos) {
|
if (copyPhotos) {
|
||||||
for (sourcePhoto in source.photos) {
|
try {
|
||||||
val copiedFileName = photoStorage.duplicate(
|
for (sourcePhoto in source.photos) {
|
||||||
sourceDocumentationId = source.documentation.id,
|
val copiedFileName = photoStorage.duplicate(
|
||||||
sourceFileName = sourcePhoto.fileName,
|
sourceDocumentationId = source.documentation.id,
|
||||||
targetDocumentationId = newDocumentationId,
|
sourceFileName = sourcePhoto.fileName,
|
||||||
)
|
targetDocumentationId = newDocumentationId,
|
||||||
photoDao.insert(
|
|
||||||
PhotoEntity(
|
|
||||||
id = UUID.randomUUID().toString(),
|
|
||||||
documentationId = newDocumentationId,
|
|
||||||
fileName = copiedFileName,
|
|
||||||
orderIndex = sourcePhoto.orderIndex,
|
|
||||||
createdAt = now,
|
|
||||||
)
|
)
|
||||||
)
|
photoDao.insert(
|
||||||
|
PhotoEntity(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
documentationId = newDocumentationId,
|
||||||
|
fileName = copiedFileName,
|
||||||
|
orderIndex = sourcePhoto.orderIndex,
|
||||||
|
createdAt = now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,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 {
|
||||||
id = UUID.randomUUID().toString(),
|
val nextOrder = photoDao.getMaxOrderIndex(documentationId) + 1
|
||||||
documentationId = documentationId,
|
PhotoEntity(
|
||||||
fileName = fileName,
|
id = UUID.randomUUID().toString(),
|
||||||
orderIndex = nextOrder,
|
documentationId = documentationId,
|
||||||
createdAt = now.toEpochMilli(),
|
fileName = fileName,
|
||||||
)
|
orderIndex = nextOrder,
|
||||||
photoDao.insert(entity)
|
createdAt = now.toEpochMilli(),
|
||||||
|
).also { photoDao.insert(it) }
|
||||||
|
} 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,10 +303,22 @@ class DocumentationRepository(
|
|||||||
* verwendet) und delegiert die eigentliche Erzeugung an [pdfExporter].
|
* verwendet) und delegiert die eigentliche Erzeugung an [pdfExporter].
|
||||||
*/
|
*/
|
||||||
suspend fun exportPdf(documentationId: String): PdfExportResult {
|
suspend fun exportPdf(documentationId: String): PdfExportResult {
|
||||||
val documentation = getFullDocumentation(documentationId)
|
return try {
|
||||||
?: return PdfExportResult.Failure(PdfExportError.DocumentationNotFound)
|
val documentation = getFullDocumentation(documentationId)
|
||||||
val model = DocumentationToPdfExportModelMapper.map(documentation)
|
?: return PdfExportResult.Failure(PdfExportError.DocumentationNotFound)
|
||||||
return pdfExporter.export(model)
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -298,7 +329,17 @@ class DocumentationRepository(
|
|||||||
* erneutes Laden wäre unnötige Doppelarbeit.
|
* erneutes Laden wäre unnötige Doppelarbeit.
|
||||||
*/
|
*/
|
||||||
suspend fun convertPdfToImages(documentationId: String, pdfFile: File): PdfToImagesResult =
|
suspend fun convertPdfToImages(documentationId: String, pdfFile: File): PdfToImagesResult =
|
||||||
pdfToImagesConverter.convert(documentationId, pdfFile)
|
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
|
||||||
@@ -352,18 +393,24 @@ class DocumentationRepository(
|
|||||||
require(!SignatureBitmapValidation.isBlank(bitmap)) { "Eine leere Signatur kann nicht gespeichert werden." }
|
require(!SignatureBitmapValidation.isBlank(bitmap)) { "Eine leere Signatur kann nicht gespeichert werden." }
|
||||||
|
|
||||||
val fileName = signatureStorage.save(documentationId, bitmap)
|
val fileName = signatureStorage.save(documentationId, bitmap)
|
||||||
val nextOrderIndex = signatureDao.getMaxOrderIndex(documentationId) + 1
|
|
||||||
val now = Instant.now()
|
val now = Instant.now()
|
||||||
val entity = SignatureEntity(
|
val entity = try {
|
||||||
id = UUID.randomUUID().toString(),
|
SignatureEntity(
|
||||||
documentationId = documentationId,
|
id = UUID.randomUUID().toString(),
|
||||||
signerName = trimmedName,
|
documentationId = documentationId,
|
||||||
fileName = fileName,
|
signerName = trimmedName,
|
||||||
orderIndex = nextOrderIndex,
|
fileName = fileName,
|
||||||
createdAt = now.toEpochMilli(),
|
orderIndex = signatureDao.getMaxOrderIndex(documentationId) + 1,
|
||||||
)
|
createdAt = now.toEpochMilli(),
|
||||||
signatureDao.insert(entity)
|
).also { signatureDao.insert(it) }
|
||||||
woundCarerRepository.recordSignatureUsage(trimmedName, bitmap)
|
} catch (e: Throwable) {
|
||||||
|
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)
|
return Signature(entity.id, entity.documentationId, entity.signerName, entity.fileName, entity.orderIndex, now)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,6 +436,9 @@ class DocumentationRepository(
|
|||||||
// geladen werden.
|
// geladen werden.
|
||||||
runCatching { photoStorage.deleteAll(documentationId) }
|
runCatching { photoStorage.deleteAll(documentationId) }
|
||||||
runCatching { signatureStorage.deleteAll(documentationId) }
|
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) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import de.medapp.wunddoku.media.PhotoStorage
|
|||||||
import de.medapp.wunddoku.pdf.AndroidPdfDocumentExporter
|
import de.medapp.wunddoku.pdf.AndroidPdfDocumentExporter
|
||||||
import de.medapp.wunddoku.pdf.AndroidPdfToImagesConverter
|
import de.medapp.wunddoku.pdf.AndroidPdfToImagesConverter
|
||||||
import de.medapp.wunddoku.pdf.DocumentationPdfExporter
|
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.pdf.PdfToImagesConverter
|
||||||
import de.medapp.wunddoku.signature.InternalSignatureStorage
|
import de.medapp.wunddoku.signature.InternalSignatureStorage
|
||||||
import de.medapp.wunddoku.signature.SignatureStorage
|
import de.medapp.wunddoku.signature.SignatureStorage
|
||||||
@@ -42,6 +44,11 @@ class AppContainer(context: Context) {
|
|||||||
}
|
}
|
||||||
private val pdfToImagesConverter: PdfToImagesConverter by lazy { AndroidPdfToImagesConverter(context) }
|
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())
|
||||||
}
|
}
|
||||||
@@ -67,6 +74,7 @@ class AppContainer(context: Context) {
|
|||||||
pdfExporter = pdfExporter,
|
pdfExporter = pdfExporter,
|
||||||
pdfToImagesConverter = pdfToImagesConverter,
|
pdfToImagesConverter = pdfToImagesConverter,
|
||||||
db = database,
|
db = database,
|
||||||
|
exportedFilesCleaner = exportedFilesCleaner,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ import de.medapp.wunddoku.domain.model.InputSpec
|
|||||||
* 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. Auf 4 erhöht mit diesem Umbau.
|
* Metadatum in Documentation.catalogVersion übernommen. Auf 5 erhöht (Decubitus als einfache Checkbox, Umbau "Empfohlene Wundversorgung").
|
||||||
*/
|
*/
|
||||||
object DocumentationCatalog {
|
object DocumentationCatalog {
|
||||||
|
|
||||||
const val CATALOG_VERSION: Int = 4
|
const val CATALOG_VERSION: Int = 5
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Label der automatisch an jeden Block angehängten Freitext-Option. Zentral hier definiert,
|
* Label der automatisch an jeden Block angehängten Freitext-Option. Zentral hier definiert,
|
||||||
@@ -50,7 +50,6 @@ object DocumentationCatalog {
|
|||||||
"0,5 cm", "1 cm", "1,5 cm", "2 cm", "2,5 cm", "3 cm", "3,5 cm", "4 cm",
|
"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",
|
"4,5 cm", "5 cm", "5,5 cm", "6 cm", "6,5 cm", "7 cm", "7,5 cm", "8 cm", ">8 cm",
|
||||||
)
|
)
|
||||||
private val DECUBITUS_GRADE_OPTIONS: List<String> = listOf("I", "II", "III", "IV")
|
|
||||||
|
|
||||||
val blocks: List<BlockDefinition> = listOf(
|
val blocks: List<BlockDefinition> = listOf(
|
||||||
BlockDefinition(
|
BlockDefinition(
|
||||||
@@ -60,15 +59,7 @@ object DocumentationCatalog {
|
|||||||
items = listOf(
|
items = listOf(
|
||||||
CheckItemDefinition(id = "wundart_nicht_bekannt_diagnostiziert", label = "Noch nicht diagnostiziert / nicht bekannt", order = 1),
|
CheckItemDefinition(id = "wundart_nicht_bekannt_diagnostiziert", label = "Noch nicht diagnostiziert / nicht bekannt", order = 1),
|
||||||
CheckItemDefinition(id = "wundart_erscheint_wie", label = "Erscheint wie", order = 2, isFreeText = true),
|
CheckItemDefinition(id = "wundart_erscheint_wie", label = "Erscheint wie", order = 2, isFreeText = true),
|
||||||
CheckItemDefinition(
|
CheckItemDefinition(id = "wundart_decubitus", label = "Decubitus", order = 3),
|
||||||
id = "wundart_decubitus",
|
|
||||||
label = "Decubitus",
|
|
||||||
order = 3,
|
|
||||||
inputSpec = InputSpec(
|
|
||||||
pdfTemplate = "Decubitus Grad {0}",
|
|
||||||
fields = listOf(InputFieldDef.Choice(label = "Grad", options = DECUBITUS_GRADE_OPTIONS)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
CheckItemDefinition(id = "wundart_ulcus_cruris_unbezeichnet", label = "Ulcus Cruris (nicht näher bezeichnet)", order = 4),
|
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_venosum", label = "Ulcus Cruris ven.", order = 5),
|
||||||
CheckItemDefinition(id = "wundart_ulcus_cruris_arteriosum", label = "Ulcus Cruris art.", order = 6),
|
CheckItemDefinition(id = "wundart_ulcus_cruris_arteriosum", label = "Ulcus Cruris art.", order = 6),
|
||||||
@@ -105,7 +96,7 @@ object DocumentationCatalog {
|
|||||||
title = "Lokalisation",
|
title = "Lokalisation",
|
||||||
order = 2,
|
order = 2,
|
||||||
items = listOf(
|
items = listOf(
|
||||||
CheckItemDefinition(id = "lokalisation_multiple_wunden", label = "multiple Wunden im Bereich", order = 1),
|
CheckItemDefinition(id = "lokalisation_multiple_wunden", label = "Multiple Wunden im Bereich", order = 1),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
BlockDefinition(
|
BlockDefinition(
|
||||||
@@ -129,9 +120,13 @@ object DocumentationCatalog {
|
|||||||
),
|
),
|
||||||
CheckItemDefinition(id = "groesse_freitext_masse", label = "Freitext für die Maße", order = 3, isFreeText = true),
|
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_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),
|
// Bewusst OHNE separate Hinweis-Überschrift (anders als bei "Maße" oben): Die
|
||||||
CheckItemDefinition(id = "groesse_areal", label = "Zu versorgendes Areal", order = 6),
|
// Checkbox selbst trägt mit "Zu versorgendes Areal" bereits den fachlich
|
||||||
CheckItemDefinition(id = "groesse_freitext_areal", label = "Freitext für zu versorgendes Areal", order = 7, isFreeText = true),
|
// gewünschten Namen - eine zusätzliche, wortgleiche Überschrift direkt darüber
|
||||||
|
// würde im PDF als zwei identische, nicht unterscheidbare Zeilen hintereinander
|
||||||
|
// erscheinen (Hinweistext und Checkbox-Label werden dort gleich dargestellt).
|
||||||
|
CheckItemDefinition(id = "groesse_areal", label = "Zu versorgendes Areal", order = 5),
|
||||||
|
CheckItemDefinition(id = "groesse_freitext_areal", label = "Freitext für zu versorgendes Areal", order = 6, isFreeText = true),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
BlockDefinition(
|
BlockDefinition(
|
||||||
@@ -353,8 +348,8 @@ object DocumentationCatalog {
|
|||||||
CheckItemDefinition(id = "vitalwerte_arteria_dorsalis_pedis_links", label = "Arteria dorsalis pedis links", order = 2),
|
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_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_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_doppler_taschendoppler", label = "Per Doppler (Taschendoppler)", order = 5),
|
||||||
CheckItemDefinition(id = "vitalwerte_per_palpation", label = "per Palpation", order = 6),
|
CheckItemDefinition(id = "vitalwerte_per_palpation", label = "Per Palpation", order = 6),
|
||||||
CheckItemDefinition(id = "vitalwerte_darstellbar", label = "Darstellbar", order = 7),
|
CheckItemDefinition(id = "vitalwerte_darstellbar", label = "Darstellbar", order = 7),
|
||||||
CheckItemDefinition(id = "vitalwerte_abgeschwaecht_veraendert", label = "Abgeschwächt/verändert", order = 8),
|
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_nicht_darstellbar", label = "Nicht darstellbar", order = 9),
|
||||||
@@ -507,7 +502,9 @@ object DocumentationCatalog {
|
|||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_sterile_kompresse", label = "Sterile Kompresse", order = 2),
|
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_nacl", label = "NaCl 0,9%", order = 3),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_octenisept", label = "Octenisept", order = 4),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_octenisept", label = "Octenisept", order = 4),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_granudacyn", label = "Granudacyn", order = 5),
|
// Granudacyn: Checkbox + direkt folgendes Freitextfeld, das nur bei angehakter
|
||||||
|
// Checkbox aktiv ist (= vorhandenes isFreeText-Verhalten, s. FreeTextCheckItemRow).
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_granudacyn", label = "Granudacyn", order = 5, isFreeText = true),
|
||||||
CheckItemDefinition(
|
CheckItemDefinition(
|
||||||
id = "empfohlene_wundversorgung_getraenkte_kompresse",
|
id = "empfohlene_wundversorgung_getraenkte_kompresse",
|
||||||
label = "Getränkte Kompresse",
|
label = "Getränkte Kompresse",
|
||||||
@@ -523,36 +520,43 @@ object DocumentationCatalog {
|
|||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_umgebung_trocken_tupfen", label = "Umgebung trocken tupfen", order = 10),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_umgebung_trocken_tupfen", label = "Umgebung trocken tupfen", order = 10),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_primaer_abdecken_austamponieren", label = "Primär Wunde abdecken/austamponieren", order = 11),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_primaer_abdecken_austamponieren", label = "Primär Wunde abdecken/austamponieren", order = 11),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_ueber_wundrand_hinaus", label = "Über Wundrand hinaus", order = 12),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_ueber_wundrand_hinaus", label = "Über Wundrand hinaus", order = 12),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit", label = "Nicht über Wundrand mit", order = 13, isFreeText = true),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit", label = "Nicht über Wundrand mit", order = 13),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_wundrandschutz_mit", label = "Wundrandschutz mit (bis 3x Wo.):", order = 14, isStaticHint = true),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit_freitext", label = FREE_TEXT_LABEL, order = 14, isFreeText = true),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_ja", label = "Ja", order = 15),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_wundrandschutz", label = "Wundrandschutz:", order = 15, isStaticHint = true),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_nein", label = "Nein", order = 16),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_ja", label = "Ja", order = 16),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_nein", label = "Nein", order = 17),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_freitext", label = FREE_TEXT_LABEL, order = 18, isFreeText = true),
|
||||||
|
// "Wie oft pro Woche": eigene Checkbox; die Auswahl (1-7x) ist erst nach dem
|
||||||
|
// Anhaken aktiv (InputSpec.requiresOwnCheckbox).
|
||||||
CheckItemDefinition(
|
CheckItemDefinition(
|
||||||
id = "empfohlene_wundversorgung_wundrandschutz_tage_pro_woche",
|
id = "empfohlene_wundversorgung_wundrandschutz_tage_pro_woche",
|
||||||
label = "Wie oft pro Woche?",
|
label = "Wie oft pro Woche",
|
||||||
order = 17,
|
order = 19,
|
||||||
inputSpec = InputSpec(
|
inputSpec = InputSpec(
|
||||||
pdfTemplate = "{0}x pro Woche",
|
pdfTemplate = "{0}x pro Woche",
|
||||||
fields = listOf(InputFieldDef.Choice(label = "Tage pro Woche", options = WEEKDAY_COUNT_OPTIONS)),
|
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_hinweis_sekundaere_wundauflage", label = "Sekundäre Wundauflage:", order = 20, isStaticHint = true),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_sekundaer_sterile_kompresse", label = "Sterile Kompresse", order = 19),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_sekundaer_sterile_kompresse", label = "Sterile Kompresse", order = 21),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_saugkompresse", label = "Saugkompresse", order = 20),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_saugkompresse", label = "Saugkompresse", order = 22),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_sonstige_wundauflage", label = "Sonstiges (Wundauflage)", order = 21, isFreeText = true),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_sonstige_wundauflage", label = "Sonstiges (Wundauflage)", order = 23, isFreeText = true),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_fixieren_mit", label = "Fixieren mit:", order = 22, isStaticHint = true),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_fixieren_mit", label = "Fixieren mit:", order = 24, isStaticHint = true),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_mullbinde", label = "Mullbinde", order = 23),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_mullbinde", label = "Mullbinde", order = 25),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_klebevlies", label = "Klebevlies", order = 24),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_klebevlies", label = "Klebevlies", order = 26),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_folienverband", label = "Folienverband", order = 25),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_folienverband", label = "Folienverband", order = 27),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_schlauchverband", label = "Schlauchverband", order = 26),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_schlauchverband", label = "Schlauchverband", order = 28),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde", label = "Kohäsive Fixierbinde", order = 27, isFreeText = true),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde", label = "Kohäsive Fixierbinde", order = 29),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system", label = "Kompressionsverband / System", order = 28),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde_freitext", label = FREE_TEXT_LABEL, order = 30, isFreeText = true),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_wechselintervall", label = "Wechselintervall", order = 29, isFreeText = true),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system", label = "Kompressionsverband / System", order = 31),
|
||||||
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_bedarf", label = "+ bei Bedarf", order = 30, isStaticHint = true),
|
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system_freitext", label = FREE_TEXT_LABEL, order = 32, isFreeText = true),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_wechselintervall", label = "Wechselintervall", order = 33, isFreeText = true),
|
||||||
|
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_bedarf", label = "+ bei Bedarf", order = 34, isStaticHint = true),
|
||||||
CheckItemDefinition(
|
CheckItemDefinition(
|
||||||
id = "empfohlene_wundversorgung_hinweis_abklaerung",
|
id = "empfohlene_wundversorgung_hinweis_abklaerung",
|
||||||
label = "Generell: bei akuter Verschlechterung sofort ärztliche Abklärung",
|
label = "Generell: bei akuter Verschlechterung sofort ärztliche Abklärung",
|
||||||
order = 31,
|
order = 35,
|
||||||
isStaticHint = true,
|
isStaticHint = true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -46,6 +46,14 @@ sealed interface InputFieldDef {
|
|||||||
data class InputSpec(
|
data class InputSpec(
|
||||||
val pdfTemplate: String,
|
val pdfTemplate: String,
|
||||||
val fields: List<InputFieldDef>,
|
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. */
|
/** Zerlegt den gespeicherten Gesamtwert (s. [encodeValues]) in genau [fields].size Einzelwerte. */
|
||||||
@@ -86,7 +94,7 @@ data class InputSpec(
|
|||||||
* prüfbar) und ohne zusätzliche Abhängigkeit. Die verwendeten Trennzeichen kommen in Labels,
|
* prüfbar) und ohne zusätzliche Abhängigkeit. Die verwendeten Trennzeichen kommen in Labels,
|
||||||
* Textvorlagen und Auswahlwerten nicht vor.
|
* Textvorlagen und Auswahlwerten nicht vor.
|
||||||
*
|
*
|
||||||
* Aufbau: `pdfTemplate ␞ feld1 ␞ feld2 ...`, jedes Feld `N␟label␟maxLength␟decimal` bzw.
|
* Aufbau: `pdfTemplate ␞ feld1 ␞ feld2 ... [␞ B]`, jedes Feld `N␟label␟maxLength␟decimal` bzw.
|
||||||
* `C␟label␟option␝option␝...`.
|
* `C␟label␟option␝option␝...`.
|
||||||
*/
|
*/
|
||||||
object InputSpecCodec {
|
object InputSpecCodec {
|
||||||
@@ -94,6 +102,11 @@ object InputSpecCodec {
|
|||||||
private const val UNIT_SEPARATOR = '\u001F'
|
private const val UNIT_SEPARATOR = '\u001F'
|
||||||
private const val GROUP_SEPARATOR = '\u001D'
|
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 {
|
fun encode(spec: InputSpec): String {
|
||||||
val records = mutableListOf(spec.pdfTemplate)
|
val records = mutableListOf(spec.pdfTemplate)
|
||||||
for (field in spec.fields) {
|
for (field in spec.fields) {
|
||||||
@@ -112,6 +125,7 @@ object InputSpecCodec {
|
|||||||
).joinToString(UNIT_SEPARATOR.toString())
|
).joinToString(UNIT_SEPARATOR.toString())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (spec.requiresOwnCheckbox) records += OWN_CHECKBOX_RECORD
|
||||||
return records.joinToString(RECORD_SEPARATOR.toString())
|
return records.joinToString(RECORD_SEPARATOR.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +164,10 @@ object InputSpecCodec {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (fields.isEmpty()) return null
|
if (fields.isEmpty()) return null
|
||||||
return InputSpec(pdfTemplate = records.first(), fields = fields)
|
return InputSpec(
|
||||||
|
pdfTemplate = records.first(),
|
||||||
|
fields = fields,
|
||||||
|
requiresOwnCheckbox = records.drop(1).any { it == OWN_CHECKBOX_RECORD },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,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) {
|
||||||
@@ -172,22 +172,37 @@ 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)
|
||||||
corrected.compress(Bitmap.CompressFormat.JPEG, ImageDownsampler.JPEG_QUALITY, out)
|
try {
|
||||||
|
val written = targetFile.outputStream().use { 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()
|
||||||
|
corrected.recycle()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (corrected !== decoded) decoded.recycle()
|
|
||||||
corrected.recycle()
|
|
||||||
|
|
||||||
fileName
|
fileName
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,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)
|
||||||
sourceFile.copyTo(targetFile, overwrite = false)
|
try {
|
||||||
|
sourceFile.copyTo(targetFile, overwrite = false)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
// Keine halb kopierte Datei zurücklassen.
|
||||||
|
targetFile.delete()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
targetFileName
|
targetFileName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,10 @@ import android.content.Context
|
|||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Typeface
|
||||||
import android.graphics.pdf.PdfDocument
|
import android.graphics.pdf.PdfDocument
|
||||||
import android.text.TextPaint
|
import android.text.TextPaint
|
||||||
|
import de.medapp.wunddoku.domain.error.FailureCategory
|
||||||
import de.medapp.wunddoku.domain.model.InputSpecCodec
|
import de.medapp.wunddoku.domain.model.InputSpecCodec
|
||||||
import de.medapp.wunddoku.media.PhotoStorage
|
import de.medapp.wunddoku.media.PhotoStorage
|
||||||
import de.medapp.wunddoku.signature.SignatureStorage
|
import de.medapp.wunddoku.signature.SignatureStorage
|
||||||
@@ -99,6 +101,14 @@ class AndroidPdfDocumentExporter(
|
|||||||
*/
|
*/
|
||||||
private const val PHOTO_TARGET_DPI = 150
|
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
|
// Verfügbare Bildfläche in Punkten (Seite abzüglich Rand) - siehe drawText()-nahe
|
||||||
// Verwendung von contentWidth/remainingHeight für die tatsächliche Platzierung; diese
|
// Verwendung von contentWidth/remainingHeight für die tatsächliche Platzierung; diese
|
||||||
// Konstanten bestimmen ausschließlich die DECODE-Zielgröße (wie groß ein Foto maximal
|
// Konstanten bestimmen ausschließlich die DECODE-Zielgröße (wie groß ein Foto maximal
|
||||||
@@ -108,11 +118,11 @@ class AndroidPdfDocumentExporter(
|
|||||||
private const val PHOTO_AREA_WIDTH_PT = PAGE_WIDTH - 2 * MARGIN
|
private const val PHOTO_AREA_WIDTH_PT = PAGE_WIDTH - 2 * MARGIN
|
||||||
private const val PHOTO_AREA_HEIGHT_PT = PAGE_HEIGHT - 2 * MARGIN
|
private const val PHOTO_AREA_HEIGHT_PT = PAGE_HEIGHT - 2 * MARGIN
|
||||||
|
|
||||||
/** Maximale Decode-Breite in Pixeln bei [PHOTO_TARGET_DPI] (≈ 1073px bei 515pt/150dpi). */
|
/** 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 / 72f * PHOTO_TARGET_DPI).toInt()
|
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] (≈ 1587px bei 762pt/150dpi). */
|
/** 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 / 72f * PHOTO_TARGET_DPI).toInt()
|
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)
|
private val HEADER_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||||
}
|
}
|
||||||
@@ -176,11 +186,18 @@ class AndroidPdfDocumentExporter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
PdfExportResult.Success(targetFile)
|
PdfExportResult.Success(targetFile)
|
||||||
} catch (e: Exception) {
|
} catch (e: Throwable) {
|
||||||
tempFile.delete()
|
tempFile.delete()
|
||||||
PdfExportResult.Failure(
|
if (e is kotlinx.coroutines.CancellationException) throw e
|
||||||
PdfExportError.WriteFailed(e.message ?: "Unbekannter Fehler bei der PDF-Erzeugung.")
|
// 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 {
|
} finally {
|
||||||
pdfDocument.close()
|
pdfDocument.close()
|
||||||
// Erst JETZT sicher, da writeTo() (im try-Block oben) zu diesem Zeitpunkt entweder
|
// Erst JETZT sicher, da writeTo() (im try-Block oben) zu diesem Zeitpunkt entweder
|
||||||
@@ -209,10 +226,11 @@ class AndroidPdfDocumentExporter(
|
|||||||
textSize = HEADER_INFO_TEXT_SIZE
|
textSize = HEADER_INFO_TEXT_SIZE
|
||||||
color = Color.BLACK
|
color = Color.BLACK
|
||||||
}
|
}
|
||||||
|
// Überschriften (Blocktitel, "Unterschriften:", "Fotoanhang:"): fett UND kursiv.
|
||||||
val blockTitle = TextPaint().apply {
|
val blockTitle = TextPaint().apply {
|
||||||
isAntiAlias = true
|
isAntiAlias = true
|
||||||
textSize = BLOCK_TITLE_TEXT_SIZE
|
textSize = BLOCK_TITLE_TEXT_SIZE
|
||||||
isFakeBoldText = true
|
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD_ITALIC)
|
||||||
color = Color.BLACK
|
color = Color.BLACK
|
||||||
}
|
}
|
||||||
val item = TextPaint().apply {
|
val item = TextPaint().apply {
|
||||||
@@ -268,8 +286,15 @@ class AndroidPdfDocumentExporter(
|
|||||||
cursor.drawDivider(spacingAfter = 12f)
|
cursor.drawDivider(spacingAfter = 12f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ü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) {
|
private fun drawBlock(cursor: PdfPageCursor, block: PdfExportBlock, paints: ExportPaints) {
|
||||||
cursor.drawText(block.title, paints.blockTitle, spacingAfter = 6f)
|
cursor.drawText(headingText(block.title), paints.blockTitle, spacingAfter = 6f)
|
||||||
|
|
||||||
for (item in block.items) {
|
for (item in block.items) {
|
||||||
drawItem(cursor, item, paints)
|
drawItem(cursor, item, paints)
|
||||||
@@ -289,8 +314,15 @@ class AndroidPdfDocumentExporter(
|
|||||||
// vorgibt.
|
// vorgibt.
|
||||||
val decodedInputSpec = if (item.inputSpec.isNotEmpty()) InputSpecCodec.decode(item.inputSpec) else null
|
val decodedInputSpec = if (item.inputSpec.isNotEmpty()) InputSpecCodec.decode(item.inputSpec) else null
|
||||||
if (decodedInputSpec != 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(
|
cursor.drawText(
|
||||||
text = decodedInputSpec.formatForPdf(item.freeTextValue),
|
text = itemText,
|
||||||
paint = paints.item,
|
paint = paints.item,
|
||||||
indent = ITEM_INDENT,
|
indent = ITEM_INDENT,
|
||||||
spacingAfter = 3f,
|
spacingAfter = 3f,
|
||||||
@@ -298,8 +330,10 @@ class AndroidPdfDocumentExporter(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Die Freitext-Option bekommt einen abschließenden Doppelpunkt, normale Optionen nicht.
|
// Die Freitext-Option bekommt einen abschließenden Doppelpunkt, sofern tatsächlich Text
|
||||||
val labelText = if (item.isFreeText) "${item.label}:" else item.label
|
// folgt (z. B. "Granudacyn" ohne Text bleibt ohne hängenden Doppelpunkt), normale
|
||||||
|
// Optionen nie.
|
||||||
|
val labelText = if (item.isFreeText && item.freeTextValue.isNotBlank()) "${item.label}:" else item.label
|
||||||
|
|
||||||
cursor.drawText(
|
cursor.drawText(
|
||||||
text = labelText,
|
text = labelText,
|
||||||
@@ -334,7 +368,7 @@ class AndroidPdfDocumentExporter(
|
|||||||
paints: ExportPaints,
|
paints: ExportPaints,
|
||||||
retainedSignatureBitmaps: MutableList<Bitmap>,
|
retainedSignatureBitmaps: MutableList<Bitmap>,
|
||||||
) {
|
) {
|
||||||
cursor.drawText("Unterschriften", paints.blockTitle, spacingAfter = SIGNATURE_SECTION_TITLE_SPACING_AFTER)
|
cursor.drawText(headingText("Unterschriften"), paints.blockTitle, spacingAfter = SIGNATURE_SECTION_TITLE_SPACING_AFTER)
|
||||||
for (signature in model.signatures) {
|
for (signature in model.signatures) {
|
||||||
drawSignatureBlock(cursor, model, signature, paints, retainedSignatureBitmaps)
|
drawSignatureBlock(cursor, model, signature, paints, retainedSignatureBitmaps)
|
||||||
}
|
}
|
||||||
@@ -430,7 +464,7 @@ class AndroidPdfDocumentExporter(
|
|||||||
) {
|
) {
|
||||||
cursor.forceNewPage()
|
cursor.forceNewPage()
|
||||||
if (index == 0) {
|
if (index == 0) {
|
||||||
cursor.drawText("Fotoanhang", paints.blockTitle, spacingAfter = 8f)
|
cursor.drawText(headingText("Fotoanhang"), paints.blockTitle, spacingAfter = 8f)
|
||||||
}
|
}
|
||||||
cursor.drawText("Foto ${index + 1} von ${model.photos.size}", paints.headerInfo, spacingAfter = 8f)
|
cursor.drawText("Foto ${index + 1} von ${model.photos.size}", paints.headerInfo, spacingAfter = 8f)
|
||||||
|
|
||||||
@@ -445,7 +479,7 @@ class AndroidPdfDocumentExporter(
|
|||||||
|
|
||||||
if (bitmap != null) {
|
if (bitmap != null) {
|
||||||
retainedPhotoBitmaps.add(bitmap)
|
retainedPhotoBitmaps.add(bitmap)
|
||||||
cursor.drawBitmapFitted(bitmap, paints.photo)
|
cursor.drawBitmapFitted(bitmap, paints.photo, sizeFactor = PHOTO_SIZE_FACTOR)
|
||||||
} else {
|
} else {
|
||||||
// Fehlende/beschädigte Datei, IOException, ungültige Abmessungen o. Ä. - Option B:
|
// Fehlende/beschädigte Datei, IOException, ungültige Abmessungen o. Ä. - Option B:
|
||||||
// neutrale Hinweisseite statt stillschweigendem Überspringen, damit nachvollziehbar
|
// neutrale Hinweisseite statt stillschweigendem Überspringen, damit nachvollziehbar
|
||||||
|
|||||||
@@ -38,4 +38,25 @@ sealed interface PdfExportError {
|
|||||||
data class DirectoryCreationFailed(val path: String) : PdfExportError
|
data class DirectoryCreationFailed(val path: String) : PdfExportError
|
||||||
data class WriteFailed(val message: String) : PdfExportError
|
data class WriteFailed(val message: String) : PdfExportError
|
||||||
data class Unexpected(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,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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,17 +107,21 @@ internal class PdfPageCursor(
|
|||||||
* darf; reicht der verbleibende Platz nicht aus, wird das Bild in den vorhandenen Rest
|
* darf; reicht der verbleibende Platz nicht aus, wird das Bild in den vorhandenen Rest
|
||||||
* hineinskaliert (kleiner, nie abgeschnitten).
|
* hineinskaliert (kleiner, nie abgeschnitten).
|
||||||
*/
|
*/
|
||||||
fun drawBitmapFitted(bitmap: Bitmap, paint: Paint) {
|
fun drawBitmapFitted(bitmap: Bitmap, paint: Paint, sizeFactor: Float = 1f) {
|
||||||
startIfNeeded()
|
startIfNeeded()
|
||||||
val availableWidth = contentWidth.toFloat()
|
val availableWidth = contentWidth.toFloat()
|
||||||
val availableHeight = remainingHeight
|
val availableHeight = remainingHeight
|
||||||
if (availableWidth <= 0f || availableHeight <= 0f || bitmap.width <= 0 || bitmap.height <= 0) return
|
if (availableWidth <= 0f || availableHeight <= 0f || bitmap.width <= 0 || bitmap.height <= 0) return
|
||||||
|
|
||||||
val scale = minOf(availableWidth / bitmap.width, availableHeight / bitmap.height)
|
// [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 drawWidth = bitmap.width * scale
|
||||||
val drawHeight = bitmap.height * scale
|
val drawHeight = bitmap.height * scale
|
||||||
val left = marginLeft + (availableWidth - drawWidth) / 2f
|
val left = marginLeft + (availableWidth - drawWidth) / 2f
|
||||||
val top = cursorY + (availableHeight - drawHeight) / 2f
|
val top = if (sizeFactor < 1f) cursorY else cursorY + (availableHeight - drawHeight) / 2f
|
||||||
|
|
||||||
canvas!!.drawBitmap(bitmap, null, RectF(left, top, left + drawWidth, top + drawHeight), paint)
|
canvas!!.drawBitmap(bitmap, null, RectF(left, top, left + drawWidth, top + drawHeight), paint)
|
||||||
cursorY = contentBottom()
|
cursorY = contentBottom()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.graphics.Bitmap
|
|||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.pdf.PdfRenderer
|
import android.graphics.pdf.PdfRenderer
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
|
import de.medapp.wunddoku.domain.error.FailureCategory
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -41,6 +42,12 @@ sealed interface PdfToImagesResult {
|
|||||||
data object EmptyDocument : Failure
|
data object EmptyDocument : Failure
|
||||||
|
|
||||||
data class Unexpected(val message: String) : 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +58,10 @@ fun PdfToImagesResult.Failure.toUserMessage(): String = when (this) {
|
|||||||
"Die PDF-Datei enthält keine Seiten."
|
"Die PDF-Datei enthält keine Seiten."
|
||||||
is PdfToImagesResult.Failure.Unexpected ->
|
is PdfToImagesResult.Failure.Unexpected ->
|
||||||
"Die PDF-Datei konnte nicht in Bilder umgewandelt werden."
|
"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 {
|
class AndroidPdfToImagesConverter(private val context: Context) : PdfToImagesConverter {
|
||||||
@@ -102,8 +113,17 @@ class AndroidPdfToImagesConverter(private val context: Context) : PdfToImagesCon
|
|||||||
PdfToImagesResult.Success(imageFiles)
|
PdfToImagesResult.Success(imageFiles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Throwable) {
|
||||||
PdfToImagesResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler bei der Bildkonvertierung.")
|
// 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.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.medapp.wunddoku.signature
|
package de.medapp.wunddoku.signature
|
||||||
|
|
||||||
|
import de.medapp.wunddoku.media.SafePaths
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
@@ -92,23 +93,32 @@ class InternalSignatureStorage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun documentationDirPath(documentationId: String): File =
|
private fun documentationDirPath(documentationId: String): File =
|
||||||
File(rootDir, documentationId)
|
SafePaths.resolveChild(rootDir, 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 save(documentationId: String, bitmap: Bitmap): String =
|
override suspend fun save(documentationId: String, bitmap: Bitmap): String =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val fileName = "${UUID.randomUUID()}.png"
|
val fileName = "${UUID.randomUUID()}.png"
|
||||||
val targetDir = ensureDocumentationDir(documentationId)
|
val targetDir = ensureDocumentationDir(documentationId)
|
||||||
FileOutputStream(File(targetDir, fileName)).use { out ->
|
val targetFile = File(targetDir, fileName)
|
||||||
// Qualität 100 ist bei PNG (verlustfreies Format) bedeutungslos - wird trotzdem
|
try {
|
||||||
// explizit angegeben, da Bitmap.compress() den Parameter unabhängig vom Format
|
val written = FileOutputStream(targetFile).use { out ->
|
||||||
// verlangt.
|
// Qualität 100 ist bei PNG (verlustfreies Format) bedeutungslos - wird trotzdem
|
||||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
|
// 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
|
fileName
|
||||||
}
|
}
|
||||||
|
|||||||
+87
-3
@@ -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
|
||||||
@@ -32,6 +33,7 @@ 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.ExpandLess
|
||||||
import androidx.compose.material.icons.filled.ExpandMore
|
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.PictureAsPdf
|
||||||
import androidx.compose.material.icons.filled.Share
|
import androidx.compose.material.icons.filled.Share
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@@ -46,6 +48,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
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.SnackbarHost
|
||||||
@@ -60,6 +63,7 @@ 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
|
||||||
@@ -74,6 +78,7 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.text.input.KeyboardType
|
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.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
|
||||||
@@ -94,6 +99,7 @@ import de.medapp.wunddoku.pdf.PdfOpenResult
|
|||||||
import de.medapp.wunddoku.pdf.PdfShareResult
|
import de.medapp.wunddoku.pdf.PdfShareResult
|
||||||
import de.medapp.wunddoku.pdf.toUserMessage
|
import de.medapp.wunddoku.pdf.toUserMessage
|
||||||
import de.medapp.wunddoku.ui.documentation.edit.signature.AddSignatureDialog
|
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
|
||||||
@@ -155,6 +161,51 @@ fun DocumentationEditScreen(
|
|||||||
val pdfImageSharer = remember(context) { AndroidPdfImageSharer(context) }
|
val pdfImageSharer = remember(context) { AndroidPdfImageSharer(context) }
|
||||||
val pdfSharer = remember(context) { AndroidPdfSharer(context) }
|
val pdfSharer = remember(context) { AndroidPdfSharer(context) }
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
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) {
|
LaunchedEffect(uiState.pdfExportMessage) {
|
||||||
val message = uiState.pdfExportMessage ?: return@LaunchedEffect
|
val message = uiState.pdfExportMessage ?: return@LaunchedEffect
|
||||||
val exportedFile = uiState.lastExportedPdfFile
|
val exportedFile = uiState.lastExportedPdfFile
|
||||||
@@ -201,6 +252,13 @@ fun DocumentationEditScreen(
|
|||||||
snackbarHostState.showSnackbar(message)
|
snackbarHostState.showSnackbar(message)
|
||||||
viewModel.dismissShareMessage()
|
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) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
@@ -281,6 +339,7 @@ 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 },
|
onPhotoClick = { viewedPhoto = it },
|
||||||
@@ -440,6 +499,7 @@ private fun DocumentationEditContent(
|
|||||||
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,
|
onPhotoClick: (Photo) -> Unit,
|
||||||
@@ -536,6 +596,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))
|
||||||
@@ -894,15 +958,32 @@ private fun StructuredInputRow(
|
|||||||
val spec = remember(item.inputSpec) { InputSpecCodec.decode(item.inputSpec) } ?: return
|
val spec = remember(item.inputSpec) { InputSpecCodec.decode(item.inputSpec) } ?: return
|
||||||
val values = remember(item.freeTextValue, spec) { spec.decodeValues(item.freeTextValue) }
|
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) {
|
fun updateValue(fieldIndex: Int, newValue: String) {
|
||||||
val newValues = values.toMutableList().apply { this[fieldIndex] = newValue }
|
val newValues = values.toMutableList().apply { this[fieldIndex] = newValue }
|
||||||
val encoded = spec.encodeValues(newValues)
|
val encoded = spec.encodeValues(newValues)
|
||||||
onFreeTextChanged(item.checkItemStateId, encoded)
|
onFreeTextChanged(item.checkItemStateId, encoded)
|
||||||
onCheckedChanged(item.checkItemStateId, spec.hasAnyValue(encoded))
|
if (!ownCheckbox) {
|
||||||
|
onCheckedChanged(item.checkItemStateId, spec.hasAnyValue(encoded))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(item.label, style = MaterialTheme.typography.bodyMedium)
|
if (ownCheckbox) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(
|
||||||
|
checked = item.checked,
|
||||||
|
onCheckedChange = { checked -> onCheckedChanged(item.checkItemStateId, checked) },
|
||||||
|
)
|
||||||
|
Text(item.label)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(item.label, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -930,6 +1011,7 @@ private fun StructuredInputRow(
|
|||||||
},
|
},
|
||||||
label = { Text(field.label) },
|
label = { Text(field.label) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
|
enabled = fieldsEnabled,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
@@ -937,6 +1019,7 @@ private fun StructuredInputRow(
|
|||||||
is InputFieldDef.Choice -> ChoiceField(
|
is InputFieldDef.Choice -> ChoiceField(
|
||||||
field = field,
|
field = field,
|
||||||
selected = values.getOrElse(index) { "" },
|
selected = values.getOrElse(index) { "" },
|
||||||
|
enabled = fieldsEnabled,
|
||||||
onSelected = { updateValue(index, it) },
|
onSelected = { updateValue(index, it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -953,11 +1036,12 @@ private fun StructuredInputRow(
|
|||||||
private fun ChoiceField(
|
private fun ChoiceField(
|
||||||
field: InputFieldDef.Choice,
|
field: InputFieldDef.Choice,
|
||||||
selected: String,
|
selected: String,
|
||||||
|
enabled: Boolean = true,
|
||||||
onSelected: (String) -> Unit,
|
onSelected: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
|
OutlinedButton(onClick = { expanded = true }, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(selected.ifBlank { field.label }, maxLines = 1)
|
Text(selected.ifBlank { field.label }, maxLines = 1)
|
||||||
}
|
}
|
||||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
|
|||||||
+164
-42
@@ -11,13 +11,15 @@ 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.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.domain.model.WoundCarer
|
||||||
import de.medapp.wunddoku.pdf.PdfExportError
|
|
||||||
import de.medapp.wunddoku.pdf.PdfExportResult
|
import de.medapp.wunddoku.pdf.PdfExportResult
|
||||||
import de.medapp.wunddoku.pdf.PdfToImagesResult
|
import de.medapp.wunddoku.pdf.PdfToImagesResult
|
||||||
import de.medapp.wunddoku.pdf.toUserMessage
|
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
|
||||||
@@ -26,7 +28,10 @@ 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.io.File
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
data class DocumentationEditUiState(
|
data class DocumentationEditUiState(
|
||||||
val documentation: Documentation? = null,
|
val documentation: Documentation? = null,
|
||||||
@@ -87,6 +92,10 @@ data class DocumentationEditUiState(
|
|||||||
/** Analog [imagesToShare], nur für die Variante "als PDF teilen" - Gegenstück
|
/** Analog [imagesToShare], nur für die Variante "als PDF teilen" - Gegenstück
|
||||||
* [DocumentationEditViewModel.onPdfShareHandled]. */
|
* [DocumentationEditViewModel.onPdfShareHandled]. */
|
||||||
val pdfToShare: File? = null,
|
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
|
||||||
|
|
||||||
@@ -117,6 +126,10 @@ class DocumentationEditViewModel(
|
|||||||
private val woundCarerRepository: WoundCarerRepository,
|
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()
|
||||||
|
|
||||||
@@ -133,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)
|
||||||
@@ -153,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
|
||||||
@@ -195,7 +252,13 @@ 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -224,38 +287,90 @@ 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) }
|
||||||
var failureCount = 0
|
pendingPhotoImports.incrementAndGet()
|
||||||
for (uri in uris) {
|
try {
|
||||||
try {
|
photoImportMutex.withLock {
|
||||||
repository.addPhoto(documentationId, uri)
|
var failureCount = 0
|
||||||
} catch (e: Exception) {
|
var outOfMemoryCount = 0
|
||||||
failureCount++
|
var outOfStorageCount = 0
|
||||||
// Bewusst OHNE Uri/Dateipfad geloggt (Datenschutz-Vorgabe: keine Bild-Uris/
|
for (uri in uris) {
|
||||||
// Dateipfade in Logs) - die Exception-Nachricht selbst genügt zur Diagnose.
|
try {
|
||||||
android.util.Log.w(
|
repository.addPhoto(documentationId, uri)
|
||||||
"DocumentationEdit",
|
} catch (e: CancellationException) {
|
||||||
"Fotoimport fehlgeschlagen: ${e::class.simpleName}: ${e.message}",
|
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++
|
||||||
|
if (FailureCategory.of(e) == FailureCategory.OUT_OF_MEMORY) outOfMemoryCount++
|
||||||
|
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(
|
||||||
|
"DocumentationEdit",
|
||||||
|
"Fotoimport fehlgeschlagen: ${e::class.simpleName}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 ->
|
||||||
|
state.copy(
|
||||||
|
documentation = refreshed ?: state.documentation,
|
||||||
|
photoImportError = if (failureCount > 0) {
|
||||||
|
"$failureCount von ${uris.size} Bild(ern) konnten nicht importiert werden.$reason"
|
||||||
|
} else {
|
||||||
|
state.photoImportError
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
runCatching { cleanup() }
|
||||||
|
if (pendingPhotoImports.decrementAndGet() == 0) {
|
||||||
|
_uiState.update { it.copy(photoImportInProgress = false) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val refreshed = repository.getFullDocumentation(documentationId)
|
|
||||||
_uiState.update { state ->
|
|
||||||
state.copy(
|
|
||||||
documentation = refreshed ?: state.documentation,
|
|
||||||
photoImportInProgress = false,
|
|
||||||
photoImportError = if (failureCount > 0) {
|
|
||||||
"$failureCount von ${uris.size} Bild(ern) konnten nicht importiert werden."
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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() {
|
||||||
_uiState.update { it.copy(photoImportError = null) }
|
_uiState.update { it.copy(photoImportError = null) }
|
||||||
}
|
}
|
||||||
@@ -273,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,
|
||||||
@@ -314,6 +430,17 @@ class DocumentationEditViewModel(
|
|||||||
}
|
}
|
||||||
} catch (e: IllegalArgumentException) {
|
} catch (e: IllegalArgumentException) {
|
||||||
_uiState.update { it.copy(signatureError = e.message ?: "Signatur konnte nicht gespeichert werden.") }
|
_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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,8 +462,9 @@ class DocumentationEditViewModel(
|
|||||||
fun confirmDeleteSignature() {
|
fun confirmDeleteSignature() {
|
||||||
val signatureId = _uiState.value.pendingDeleteSignatureId ?: return
|
val signatureId = _uiState.value.pendingDeleteSignatureId ?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repository.removeSignature(signatureId)
|
val result = runCatchingCancellable { repository.removeSignature(signatureId) }
|
||||||
val refreshed = repository.getFullDocumentation(documentationId)
|
if (result.isFailure) reportSaveFailure(result.exceptionOrNull(), "Die Signatur 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,
|
||||||
@@ -495,13 +623,6 @@ class DocumentationEditViewModel(
|
|||||||
_uiState.update { it.copy(shareMessage = null) }
|
_uiState.update { it.copy(shareMessage = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private 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."
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@@ -545,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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,12 +243,39 @@ 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 {
|
||||||
|
repository.upsert(patient)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
reportPersistenceFailure(e, "Der Patient konnte nicht gespeichert werden.")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
_uiState.update { it.copy(validationError = null) }
|
_uiState.update { it.copy(validationError = null) }
|
||||||
finish(saved.id)
|
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) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gemeinsamer Abschluss für beide Wege, an einen gültigen Patienten zu kommen (neu
|
* Gemeinsamer Abschluss für beide Wege, an einen gültigen Patienten zu kommen (neu
|
||||||
* gespeichert oder als Duplikat übernommen): Je nach [startsNewDocumentation] entweder eine
|
* gespeichert oder als Duplikat übernommen): Je nach [startsNewDocumentation] entweder eine
|
||||||
|
|||||||
@@ -1,13 +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="pdf_images" path="pdf_images/" />
|
<files-path name="pdf_images" path="pdf_images/" />
|
||||||
<files-path name="photos" path="photos/" />
|
<!-- Temporäre Kameraaufnahmen (Kamerabutton im Fotobereich): nur bis zum Import in die
|
||||||
<files-path name="signatures" path="signatures/" />
|
Dokumentation, danach sofort gelöscht. -->
|
||||||
|
<cache-path name="camera_captures" path="camera/" />
|
||||||
</paths>
|
</paths>
|
||||||
|
|||||||
@@ -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,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,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,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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user