From 489c4fa070c8e12e0931ef2cb4b7942e4ce658ab Mon Sep 17 00:00:00 2001 From: NOrtmann1 <145041949+NOrtmann1@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:14:05 +0200 Subject: [PATCH 1/2] Fehlerbehandlung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Robuste Behandlung aller bekannten Fehlerfälle. - Speichermangel behandelt - Bildfehler behandelt - PDF-Fehler behandelt - Datenverlust verhindert --- ...ocumentationRepositoryErrorHandlingTest.kt | 274 ++++++++++ .../media/PhotoStorageErrorHandlingTest.kt | 222 ++++++++ .../pdf/PdfExportErrorHandlingTest.kt | 228 +++++++++ .../pdf/PdfIntentErrorHandlingTest.kt | 210 ++++++++ .../pdf/PdfToImagesErrorHandlingTest.kt | 110 ++++ .../SignatureStorageErrorHandlingTest.kt | 114 +++++ .../wunddoku/testutil/ErrorTestDoubles.kt | 196 ++++++++ ...mentationEditViewModelErrorHandlingTest.kt | 473 ++++++++++++++++++ .../PatientFormViewModelErrorHandlingTest.kt | 151 ++++++ .../repository/DocumentationRepository.kt | 123 +++-- .../wunddoku/domain/error/FailureCategory.kt | 59 +++ .../de/medapp/wunddoku/media/PhotoStorage.kt | 41 +- .../pdf/AndroidPdfDocumentExporter.kt | 16 +- .../wunddoku/pdf/DocumentationPdfExporter.kt | 21 + .../wunddoku/pdf/PdfToImagesConverter.kt | 24 +- .../wunddoku/signature/SignatureStorage.kt | 19 +- .../edit/DocumentationEditScreen.kt | 7 + .../edit/DocumentationEditViewModel.kt | 117 ++++- .../ui/patients/form/PatientFormViewModel.kt | 35 +- .../domain/error/FailureCategoryTest.kt | 91 ++++ .../wunddoku/media/ImageDownsamplerTest.kt | 80 +++ .../medapp/wunddoku/pdf/ErrorMessagesTest.kt | 106 ++++ 22 files changed, 2634 insertions(+), 83 deletions(-) create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/data/repository/DocumentationRepositoryErrorHandlingTest.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/media/PhotoStorageErrorHandlingTest.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/pdf/PdfExportErrorHandlingTest.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/pdf/PdfIntentErrorHandlingTest.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/pdf/PdfToImagesErrorHandlingTest.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/signature/SignatureStorageErrorHandlingTest.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/testutil/ErrorTestDoubles.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModelErrorHandlingTest.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModelErrorHandlingTest.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/domain/error/FailureCategory.kt create mode 100644 app/src/test/java/de/medapp/wunddoku/domain/error/FailureCategoryTest.kt create mode 100644 app/src/test/java/de/medapp/wunddoku/media/ImageDownsamplerTest.kt create mode 100644 app/src/test/java/de/medapp/wunddoku/pdf/ErrorMessagesTest.kt diff --git a/app/src/androidTest/java/de/medapp/wunddoku/data/repository/DocumentationRepositoryErrorHandlingTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/data/repository/DocumentationRepositoryErrorHandlingTest.kt new file mode 100644 index 0000000..f582175 --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/data/repository/DocumentationRepositoryErrorHandlingTest.kt @@ -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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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") + } +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/media/PhotoStorageErrorHandlingTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/media/PhotoStorageErrorHandlingTest.kt new file mode 100644 index 0000000..2d77fb0 --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/media/PhotoStorageErrorHandlingTest.kt @@ -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 = 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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()) + } +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/pdf/PdfExportErrorHandlingTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/pdf/PdfExportErrorHandlingTest.kt new file mode 100644 index 0000000..b8ed90f --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/pdf/PdfExportErrorHandlingTest.kt @@ -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 = 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) + } +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/pdf/PdfIntentErrorHandlingTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/pdf/PdfIntentErrorHandlingTest.kt new file mode 100644 index 0000000..2b4da14 --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/pdf/PdfIntentErrorHandlingTest.kt @@ -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() + + 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.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 + ) + } +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/pdf/PdfToImagesErrorHandlingTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/pdf/PdfToImagesErrorHandlingTest.kt new file mode 100644 index 0000000..b54038c --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/pdf/PdfToImagesErrorHandlingTest.kt @@ -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) + } +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/signature/SignatureStorageErrorHandlingTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/signature/SignatureStorageErrorHandlingTest.kt new file mode 100644 index 0000000..0750f3c --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/signature/SignatureStorageErrorHandlingTest.kt @@ -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(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 { 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 { 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())) + } +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/testutil/ErrorTestDoubles.kt b/app/src/androidTest/java/de/medapp/wunddoku/testutil/ErrorTestDoubles.kt new file mode 100644 index 0000000..fed6282 --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/testutil/ErrorTestDoubles.kt @@ -0,0 +1,196 @@ +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(), +): 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, +) + +/** Erwartet, dass der Suspend-Block eine Exception vom Typ [T] wirft, und liefert sie zurück. */ +suspend inline fun 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 onMain(block: () -> T): T { + var result: Result? = null + androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().runOnMainSync { + result = runCatching(block) + } + return result!!.getOrThrow() +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModelErrorHandlingTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModelErrorHandlingTest.kt new file mode 100644 index 0000000..c6501a8 --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModelErrorHandlingTest.kt @@ -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) + } +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModelErrorHandlingTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModelErrorHandlingTest.kt new file mode 100644 index 0000000..c1bf1b1 --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModelErrorHandlingTest.kt @@ -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) + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/data/repository/DocumentationRepository.kt b/app/src/main/java/de/medapp/wunddoku/data/repository/DocumentationRepository.kt index 9650ce0..3dc9d50 100644 --- a/app/src/main/java/de/medapp/wunddoku/data/repository/DocumentationRepository.kt +++ b/app/src/main/java/de/medapp/wunddoku/data/repository/DocumentationRepository.kt @@ -18,6 +18,8 @@ import de.medapp.wunddoku.data.entity.SignatureEntity import de.medapp.wunddoku.data.mapper.toDomain import de.medapp.wunddoku.data.mapper.toSummary 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.DocumentationStatus import de.medapp.wunddoku.domain.model.DocumentationSummary @@ -34,6 +36,7 @@ import de.medapp.wunddoku.pdf.PdfToImagesConverter import de.medapp.wunddoku.pdf.PdfToImagesResult import de.medapp.wunddoku.signature.SignatureBitmapValidation import de.medapp.wunddoku.signature.SignatureStorage +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import java.io.File @@ -201,21 +204,29 @@ class DocumentationRepository( // Unterschriften werden bewusst NICHT kopiert (siehe Doc-Kommentar oben). if (copyPhotos) { - for (sourcePhoto in source.photos) { - val copiedFileName = photoStorage.duplicate( - sourceDocumentationId = source.documentation.id, - sourceFileName = sourcePhoto.fileName, - targetDocumentationId = newDocumentationId, - ) - photoDao.insert( - PhotoEntity( - id = UUID.randomUUID().toString(), - documentationId = newDocumentationId, - fileName = copiedFileName, - orderIndex = sourcePhoto.orderIndex, - createdAt = now, + try { + for (sourcePhoto in source.photos) { + val copiedFileName = photoStorage.duplicate( + sourceDocumentationId = source.documentation.id, + sourceFileName = sourcePhoto.fileName, + targetDocumentationId = newDocumentationId, ) - ) + 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 +261,22 @@ class DocumentationRepository( suspend fun addPhoto(documentationId: String, sourceUri: Uri): Photo { val fileName = photoStorage.importFrom(documentationId, sourceUri) - val nextOrder = photoDao.getMaxOrderIndex(documentationId) + 1 val now = Instant.now() - val entity = PhotoEntity( - id = UUID.randomUUID().toString(), - documentationId = documentationId, - fileName = fileName, - orderIndex = nextOrder, - createdAt = now.toEpochMilli(), - ) - photoDao.insert(entity) + val entity = try { + val nextOrder = photoDao.getMaxOrderIndex(documentationId) + 1 + PhotoEntity( + id = UUID.randomUUID().toString(), + documentationId = documentationId, + fileName = fileName, + orderIndex = nextOrder, + 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) } @@ -284,10 +301,22 @@ class DocumentationRepository( * verwendet) und delegiert die eigentliche Erzeugung an [pdfExporter]. */ suspend fun exportPdf(documentationId: String): PdfExportResult { - val documentation = getFullDocumentation(documentationId) - ?: return PdfExportResult.Failure(PdfExportError.DocumentationNotFound) - val model = DocumentationToPdfExportModelMapper.map(documentation) - return pdfExporter.export(model) + return try { + val documentation = getFullDocumentation(documentationId) + ?: return PdfExportResult.Failure(PdfExportError.DocumentationNotFound) + val model = DocumentationToPdfExportModelMapper.map(documentation) + pdfExporter.export(model) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + // Vertrag: der Export wirft nie - weder bei Datenbankfehlern noch bei Speichermangel. + // Technische Fehlertexte werden bewusst nicht weitergereicht (Datenschutz). + when (FailureCategory.of(e)) { + FailureCategory.OUT_OF_MEMORY -> PdfExportResult.Failure(PdfExportError.OutOfMemory) + FailureCategory.OUT_OF_STORAGE -> PdfExportResult.Failure(PdfExportError.InsufficientStorage) + FailureCategory.OTHER -> PdfExportResult.Failure(PdfExportError.Unexpected(e::class.java.simpleName)) + } + } } /** @@ -298,7 +327,17 @@ class DocumentationRepository( * erneutes Laden wäre unnötige Doppelarbeit. */ 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 @@ -352,18 +391,24 @@ class DocumentationRepository( require(!SignatureBitmapValidation.isBlank(bitmap)) { "Eine leere Signatur kann nicht gespeichert werden." } val fileName = signatureStorage.save(documentationId, bitmap) - val nextOrderIndex = signatureDao.getMaxOrderIndex(documentationId) + 1 val now = Instant.now() - val entity = SignatureEntity( - id = UUID.randomUUID().toString(), - documentationId = documentationId, - signerName = trimmedName, - fileName = fileName, - orderIndex = nextOrderIndex, - createdAt = now.toEpochMilli(), - ) - signatureDao.insert(entity) - woundCarerRepository.recordSignatureUsage(trimmedName, bitmap) + val entity = try { + SignatureEntity( + id = UUID.randomUUID().toString(), + documentationId = documentationId, + signerName = trimmedName, + fileName = fileName, + orderIndex = signatureDao.getMaxOrderIndex(documentationId) + 1, + createdAt = now.toEpochMilli(), + ).also { signatureDao.insert(it) } + } 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) } diff --git a/app/src/main/java/de/medapp/wunddoku/domain/error/FailureCategory.kt b/app/src/main/java/de/medapp/wunddoku/domain/error/FailureCategory.kt new file mode 100644 index 0000000..9756a77 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/domain/error/FailureCategory.kt @@ -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() + 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 runCatchingCancellable(block: () -> T): Result = try { + Result.success(block()) +} catch (e: CancellationException) { + throw e +} catch (e: Exception) { + Result.failure(e) +} diff --git a/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt b/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt index e6fb579..36e95bb 100644 --- a/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt +++ b/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt @@ -172,22 +172,37 @@ class InternalPhotoStorage(private val context: Context) : PhotoStorage { }.getOrDefault(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 // speichern. Erneutes Kodieren als JPEG verwirft dabei nebenbei die ursprünglichen - // EXIF-Metadaten (z. B. GPS-Position) des Originalfotos, statt sie 1:1 zu - // übernehmen - für sensible medizinische Dokumentationsfotos ein willkommener - // Nebeneffekt, keine bewusste Anforderung dieser Aufgabe. + // EXIF-Metadaten (z. B. GPS-Position) des Originalfotos. + // + // 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 fileName = "${UUID.randomUUID()}.jpg" - File(targetDir, fileName).outputStream().use { out -> - corrected.compress(Bitmap.CompressFormat.JPEG, ImageDownsampler.JPEG_QUALITY, out) + val targetFile = File(targetDir, fileName) + 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 } @@ -199,7 +214,13 @@ class InternalPhotoStorage(private val context: Context) : PhotoStorage { val sourceFile = resolve(sourceDocumentationId, sourceFileName) val targetFileName = "${UUID.randomUUID()}.jpg" 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 } diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt b/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt index 1c675a1..2b0a5cf 100644 --- a/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt +++ b/app/src/main/java/de/medapp/wunddoku/pdf/AndroidPdfDocumentExporter.kt @@ -7,6 +7,7 @@ import android.graphics.Paint import android.graphics.Typeface import android.graphics.pdf.PdfDocument import android.text.TextPaint +import de.medapp.wunddoku.domain.error.FailureCategory import de.medapp.wunddoku.domain.model.InputSpecCodec import de.medapp.wunddoku.media.PhotoStorage import de.medapp.wunddoku.signature.SignatureStorage @@ -185,11 +186,18 @@ class AndroidPdfDocumentExporter( } PdfExportResult.Success(targetFile) - } catch (e: Exception) { + } catch (e: Throwable) { tempFile.delete() - PdfExportResult.Failure( - PdfExportError.WriteFailed(e.message ?: "Unbekannter Fehler bei der PDF-Erzeugung.") - ) + if (e is kotlinx.coroutines.CancellationException) throw e + // Bewusst Throwable: OutOfMemoryError ist ein Error, keine Exception, und würde + // sonst die App beenden. Eine bereits vorhandene PDF am Zielpfad bleibt unangetastet. + when (FailureCategory.of(e)) { + FailureCategory.OUT_OF_MEMORY -> PdfExportResult.Failure(PdfExportError.OutOfMemory) + FailureCategory.OUT_OF_STORAGE -> PdfExportResult.Failure(PdfExportError.InsufficientStorage) + FailureCategory.OTHER -> PdfExportResult.Failure( + PdfExportError.WriteFailed(e.message ?: "Unbekannter Fehler bei der PDF-Erzeugung.") + ) + } } finally { pdfDocument.close() // Erst JETZT sicher, da writeTo() (im try-Block oben) zu diesem Zeitpunkt entweder diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationPdfExporter.kt b/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationPdfExporter.kt index 7f011c6..96ea322 100644 --- a/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationPdfExporter.kt +++ b/app/src/main/java/de/medapp/wunddoku/pdf/DocumentationPdfExporter.kt @@ -38,4 +38,25 @@ sealed interface PdfExportError { data class DirectoryCreationFailed(val path: String) : PdfExportError data class WriteFailed(val message: String) : PdfExportError data class Unexpected(val message: String) : PdfExportError + + /** Arbeitsspeicher reichte für die PDF-Erzeugung nicht aus (z. B. sehr viele Fotos). */ + data object OutOfMemory : PdfExportError + + /** Auf dem Gerät ist nicht genug freier Speicherplatz für die PDF-Datei vorhanden. */ + data object InsufficientStorage : PdfExportError +} + +/** + * Verständliche Anzeige-Meldung ohne technische Details (keine Pfade, keine Exception-Texte - + * Datenschutz: es können Patientendaten im Dateinamen/Pfad stecken). + */ +fun PdfExportError.toUserMessage(): String = when (this) { + PdfExportError.DocumentationNotFound -> "Die Dokumentation wurde nicht gefunden." + is PdfExportError.DirectoryCreationFailed -> "Zielverzeichnis konnte nicht angelegt werden." + is PdfExportError.WriteFailed -> "Beim Schreiben der PDF-Datei ist ein Fehler aufgetreten." + is PdfExportError.Unexpected -> "Es ist ein unerwarteter Fehler aufgetreten." + PdfExportError.OutOfMemory -> + "Nicht genügend Arbeitsspeicher. Bitte andere Apps schließen oder weniger Fotos verwenden." + PdfExportError.InsufficientStorage -> + "Nicht genügend freier Speicherplatz auf dem Gerät." } diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/PdfToImagesConverter.kt b/app/src/main/java/de/medapp/wunddoku/pdf/PdfToImagesConverter.kt index 422a249..9f4f461 100644 --- a/app/src/main/java/de/medapp/wunddoku/pdf/PdfToImagesConverter.kt +++ b/app/src/main/java/de/medapp/wunddoku/pdf/PdfToImagesConverter.kt @@ -5,6 +5,7 @@ import android.graphics.Bitmap import android.graphics.Color import android.graphics.pdf.PdfRenderer import android.os.ParcelFileDescriptor +import de.medapp.wunddoku.domain.error.FailureCategory import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File @@ -41,6 +42,12 @@ sealed interface PdfToImagesResult { data object EmptyDocument : Failure data class Unexpected(val message: String) : Failure + + /** Arbeitsspeicher reichte zum Rendern der Seiten nicht aus. */ + data object OutOfMemory : Failure + + /** Nicht genug freier Speicherplatz für die Bilddateien. */ + data object InsufficientStorage : Failure } } @@ -51,6 +58,10 @@ fun PdfToImagesResult.Failure.toUserMessage(): String = when (this) { "Die PDF-Datei enthält keine Seiten." is PdfToImagesResult.Failure.Unexpected -> "Die PDF-Datei konnte nicht in Bilder umgewandelt werden." + PdfToImagesResult.Failure.OutOfMemory -> + "Nicht genügend Arbeitsspeicher zum Umwandeln in Bilder." + PdfToImagesResult.Failure.InsufficientStorage -> + "Nicht genügend freier Speicherplatz für die Bilder." } class AndroidPdfToImagesConverter(private val context: Context) : PdfToImagesConverter { @@ -102,8 +113,17 @@ class AndroidPdfToImagesConverter(private val context: Context) : PdfToImagesCon PdfToImagesResult.Success(imageFiles) } } - } catch (e: Exception) { - PdfToImagesResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler bei der Bildkonvertierung.") + } catch (e: Throwable) { + // Abbruch der Coroutine nie verschlucken. + if (e is kotlinx.coroutines.CancellationException) throw e + // Halbfertigen Bildsatz entfernen: ein unvollständiger Satz darf nie geteilt werden. + outputDir.deleteRecursively() + when (FailureCategory.of(e)) { + FailureCategory.OUT_OF_MEMORY -> PdfToImagesResult.Failure.OutOfMemory + FailureCategory.OUT_OF_STORAGE -> PdfToImagesResult.Failure.InsufficientStorage + FailureCategory.OTHER -> + PdfToImagesResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler bei der Bildkonvertierung.") + } } } diff --git a/app/src/main/java/de/medapp/wunddoku/signature/SignatureStorage.kt b/app/src/main/java/de/medapp/wunddoku/signature/SignatureStorage.kt index 29b205f..a0b3349 100644 --- a/app/src/main/java/de/medapp/wunddoku/signature/SignatureStorage.kt +++ b/app/src/main/java/de/medapp/wunddoku/signature/SignatureStorage.kt @@ -104,11 +104,20 @@ class InternalSignatureStorage( withContext(Dispatchers.IO) { val fileName = "${UUID.randomUUID()}.png" val targetDir = ensureDocumentationDir(documentationId) - FileOutputStream(File(targetDir, fileName)).use { out -> - // Qualität 100 ist bei PNG (verlustfreies Format) bedeutungslos - wird trotzdem - // explizit angegeben, da Bitmap.compress() den Parameter unabhängig vom Format - // verlangt. - bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + val targetFile = File(targetDir, fileName) + try { + val written = FileOutputStream(targetFile).use { out -> + // Qualität 100 ist bei PNG (verlustfreies Format) bedeutungslos - wird trotzdem + // explizit angegeben, da Bitmap.compress() den Parameter unabhängig vom Format + // verlangt. + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + } + // compress() meldet Schreibfehler (z. B. Speicher voll) nur über `false`. + if (!written) throw java.io.IOException("Signatur konnte nicht gespeichert werden.") + } catch (e: Throwable) { + // Keine leere/halbe PNG-Datei als scheinbar gültige Signatur zurücklassen. + targetFile.delete() + throw e } fileName } diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt index aebba3f..589df24 100644 --- a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt +++ b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt @@ -252,6 +252,13 @@ fun DocumentationEditScreen( snackbarHostState.showSnackbar(message) viewModel.dismissShareMessage() } + // Speicherfehler beim Autosave/Löschen: der Nutzer muss erfahren, dass eine Änderung NICHT + // gespeichert wurde (sonst entsteht unbemerkter Datenverlust). + LaunchedEffect(uiState.saveError) { + val message = uiState.saveError ?: return@LaunchedEffect + snackbarHostState.showSnackbar(message) + viewModel.dismissSaveError() + } Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt index aa48f8d..b69d9e4 100644 --- a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt +++ b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt @@ -11,13 +11,15 @@ import androidx.navigation.toRoute import de.medapp.wunddoku.data.repository.DocumentationRepository import de.medapp.wunddoku.data.repository.WoundCarerRepository 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.WoundCarer -import de.medapp.wunddoku.pdf.PdfExportError import de.medapp.wunddoku.pdf.PdfExportResult import de.medapp.wunddoku.pdf.PdfToImagesResult import de.medapp.wunddoku.pdf.toUserMessage import de.medapp.wunddoku.ui.navigation.Screen +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -90,6 +92,10 @@ data class DocumentationEditUiState( /** Analog [imagesToShare], nur für die Variante "als PDF teilen" - Gegenstück * [DocumentationEditViewModel.onPdfShareHandled]. */ val pdfToShare: File? = null, + /** Einmalige Fehlermeldung, wenn eine Änderung (Haken, Freitext, Löschen) NICHT in der + * Datenbank gespeichert werden konnte - z. B. bei vollem Speicher. Die Anzeige zeigt dann + * wieder den tatsächlich gespeicherten Stand, nie einen nur scheinbar gespeicherten. */ + val saveError: String? = null, ) { val blockCount: Int get() = documentation?.blocks?.size ?: 0 @@ -140,7 +146,14 @@ class DocumentationEditViewModel( private suspend fun loadDocumentation() { _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 { if (documentation != null) { it.copy(documentation = documentation, isLoading = false) @@ -160,11 +173,48 @@ class DocumentationEditViewModel( } 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. 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 = * true), NICHT den Block selbst - der Freitext ist seit der Umstellung auf eine @@ -202,7 +252,13 @@ class DocumentationEditViewModel( state.copy(documentation = documentation.copy(blocks = updatedBlocks)) } // 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() + } + } } /** @@ -261,11 +317,20 @@ class DocumentationEditViewModel( try { photoImportMutex.withLock { var failureCount = 0 + var outOfMemoryCount = 0 + var outOfStorageCount = 0 for (uri in uris) { try { repository.addPhoto(documentationId, uri) - } catch (e: Exception) { + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + // Throwable (statt Exception): OutOfMemoryError beim Dekodieren großer + // Fotos darf die App nicht beenden - das Bild gilt als fehlgeschlagen, + // die übrigen Bilder werden weiter importiert. failureCount++ + if (FailureCategory.of(e) == FailureCategory.OUT_OF_MEMORY) outOfMemoryCount++ + if (FailureCategory.of(e) == FailureCategory.OUT_OF_STORAGE) outOfStorageCount++ // Bewusst OHNE Uri/Dateipfad geloggt (Datenschutz-Vorgabe: keine Bild-Uris/ // Dateipfade in Logs) - die Exception-Nachricht selbst genügt zur Diagnose. android.util.Log.w( @@ -274,12 +339,17 @@ class DocumentationEditViewModel( ) } } - val refreshed = repository.getFullDocumentation(documentationId) + val refreshed = runCatchingCancellable { repository.getFullDocumentation(documentationId) }.getOrNull() + val reason = when { + outOfStorageCount > 0 -> " Nicht genügend Speicherplatz." + outOfMemoryCount > 0 -> " Nicht genügend Arbeitsspeicher." + else -> "" + } _uiState.update { state -> state.copy( documentation = refreshed ?: state.documentation, photoImportError = if (failureCount > 0) { - "$failureCount von ${uris.size} Bild(ern) konnten nicht importiert werden." + "$failureCount von ${uris.size} Bild(ern) konnten nicht importiert werden.$reason" } else { state.photoImportError }, @@ -317,8 +387,9 @@ class DocumentationEditViewModel( fun confirmDeletePhoto() { val photoId = _uiState.value.pendingDeletePhotoId ?: return viewModelScope.launch { - repository.removePhoto(photoId) - val refreshed = repository.getFullDocumentation(documentationId) + val result = runCatchingCancellable { repository.removePhoto(photoId) } + if (result.isFailure) reportSaveFailure(result.exceptionOrNull(), "Das Foto konnte nicht gelöscht werden.") + val refreshed = runCatchingCancellable { repository.getFullDocumentation(documentationId) }.getOrNull() _uiState.update { state -> state.copy( documentation = refreshed ?: state.documentation, @@ -358,6 +429,17 @@ class DocumentationEditViewModel( } } catch (e: IllegalArgumentException) { _uiState.update { it.copy(signatureError = e.message ?: "Signatur konnte nicht gespeichert werden.") } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + // Speicherfehler o. Ä.: Dialog bleibt offen, die gezeichnete Signatur geht nicht + // verloren und kann nach Freigabe von Speicher erneut gespeichert werden. + val message = when (FailureCategory.of(e)) { + FailureCategory.OUT_OF_STORAGE -> "Nicht genügend Speicherplatz - die Signatur konnte nicht gespeichert werden." + FailureCategory.OUT_OF_MEMORY -> "Nicht genügend Arbeitsspeicher - die Signatur konnte nicht gespeichert werden." + FailureCategory.OTHER -> "Die Signatur konnte nicht gespeichert werden." + } + _uiState.update { it.copy(signatureError = message) } } } } @@ -379,8 +461,9 @@ class DocumentationEditViewModel( fun confirmDeleteSignature() { val signatureId = _uiState.value.pendingDeleteSignatureId ?: return viewModelScope.launch { - repository.removeSignature(signatureId) - val refreshed = repository.getFullDocumentation(documentationId) + val result = runCatchingCancellable { repository.removeSignature(signatureId) } + if (result.isFailure) reportSaveFailure(result.exceptionOrNull(), "Die Signatur konnte nicht gelöscht werden.") + val refreshed = runCatchingCancellable { repository.getFullDocumentation(documentationId) }.getOrNull() _uiState.update { state -> state.copy( documentation = refreshed ?: state.documentation, @@ -539,13 +622,6 @@ class DocumentationEditViewModel( _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. * Bewusst NICHT im zentralen UiState gehalten (kein `Bitmap`-Feld in @@ -589,9 +665,10 @@ class DocumentationEditViewModel( fun flushPendingChanges() { if (pendingFreeText.isEmpty()) return val toFlush = pendingFreeText.toMap() - pendingFreeText.clear() 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) } } } diff --git a/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModel.kt b/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModel.kt index d8e3d4f..5a8a019 100644 --- a/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModel.kt +++ b/app/src/main/java/de/medapp/wunddoku/ui/patients/form/PatientFormViewModel.kt @@ -9,6 +9,7 @@ import androidx.navigation.toRoute import de.medapp.wunddoku.data.repository.DocumentationRepository import de.medapp.wunddoku.data.repository.PatientRepository 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.PatientListItem 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.toGermanDateDigits import de.medapp.wunddoku.ui.navigation.Screen +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -187,7 +189,7 @@ class PatientFormViewModel( fun useExistingPatient() { val existing = _uiState.value.duplicateWarning ?: return _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. */ @@ -241,12 +243,39 @@ class PatientFormViewModel( createdAt = Instant.EPOCH, // wird von PatientRepository.upsert() korrekt gesetzt 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) } - 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 * gespeichert oder als Duplikat übernommen): Je nach [startsNewDocumentation] entweder eine diff --git a/app/src/test/java/de/medapp/wunddoku/domain/error/FailureCategoryTest.kt b/app/src/test/java/de/medapp/wunddoku/domain/error/FailureCategoryTest.kt new file mode 100644 index 0000000..1b9fef4 --- /dev/null +++ b/app/src/test/java/de/medapp/wunddoku/domain/error/FailureCategoryTest.kt @@ -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 { throw IOException("x") } + assertTrue(failure.exceptionOrNull() is IOException) + } + + @Test + fun runCatchingCancellable_reicht_coroutine_abbruch_weiter() { + try { + runCatchingCancellable { throw CancellationException("abgebrochen") } + fail("CancellationException darf nicht verschluckt werden") + } catch (expected: CancellationException) { + assertEquals("abgebrochen", expected.message) + } + } +} diff --git a/app/src/test/java/de/medapp/wunddoku/media/ImageDownsamplerTest.kt b/app/src/test/java/de/medapp/wunddoku/media/ImageDownsamplerTest.kt new file mode 100644 index 0000000..2a305bd --- /dev/null +++ b/app/src/test/java/de/medapp/wunddoku/media/ImageDownsamplerTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/medapp/wunddoku/pdf/ErrorMessagesTest.kt b/app/src/test/java/de/medapp/wunddoku/pdf/ErrorMessagesTest.kt new file mode 100644 index 0000000..75b7a32 --- /dev/null +++ b/app/src/test/java/de/medapp/wunddoku/pdf/ErrorMessagesTest.kt @@ -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 = listOf( + PdfExportError.DocumentationNotFound, + PdfExportError.DirectoryCreationFailed(sensitive), + PdfExportError.WriteFailed(sensitive), + PdfExportError.Unexpected(sensitive), + PdfExportError.OutOfMemory, + PdfExportError.InsufficientStorage, + ) + + private val openFailures: List = listOf( + PdfOpenResult.Failure.FileNotFound, + PdfOpenResult.Failure.UriResolutionFailed(sensitive), + PdfOpenResult.Failure.NoViewerAvailable, + PdfOpenResult.Failure.Unexpected(sensitive), + ) + + private val shareFailures: List = listOf( + PdfShareResult.Failure.UriResolutionFailed(sensitive), + PdfShareResult.Failure.NoAppAvailable, + PdfShareResult.Failure.Unexpected(sensitive), + ) + + private val imageShareFailures: List = listOf( + PdfImageShareResult.Failure.NoImages, + PdfImageShareResult.Failure.UriResolutionFailed(sensitive), + PdfImageShareResult.Failure.NoAppAvailable, + PdfImageShareResult.Failure.Unexpected(sensitive), + ) + + private val imageConversionFailures: List = 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) + } +} -- 2.47.3 From 58430a0903c6b742e30368003c43b2f9c747589d Mon Sep 17 00:00:00 2001 From: NOrtmann1 <145041949+NOrtmann1@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:40:35 +0200 Subject: [PATCH 2/2] QA006 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Datenschutz: Überprüfung der Anwendung hinsichtlich Datenschutz und Datensicherheit. Keine unnötigen Berechtigungen Keine sensiblen Logs Sichere Dateispeicherung Backup-Verhalten geprüft Datenschutz-Dokumentation erstellt usw. --- .../DocumentationDeletionPrivacyTest.kt | 89 +++++++ .../privacy/PrivacyConfigurationTest.kt | 240 ++++++++++++++++++ .../wunddoku/testutil/ErrorTestDoubles.kt | 2 + app/src/main/AndroidManifest.xml | 9 +- .../de/medapp/wunddoku/WunddokuApplication.kt | 11 + .../repository/DocumentationRepository.kt | 5 + .../de/medapp/wunddoku/di/AppContainer.kt | 8 + .../de/medapp/wunddoku/media/PhotoStorage.kt | 4 +- .../de/medapp/wunddoku/media/SafePaths.kt | 32 +++ .../wunddoku/pdf/ExportedFilesCleaner.kt | 80 ++++++ .../wunddoku/signature/SignatureStorage.kt | 5 +- .../edit/DocumentationEditViewModel.kt | 7 +- app/src/main/res/xml/file_paths.xml | 7 +- .../de/medapp/wunddoku/media/SafePathsTest.kt | 81 ++++++ .../wunddoku/pdf/ExportedFilesCleanerTest.kt | 157 ++++++++++++ .../wunddoku/privacy/PrivacySourceScanTest.kt | 219 ++++++++++++++++ docs/Datenschutz-Dokumentation.md | 212 ++++++++++++++++ docs/QA-006-Review-Befunde.md | 50 ++++ 18 files changed, 1207 insertions(+), 11 deletions(-) create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/data/repository/DocumentationDeletionPrivacyTest.kt create mode 100644 app/src/androidTest/java/de/medapp/wunddoku/privacy/PrivacyConfigurationTest.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/media/SafePaths.kt create mode 100644 app/src/main/java/de/medapp/wunddoku/pdf/ExportedFilesCleaner.kt create mode 100644 app/src/test/java/de/medapp/wunddoku/media/SafePathsTest.kt create mode 100644 app/src/test/java/de/medapp/wunddoku/pdf/ExportedFilesCleanerTest.kt create mode 100644 app/src/test/java/de/medapp/wunddoku/privacy/PrivacySourceScanTest.kt create mode 100644 docs/Datenschutz-Dokumentation.md create mode 100644 docs/QA-006-Review-Befunde.md diff --git a/app/src/androidTest/java/de/medapp/wunddoku/data/repository/DocumentationDeletionPrivacyTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/data/repository/DocumentationDeletionPrivacyTest.kt new file mode 100644 index 0000000..fd4ef9c --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/data/repository/DocumentationDeletionPrivacyTest.kt @@ -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() + 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()) + } +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/privacy/PrivacyConfigurationTest.kt b/app/src/androidTest/java/de/medapp/wunddoku/privacy/PrivacyConfigurationTest.kt new file mode 100644 index 0000000..83ccfeb --- /dev/null +++ b/app/src/androidTest/java/de/medapp/wunddoku/privacy/PrivacyConfigurationTest.kt @@ -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(), 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>() + 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 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) + } +} diff --git a/app/src/androidTest/java/de/medapp/wunddoku/testutil/ErrorTestDoubles.kt b/app/src/androidTest/java/de/medapp/wunddoku/testutil/ErrorTestDoubles.kt index fed6282..a331f96 100644 --- a/app/src/androidTest/java/de/medapp/wunddoku/testutil/ErrorTestDoubles.kt +++ b/app/src/androidTest/java/de/medapp/wunddoku/testutil/ErrorTestDoubles.kt @@ -146,6 +146,7 @@ fun TestEnvironment.buildRepository( 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(), @@ -159,6 +160,7 @@ fun TestEnvironment.buildRepository( pdfExporter = pdfExporter, pdfToImagesConverter = pdfToImagesConverter, db = db, + exportedFilesCleaner = exportedFilesCleaner, ) /** Erwartet, dass der Suspend-Block eine Exception vom Typ [T] wirft, und liefert sie zurück. */ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a47a0be..1c7cd23 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,10 +1,17 @@ - + + + + + > = @@ -434,6 +436,9 @@ class DocumentationRepository( // geladen werden. runCatching { photoStorage.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) } } /** diff --git a/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt b/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt index 4909755..08fd523 100644 --- a/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt +++ b/app/src/main/java/de/medapp/wunddoku/di/AppContainer.kt @@ -10,6 +10,8 @@ import de.medapp.wunddoku.media.PhotoStorage import de.medapp.wunddoku.pdf.AndroidPdfDocumentExporter import de.medapp.wunddoku.pdf.AndroidPdfToImagesConverter import de.medapp.wunddoku.pdf.DocumentationPdfExporter +import de.medapp.wunddoku.pdf.ExportedFilesCleaner +import de.medapp.wunddoku.pdf.InternalExportedFilesCleaner import de.medapp.wunddoku.pdf.PdfToImagesConverter import de.medapp.wunddoku.signature.InternalSignatureStorage import de.medapp.wunddoku.signature.SignatureStorage @@ -42,6 +44,11 @@ class AppContainer(context: 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 { PatientRepository(patientDao = database.patientDao()) } @@ -67,6 +74,7 @@ class AppContainer(context: Context) { pdfExporter = pdfExporter, pdfToImagesConverter = pdfToImagesConverter, db = database, + exportedFilesCleaner = exportedFilesCleaner, ) } } diff --git a/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt b/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt index 36e95bb..77dc503 100644 --- a/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt +++ b/app/src/main/java/de/medapp/wunddoku/media/PhotoStorage.kt @@ -117,13 +117,13 @@ class InternalPhotoStorage(private val context: Context) : PhotoStorage { } private fun documentationDirPath(documentationId: String): File = - File(photosRootDir, documentationId) + SafePaths.resolveChild(photosRootDir, documentationId) private fun ensureDocumentationDir(documentationId: String): File = documentationDirPath(documentationId).apply { mkdirs() } 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 = withContext(Dispatchers.IO) { diff --git a/app/src/main/java/de/medapp/wunddoku/media/SafePaths.kt b/app/src/main/java/de/medapp/wunddoku/media/SafePaths.kt new file mode 100644 index 0000000..f0573ad --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/media/SafePaths.kt @@ -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 + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/ExportedFilesCleaner.kt b/app/src/main/java/de/medapp/wunddoku/pdf/ExportedFilesCleaner.kt new file mode 100644 index 0000000..19e19f6 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/pdf/ExportedFilesCleaner.kt @@ -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//`), daraus erzeugte Seitenbilder + * (`filesDir/pdf_images//`) 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 + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/signature/SignatureStorage.kt b/app/src/main/java/de/medapp/wunddoku/signature/SignatureStorage.kt index a0b3349..7bac9d1 100644 --- a/app/src/main/java/de/medapp/wunddoku/signature/SignatureStorage.kt +++ b/app/src/main/java/de/medapp/wunddoku/signature/SignatureStorage.kt @@ -1,5 +1,6 @@ package de.medapp.wunddoku.signature +import de.medapp.wunddoku.media.SafePaths import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory @@ -92,13 +93,13 @@ class InternalSignatureStorage( } private fun documentationDirPath(documentationId: String): File = - File(rootDir, documentationId) + SafePaths.resolveChild(rootDir, documentationId) private fun ensureDocumentationDir(documentationId: String): File = documentationDirPath(documentationId).apply { mkdirs() } 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 = withContext(Dispatchers.IO) { diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt index b69d9e4..a060978 100644 --- a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt +++ b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt @@ -331,11 +331,12 @@ class DocumentationEditViewModel( failureCount++ if (FailureCategory.of(e) == FailureCategory.OUT_OF_MEMORY) outOfMemoryCount++ if (FailureCategory.of(e) == FailureCategory.OUT_OF_STORAGE) outOfStorageCount++ - // Bewusst OHNE Uri/Dateipfad geloggt (Datenschutz-Vorgabe: keine Bild-Uris/ - // Dateipfade in Logs) - die Exception-Nachricht selbst genügt zur Diagnose. + // 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}: ${e.message}", + "Fotoimport fehlgeschlagen: ${e::class.simpleName}", ) } } diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index 77d0b08..2f353ef 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -1,15 +1,16 @@ - - diff --git a/app/src/test/java/de/medapp/wunddoku/media/SafePathsTest.kt b/app/src/test/java/de/medapp/wunddoku/media/SafePathsTest.kt new file mode 100644 index 0000000..d5fc302 --- /dev/null +++ b/app/src/test/java/de/medapp/wunddoku/media/SafePathsTest.kt @@ -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 + } + } +} diff --git a/app/src/test/java/de/medapp/wunddoku/pdf/ExportedFilesCleanerTest.kt b/app/src/test/java/de/medapp/wunddoku/pdf/ExportedFilesCleanerTest.kt new file mode 100644 index 0000000..fc0bd82 --- /dev/null +++ b/app/src/test/java/de/medapp/wunddoku/pdf/ExportedFilesCleanerTest.kt @@ -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()) + } +} diff --git a/app/src/test/java/de/medapp/wunddoku/privacy/PrivacySourceScanTest.kt b/app/src/test/java/de/medapp/wunddoku/privacy/PrivacySourceScanTest.kt new file mode 100644 index 0000000..39882cd --- /dev/null +++ b/app/src/test/java/de/medapp/wunddoku/privacy/PrivacySourceScanTest.kt @@ -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> = + 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> = kotlinSources()): List = + 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() + + 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("""]*>""").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("""]*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("""""").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 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() + .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(), + ) + } +} diff --git a/docs/Datenschutz-Dokumentation.md b/docs/Datenschutz-Dokumentation.md new file mode 100644 index 0000000..35f5c5b --- /dev/null +++ b/docs/Datenschutz-Dokumentation.md @@ -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//` | +| Unterschriften | Name des Unterzeichners, Unterschrift als PNG | Datenbank + `files/signatures//` | +| 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//` | +| Bildexport | Seiten des PDFs als JPEG (zum Teilen als Bilder) | `files/pdf_images//` | +| 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 ``. +- 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` diff --git a/docs/QA-006-Review-Befunde.md b/docs/QA-006-Review-Befunde.md new file mode 100644 index 0000000..5e3a71a --- /dev/null +++ b/docs/QA-006-Review-Befunde.md @@ -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//` und `pdf_images//`. | `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. -- 2.47.3