Compare commits

..

15 Commits

53 changed files with 2095 additions and 3851 deletions
+4 -4
View File
@@ -7,7 +7,7 @@ Die App ermöglicht die Verwaltung von Patienten und Wunddokumentationen,
unterstützt Fotos und digitale Unterschriften und erzeugt aus unterstützt Fotos und digitale Unterschriften und erzeugt aus
abgeschlossenen Dokumentationen PDF-Dateien. abgeschlossenen Dokumentationen PDF-Dateien.
> **Projektstatus:** beta1.4 > **Projektstatus:** beta1.2
------------------------------------------------------------------------ ------------------------------------------------------------------------
@@ -400,10 +400,10 @@ werden kann.
- [x] Unit Tests - [x] Unit Tests
- [x] Room Tests - [x] Room Tests
- [x] UI Tests - [ ] UI Tests
- [x] Fehlerbehandlung - [ ] Fehlerbehandlung
- [ ] Tablet-Optimierung - [ ] Tablet-Optimierung
- [x] Datenschutz-Review - [ ] Datenschutz-Review
### Phase 7 -- Multi-Device-Synchronisation ### Phase 7 -- Multi-Device-Synchronisation
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+37
View File
@@ -0,0 +1,37 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "de.medapp.wunddoku",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.0",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 26
}
@@ -0,0 +1,259 @@
package de.medapp.wunddoku.data.dao
import android.database.sqlite.SQLiteConstraintException
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.blockStateEntity
import de.medapp.wunddoku.testutil.checkItemStateEntity
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.runDbTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** DAO-Tests für [BlockStateDao] und [CheckItemStateDao]. */
class BlockAndCheckItemDaoTest : RoomTestBase() {
// --- BlockStateDao -------------------------------------------------------------------
@Test
fun blockStates_insertAll_und_getById_liefern_alle_felder() = runDbTest {
seedDocumentation()
val block = blockStateEntity(title = "Größe & Tiefe", order = 3, updatedAt = 4_000L)
blockStateDao.insertAll(listOf(block))
assertEquals(block, blockStateDao.getById("b-1"))
assertNull(blockStateDao.getById("unbekannt"))
}
@Test
fun blockStates_getForDocumentation_sortiert_nach_blockOrder_und_filtert_nach_dokumentation() = runDbTest {
seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
blockStateDao.insertAll(
listOf(
blockStateEntity(id = "b-3", documentationId = "d-1", blockDefinitionId = "c", order = 3),
blockStateEntity(id = "b-1", documentationId = "d-1", blockDefinitionId = "a", order = 1),
blockStateEntity(id = "b-2", documentationId = "d-1", blockDefinitionId = "b", order = 2),
blockStateEntity(id = "b-x", documentationId = "d-2", blockDefinitionId = "a", order = 1),
)
)
assertEquals(listOf("b-1", "b-2", "b-3"), blockStateDao.getForDocumentation("d-1").map { it.id })
assertEquals(listOf("b-x"), blockStateDao.getForDocumentation("d-2").map { it.id })
assertTrue(blockStateDao.getForDocumentation("unbekannt").isEmpty())
}
@Test
fun blockStates_gleiche_blockdefinition_in_derselben_dokumentation_ist_verboten() = runDbTest {
seedDocumentation()
blockStateDao.insertAll(listOf(blockStateEntity(id = "b-1", blockDefinitionId = "wundart")))
expectThrows<SQLiteConstraintException> {
blockStateDao.insertAll(listOf(blockStateEntity(id = "b-2", blockDefinitionId = "wundart")))
}
assertEquals(1, countRows("block_states"))
}
@Test
fun blockStates_gleiche_blockdefinition_in_verschiedenen_dokumentationen_ist_erlaubt() = runDbTest {
seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
blockStateDao.insertAll(
listOf(
blockStateEntity(id = "b-1", documentationId = "d-1", blockDefinitionId = "wundart"),
blockStateEntity(id = "b-2", documentationId = "d-2", blockDefinitionId = "wundart"),
)
)
assertEquals(2, countRows("block_states"))
}
@Test
fun blockStates_mit_unbekannter_dokumentation_wirft() = runDbTest {
expectThrows<SQLiteConstraintException> {
blockStateDao.insertAll(listOf(blockStateEntity(documentationId = "gibt-es-nicht")))
}
assertEquals(0, countRows("block_states"))
}
@Test
fun blockStates_insertAll_mit_leerer_liste_ist_unkritisch() = runDbTest {
blockStateDao.insertAll(emptyList())
assertEquals(0, countRows("block_states"))
}
// --- CheckItemStateDao ---------------------------------------------------------------
private suspend fun seedBlock(id: String = "b-1", documentationId: String = "d-1", definitionId: String = "wundart") {
if (documentationDao.getById(documentationId) == null) seedDocumentation(id = documentationId)
blockStateDao.insertAll(listOf(blockStateEntity(id = id, documentationId = documentationId, blockDefinitionId = definitionId)))
}
@Test
fun checkItems_insertAll_und_getForBlockState_liefern_alle_felder_inklusive_standardwerten() = runDbTest {
seedBlock()
val plain = checkItemStateEntity(id = "c-1", definitionId = "plain", order = 1)
val full = checkItemStateEntity(
id = "c-2", definitionId = "full", order = 2, checked = true, isFreeText = true,
freeTextValue = "Text", isStaticHint = false, inputSpec = "SPEC",
)
checkItemStateDao.insertAll(listOf(plain, full))
val loaded = checkItemStateDao.getForBlockState("b-1")
assertEquals(listOf(plain, full), loaded)
assertFalse(loaded[0].checked)
assertFalse(loaded[0].isFreeText)
assertEquals("", loaded[0].freeTextValue)
assertFalse(loaded[0].isStaticHint)
assertEquals("", loaded[0].inputSpec)
}
@Test
fun checkItems_getForBlockState_sortiert_nach_itemOrder_und_filtert_nach_block() = runDbTest {
seedBlock(id = "b-1", definitionId = "a")
seedBlock(id = "b-2", definitionId = "b")
checkItemStateDao.insertAll(
listOf(
checkItemStateEntity(id = "c-3", blockStateId = "b-1", definitionId = "z", order = 3),
checkItemStateEntity(id = "c-1", blockStateId = "b-1", definitionId = "x", order = 1),
checkItemStateEntity(id = "c-2", blockStateId = "b-1", definitionId = "y", order = 2),
checkItemStateEntity(id = "c-x", blockStateId = "b-2", definitionId = "x", order = 1),
)
)
assertEquals(listOf("c-1", "c-2", "c-3"), checkItemStateDao.getForBlockState("b-1").map { it.id })
assertEquals(listOf("c-x"), checkItemStateDao.getForBlockState("b-2").map { it.id })
assertTrue(checkItemStateDao.getForBlockState("unbekannt").isEmpty())
}
@Test
fun checkItems_setChecked_setzt_und_entfernt_den_haken_ohne_andere_felder_zu_aendern() = runDbTest {
seedBlock()
val item = checkItemStateEntity(freeTextValue = "bleibt", inputSpec = "bleibt auch")
checkItemStateDao.insertAll(listOf(item))
checkItemStateDao.setChecked("c-1", true)
assertEquals(item.copy(checked = true), checkItemStateDao.getForBlockState("b-1").single())
checkItemStateDao.setChecked("c-1", false)
assertEquals(item, checkItemStateDao.getForBlockState("b-1").single())
}
@Test
fun checkItems_setChecked_betrifft_nur_die_angegebene_option() = runDbTest {
seedBlock()
checkItemStateDao.insertAll(
listOf(
checkItemStateEntity(id = "c-1", definitionId = "a", order = 1),
checkItemStateEntity(id = "c-2", definitionId = "b", order = 2),
)
)
checkItemStateDao.setChecked("c-1", true)
val byId = checkItemStateDao.getForBlockState("b-1").associateBy { it.id }
assertTrue(byId.getValue("c-1").checked)
assertFalse(byId.getValue("c-2").checked)
}
@Test
fun checkItems_setChecked_und_updateFreeTextValue_mit_unbekannter_id_sind_wirkungslos() = runDbTest {
seedBlock()
val item = checkItemStateEntity()
checkItemStateDao.insertAll(listOf(item))
checkItemStateDao.setChecked("unbekannt", true)
checkItemStateDao.updateFreeTextValue("unbekannt", "x")
assertEquals(listOf(item), checkItemStateDao.getForBlockState("b-1"))
}
@Test
fun checkItems_updateFreeTextValue_aendert_nur_den_text_und_nicht_den_haken() = runDbTest {
seedBlock()
checkItemStateDao.insertAll(listOf(checkItemStateEntity(isFreeText = true, checked = true)))
checkItemStateDao.updateFreeTextValue("c-1", "Rötung am Wundrand")
val loaded = checkItemStateDao.getForBlockState("b-1").single()
assertEquals("Rötung am Wundrand", loaded.freeTextValue)
assertTrue(loaded.checked)
assertTrue(loaded.isFreeText)
}
@Test
fun checkItems_freitext_laesst_sich_leeren() = runDbTest {
seedBlock()
checkItemStateDao.insertAll(listOf(checkItemStateEntity(isFreeText = true, freeTextValue = "alt")))
checkItemStateDao.updateFreeTextValue("c-1", "")
assertEquals("", checkItemStateDao.getForBlockState("b-1").single().freeTextValue)
}
@Test
fun checkItems_sonderzeichen_und_sql_fragmente_werden_als_reiner_text_gespeichert() = runDbTest {
seedBlock()
checkItemStateDao.insertAll(listOf(checkItemStateEntity(isFreeText = true)))
val text = "O'Brien \"Test\" ; DROP TABLE patients; -- Größe 3,5 × 2 cm\nzweite Zeile\t😀 100% _x_"
checkItemStateDao.updateFreeTextValue("c-1", text)
assertEquals(text, checkItemStateDao.getForBlockState("b-1").single().freeTextValue)
assertEquals(1, countRows("patients")) // Tabelle existiert weiterhin, der Seed-Patient ist unangetastet
}
@Test
fun checkItems_inputSpec_mit_steuerzeichen_der_codierung_bleibt_verlustfrei_erhalten() = runDbTest {
// InputSpecCodec nutzt \u001E, \u001F und \u001D als Trennzeichen.
seedBlock()
val spec = "tpl\u001EN\u001Flabel\u001F3\u001Dx\u001Ey"
checkItemStateDao.insertAll(listOf(checkItemStateEntity(inputSpec = spec)))
assertEquals(spec, checkItemStateDao.getForBlockState("b-1").single().inputSpec)
}
@Test
fun checkItems_gleiche_optionsdefinition_im_selben_block_ist_verboten() = runDbTest {
seedBlock()
checkItemStateDao.insertAll(listOf(checkItemStateEntity(id = "c-1", definitionId = "dup")))
expectThrows<SQLiteConstraintException> {
checkItemStateDao.insertAll(listOf(checkItemStateEntity(id = "c-2", definitionId = "dup")))
}
assertEquals(1, countRows("check_item_states"))
}
@Test
fun checkItems_gleiche_optionsdefinition_in_verschiedenen_bloecken_ist_erlaubt() = runDbTest {
seedBlock(id = "b-1", definitionId = "a")
seedBlock(id = "b-2", definitionId = "b")
checkItemStateDao.insertAll(
listOf(
checkItemStateEntity(id = "c-1", blockStateId = "b-1", definitionId = "same"),
checkItemStateEntity(id = "c-2", blockStateId = "b-2", definitionId = "same"),
)
)
assertEquals(2, countRows("check_item_states"))
}
@Test
fun checkItems_mit_unbekanntem_block_wirft() = runDbTest {
expectThrows<SQLiteConstraintException> {
checkItemStateDao.insertAll(listOf(checkItemStateEntity(blockStateId = "gibt-es-nicht")))
}
assertEquals(0, countRows("check_item_states"))
}
}
@@ -0,0 +1,222 @@
package de.medapp.wunddoku.data.dao
import android.database.sqlite.SQLiteConstraintException
import de.medapp.wunddoku.domain.model.DocumentationStatus
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.awaitMatching
import de.medapp.wunddoku.testutil.documentationEntity
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.runDbTest
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** DAO-Tests für [DocumentationDao] (ohne Suche und Relationen, s. SearchQueryTest / DocumentationRelationsTest). */
class DocumentationDaoTest : RoomTestBase() {
// --- insert / getById ----------------------------------------------------------------
@Test
fun insert_und_getById_liefern_alle_felder_inklusive_snapshots() = runDbTest {
seedPatient()
val entity = documentationEntity(
status = DocumentationStatus.COMPLETED,
createdAt = 1_000L,
updatedAt = 2_000L,
pdfLastGeneratedAt = 3_000L,
catalogVersion = 3,
firstNameSnapshot = "Erika",
lastNameSnapshot = "Musterfrau",
birthDateSnapshot = "1975-12-01",
streetSnapshot = "Hauptstraße 5",
postalCodeSnapshot = "54321",
citySnapshot = "Beispielstadt",
)
documentationDao.insert(entity)
assertEquals(entity, documentationDao.getById("d-1"))
}
@Test
fun nullable_felder_bleiben_null() = runDbTest {
seedDocumentation()
val loaded = documentationDao.getById("d-1")!!
assertNull(loaded.continuedFromDocumentationId)
assertNull(loaded.pdfLastGeneratedAt)
}
@Test
fun getById_liefert_null_fuer_unbekannte_id() = runDbTest {
assertNull(documentationDao.getById("unbekannt"))
}
@Test
fun status_wird_als_enum_name_gespeichert_und_vollstaendig_zurueckgelesen() = runDbTest {
seedPatient()
DocumentationStatus.values().forEachIndexed { index, status ->
documentationDao.insert(documentationEntity(id = "d-$index", status = status))
}
DocumentationStatus.values().forEachIndexed { index, status ->
assertEquals(status, documentationDao.getById("d-$index")!!.status)
assertEquals(status.name, queryString("SELECT status FROM documentations WHERE id = 'd-$index'"))
}
}
// --- Foreign Keys --------------------------------------------------------------------
@Test
fun insert_mit_unbekanntem_patienten_wirft() = runDbTest {
expectThrows<SQLiteConstraintException> {
documentationDao.insert(documentationEntity(patientId = "gibt-es-nicht"))
}
assertEquals(0, countRows("documentations"))
}
@Test
fun insert_mit_unbekannter_fortsetzungsquelle_wirft() = runDbTest {
seedPatient()
expectThrows<SQLiteConstraintException> {
documentationDao.insert(documentationEntity(continuedFromDocumentationId = "gibt-es-nicht"))
}
assertEquals(0, countRows("documentations"))
}
@Test
fun fortsetzungsquelle_wird_beim_loeschen_der_quelle_auf_null_gesetzt() = runDbTest {
val source = seedDocumentation(id = "d-source")
documentationDao.insert(documentationEntity(id = "d-copy", continuedFromDocumentationId = "d-source"))
assertEquals("d-source", documentationDao.getById("d-copy")!!.continuedFromDocumentationId)
documentationDao.delete(source)
assertNull(documentationDao.getById("d-source"))
val copy = documentationDao.getById("d-copy")
assertNotNull("Die Fortsetzung darf nicht mitgelöscht werden", copy)
assertNull(copy!!.continuedFromDocumentationId)
}
// --- update / delete -----------------------------------------------------------------
@Test
fun update_aendert_nur_die_uebergebene_dokumentation() = runDbTest {
val first = seedDocumentation(id = "d-1")
val second = seedDocumentation(id = "d-2")
documentationDao.update(first.copy(status = DocumentationStatus.COMPLETED, updatedAt = 9_000L, pdfLastGeneratedAt = 9_500L))
val changed = documentationDao.getById("d-1")!!
assertEquals(DocumentationStatus.COMPLETED, changed.status)
assertEquals(9_000L, changed.updatedAt)
assertEquals(9_500L, changed.pdfLastGeneratedAt)
assertEquals(second, documentationDao.getById("d-2"))
}
@Test
fun update_einer_unbekannten_id_legt_nichts_an() = runDbTest {
seedPatient()
documentationDao.update(documentationEntity(id = "geist"))
assertNull(documentationDao.getById("geist"))
}
@Test
fun delete_entfernt_nur_die_uebergebene_dokumentation_und_laesst_den_patienten_bestehen() = runDbTest {
val first = seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
documentationDao.delete(first)
assertNull(documentationDao.getById("d-1"))
assertNotNull(documentationDao.getById("d-2"))
assertNotNull(patientDao.getById("p-1"))
}
// --- observeAll / countForPatient ----------------------------------------------------
@Test
fun observeAll_sortiert_nach_erstelldatum_absteigend() = runDbTest {
seedDocumentation(id = "d-alt", createdAt = 1_000L)
seedDocumentation(id = "d-neu", createdAt = 3_000L)
seedDocumentation(id = "d-mitte", createdAt = 2_000L)
val ids = documentationDao.observeAll().first().map { it.id }
assertEquals(listOf("d-neu", "d-mitte", "d-alt"), ids)
}
@Test
fun observeAll_liefert_leere_liste_ohne_dokumentationen() = runDbTest {
assertTrue(documentationDao.observeAll().first().isEmpty())
}
@Test
fun observeAll_aktualisiert_sich_bei_insert_update_und_delete() = runDbTest {
seedPatient()
val afterInsert = async { documentationDao.observeAll().awaitMatching { it.size == 1 } }
val entity = documentationEntity(id = "d-1")
documentationDao.insert(entity)
assertEquals("d-1", afterInsert.await().single().id)
val afterUpdate = async {
documentationDao.observeAll().awaitMatching { list -> list.singleOrNull()?.status == DocumentationStatus.COMPLETED }
}
documentationDao.update(entity.copy(status = DocumentationStatus.COMPLETED))
afterUpdate.await()
val afterDelete = async { documentationDao.observeAll().awaitMatching { it.isEmpty() } }
documentationDao.delete(entity)
assertTrue(afterDelete.await().isEmpty())
}
@Test
fun countForPatient_zaehlt_nur_dokumentationen_des_jeweiligen_patienten() = runDbTest {
seedDocumentation(id = "d-1", patientId = "p-1")
seedDocumentation(id = "d-2", patientId = "p-1")
seedDocumentation(id = "d-3", patientId = "p-2")
assertEquals(2, documentationDao.countForPatient("p-1"))
assertEquals(1, documentationDao.countForPatient("p-2"))
assertEquals(0, documentationDao.countForPatient("unbekannt"))
}
// --- getLatestForPatient -------------------------------------------------------------
@Test
fun getLatestForPatient_liefert_die_zuletzt_erstellte_dokumentation_des_patienten() = runDbTest {
seedDocumentation(id = "d-alt", patientId = "p-1", createdAt = 1_000L)
seedDocumentation(id = "d-neu", patientId = "p-1", createdAt = 3_000L)
seedDocumentation(id = "d-mitte", patientId = "p-1", createdAt = 2_000L)
assertEquals("d-neu", documentationDao.getLatestForPatient("p-1")?.documentation?.id)
}
@Test
fun getLatestForPatient_ignoriert_dokumentationen_anderer_patienten() = runDbTest {
seedDocumentation(id = "d-1", patientId = "p-1", createdAt = 1_000L)
seedDocumentation(id = "d-2", patientId = "p-2", createdAt = 9_000L)
assertEquals("d-1", documentationDao.getLatestForPatient("p-1")?.documentation?.id)
assertEquals("d-2", documentationDao.getLatestForPatient("p-2")?.documentation?.id)
}
@Test
fun getLatestForPatient_liefert_null_ohne_dokumentation() = runDbTest {
seedPatient()
assertNull(documentationDao.getLatestForPatient("p-1"))
assertNull(documentationDao.getLatestForPatient("unbekannt"))
}
}
@@ -0,0 +1,229 @@
package de.medapp.wunddoku.data.dao
import de.medapp.wunddoku.data.entity.relations.DocumentationWithDetails
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.blockStateEntity
import de.medapp.wunddoku.testutil.checkItemStateEntity
import de.medapp.wunddoku.testutil.photoEntity
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureEntity
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Relationstests (QA-002): [DocumentationWithDetails] (Dokumentation -> Blöcke -> Optionen,
* Fotos, Signaturen) sowie Cascade-/Restrict-Verhalten der Foreign Keys.
*/
class DocumentationRelationsTest : RoomTestBase() {
/**
* Dokumentation [docId] mit 2 Blöcken ("<prefix>-b1" order 2, "<prefix>-b2" order 1),
* 3 Optionen (b1: c1, c2 / b2: c3), 2 Fotos und 2 Signaturen.
*/
private suspend fun seedWithChildren(docId: String, createdAt: Long, prefix: String) {
seedDocumentation(id = docId, patientId = "p-1", createdAt = createdAt)
blockStateDao.insertAll(
listOf(
blockStateEntity(id = "$prefix-b1", documentationId = docId, blockDefinitionId = "wundart", order = 2),
blockStateEntity(id = "$prefix-b2", documentationId = docId, blockDefinitionId = "groesse", order = 1),
)
)
checkItemStateDao.insertAll(
listOf(
checkItemStateEntity(id = "$prefix-c1", blockStateId = "$prefix-b1", definitionId = "a", order = 1),
checkItemStateEntity(id = "$prefix-c2", blockStateId = "$prefix-b1", definitionId = "b", order = 2),
checkItemStateEntity(id = "$prefix-c3", blockStateId = "$prefix-b2", definitionId = "a", order = 1),
)
)
photoDao.insert(photoEntity(id = "$prefix-ph1", documentationId = docId, orderIndex = 0))
photoDao.insert(photoEntity(id = "$prefix-ph2", documentationId = docId, orderIndex = 1))
signatureDao.insert(signatureEntity(id = "$prefix-s1", documentationId = docId, orderIndex = 0))
signatureDao.insert(signatureEntity(id = "$prefix-s2", documentationId = docId, orderIndex = 1))
}
private fun DocumentationWithDetails.itemIdsByBlock(): Map<String, Set<String>> =
blocks.associate { it.blockState.id to it.checkItems.map { item -> item.id }.toSet() }
// --- Laden ---------------------------------------------------------------------------
@Test
fun getFullDocumentation_liefert_dokumentation_mit_allen_kindern() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
val full = documentationDao.getFullDocumentation("d-a")!!
assertEquals("d-a", full.documentation.id)
assertEquals(setOf("a-b1", "a-b2"), full.blocks.map { it.blockState.id }.toSet())
assertEquals(setOf("a-ph1", "a-ph2"), full.photos.map { it.id }.toSet())
assertEquals(setOf("a-s1", "a-s2"), full.signatures.map { it.id }.toSet())
}
@Test
fun jede_option_haengt_am_richtigen_block() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
val full = documentationDao.getFullDocumentation("d-a")!!
assertEquals(
mapOf("a-b1" to setOf("a-c1", "a-c2"), "a-b2" to setOf("a-c3")),
full.itemIdsByBlock(),
)
}
@Test
fun relation_enthaelt_die_vollstaendigen_entities_nicht_nur_ids() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
val full = documentationDao.getFullDocumentation("d-a")!!
assertEquals(documentationDao.getById("d-a"), full.documentation)
assertEquals(blockStateDao.getById("a-b1"), full.blocks.single { it.blockState.id == "a-b1" }.blockState)
assertEquals(photoDao.getById("a-ph1"), full.photos.single { it.id == "a-ph1" })
assertEquals(signatureDao.getById("a-s1"), full.signatures.single { it.id == "a-s1" })
assertEquals(
checkItemStateDao.getForBlockState("a-b1"),
full.blocks.single { it.blockState.id == "a-b1" }.checkItems.sortedBy { it.itemOrder },
)
}
@Test
fun relation_vermischt_keine_daten_verschiedener_dokumentationen() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
seedWithChildren("d-b", 2_000L, "b")
val a = documentationDao.getFullDocumentation("d-a")!!
val b = documentationDao.getFullDocumentation("d-b")!!
assertTrue(a.blocks.all { it.blockState.documentationId == "d-a" })
assertTrue(a.photos.all { it.documentationId == "d-a" })
assertTrue(a.signatures.all { it.documentationId == "d-a" })
assertEquals(setOf("a-c1", "a-c2", "a-c3"), a.itemIdsByBlock().values.flatten().toSet())
assertEquals(setOf("b-c1", "b-c2", "b-c3"), b.itemIdsByBlock().values.flatten().toSet())
}
@Test
fun dokumentation_ohne_kinder_liefert_leere_listen_statt_null() = runDbTest {
seedDocumentation(id = "d-leer")
val full = documentationDao.getFullDocumentation("d-leer")!!
assertTrue(full.blocks.isEmpty())
assertTrue(full.photos.isEmpty())
assertTrue(full.signatures.isEmpty())
}
@Test
fun block_ohne_optionen_liefert_leere_optionsliste() = runDbTest {
seedDocumentation()
blockStateDao.insertAll(listOf(blockStateEntity()))
val block = documentationDao.getFullDocumentation("d-1")!!.blocks.single()
assertTrue(block.checkItems.isEmpty())
}
@Test
fun getFullDocumentation_liefert_null_fuer_unbekannte_id() = runDbTest {
assertNull(documentationDao.getFullDocumentation("unbekannt"))
}
@Test
fun getLatestForPatient_laedt_auch_die_kinder_der_neuesten_dokumentation() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
seedWithChildren("d-b", 2_000L, "b")
val latest = documentationDao.getLatestForPatient("p-1")!!
assertEquals("d-b", latest.documentation.id)
assertEquals(setOf("b-b1", "b-b2"), latest.blocks.map { it.blockState.id }.toSet())
assertEquals(setOf("b-c1", "b-c2", "b-c3"), latest.itemIdsByBlock().values.flatten().toSet())
assertEquals(2, latest.photos.size)
assertEquals(2, latest.signatures.size)
}
@Test
fun grosse_dokumentation_wird_vollstaendig_und_korrekt_zugeordnet_geladen() = runDbTest {
seedDocumentation()
val blocks = (1..25).map { b ->
blockStateEntity(id = "b-$b", blockDefinitionId = "block_$b", title = "Block $b", order = b)
}
blockStateDao.insertAll(blocks)
val items = (1..25).flatMap { b ->
(1..20).map { i ->
checkItemStateEntity(id = "c-$b-$i", blockStateId = "b-$b", definitionId = "item_$i", order = i)
}
}
checkItemStateDao.insertAll(items)
val full = documentationDao.getFullDocumentation("d-1")!!
assertEquals(25, full.blocks.size)
assertEquals(500, full.blocks.sumOf { it.checkItems.size })
for (block in full.blocks) {
assertEquals(20, block.checkItems.size)
assertTrue(block.checkItems.all { it.blockStateId == block.blockState.id })
}
}
// --- Cascade / Restrict --------------------------------------------------------------
@Test
fun foreign_keys_sind_in_der_testdatenbank_aktiviert() = runDbTest {
assertEquals(1, queryInt("PRAGMA foreign_keys"))
}
@Test
fun dokumentation_loeschen_entfernt_bloecke_optionen_fotos_und_signaturen_per_cascade() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
documentationDao.delete(documentationDao.getById("d-a")!!)
assertEquals(0, countRows("documentations"))
assertEquals(0, countRows("block_states"))
assertEquals(0, countRows("check_item_states"))
assertEquals(0, countRows("photos"))
assertEquals(0, countRows("signatures"))
}
@Test
fun dokumentation_loeschen_laesst_patient_und_andere_dokumentationen_unangetastet() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
seedWithChildren("d-b", 2_000L, "b")
documentationDao.delete(documentationDao.getById("d-a")!!)
assertNotNull(patientDao.getById("p-1"))
val remaining = documentationDao.getFullDocumentation("d-b")!!
assertEquals(2, remaining.blocks.size)
assertEquals(3, remaining.itemIdsByBlock().values.sumOf { it.size })
assertEquals(2, remaining.photos.size)
assertEquals(2, remaining.signatures.size)
assertEquals(2, countRows("block_states"))
assertEquals(3, countRows("check_item_states"))
}
@Test
fun block_loeschen_entfernt_seine_optionen_per_cascade_aber_nicht_die_anderer_bloecke() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
execSql("DELETE FROM block_states WHERE id = 'a-b1'")
assertEquals(listOf("a-c3"), checkItemStateDao.getForBlockState("a-b2").map { it.id })
assertTrue(checkItemStateDao.getForBlockState("a-b1").isEmpty())
assertEquals(1, countRows("check_item_states"))
}
@Test
fun patient_mit_dokumentation_kann_nicht_geloescht_werden_und_kinder_bleiben_erhalten() = runDbTest {
seedWithChildren("d-a", 1_000L, "a")
val failed = runCatching { patientDao.delete(patientDao.getById("p-1")!!) }
assertTrue(failed.isFailure)
assertNotNull(documentationDao.getFullDocumentation("d-a"))
assertEquals(3, countRows("check_item_states"))
}
}
@@ -0,0 +1,232 @@
package de.medapp.wunddoku.data.dao
import android.database.sqlite.SQLiteConstraintException
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.awaitMatching
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.patientEntity
import de.medapp.wunddoku.testutil.runDbTest
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** DAO-Tests für [PatientDao]. */
class PatientDaoTest : RoomTestBase() {
// --- insert / getById ----------------------------------------------------------------
@Test
fun insert_und_getById_liefern_alle_felder_zurueck() = runDbTest {
val entity = patientEntity(phone2 = "0987 654321", phone3 = "0555 123")
patientDao.insert(entity)
assertEquals(entity, patientDao.getById("p-1"))
}
@Test
fun optionale_telefonnummern_bleiben_null() = runDbTest {
patientDao.insert(patientEntity(phone2 = null, phone3 = null))
val loaded = patientDao.getById("p-1")!!
assertNull(loaded.phone2)
assertNull(loaded.phone3)
}
@Test
fun umlaute_und_sonderzeichen_bleiben_unveraendert() = runDbTest {
val entity = patientEntity(
firstName = "Jörg-Åke", lastName = "O'Brien-Müller", street = "Straße \"des\" 17. Juni 1/2", city = "Köln-Ehrenfeld",
)
patientDao.insert(entity)
assertEquals(entity, patientDao.getById("p-1"))
}
@Test
fun getById_liefert_null_fuer_unbekannte_id() = runDbTest {
assertNull(patientDao.getById("unbekannt"))
}
@Test
fun insert_mit_vorhandener_id_wirft_und_laesst_den_bestehenden_datensatz_unveraendert() = runDbTest {
val original = patientEntity(lastName = "Original")
patientDao.insert(original)
expectThrows<SQLiteConstraintException> { patientDao.insert(patientEntity(lastName = "Duplikat")) }
assertEquals(original, patientDao.getById("p-1"))
}
@Test
fun doppelte_stammdaten_mit_unterschiedlicher_id_sind_auf_db_ebene_erlaubt() = runDbTest {
// Duplikatschutz liegt bewusst in der Anwendung (findExisting), nicht in einem UNIQUE-Index.
patientDao.insert(patientEntity(id = "p-1"))
patientDao.insert(patientEntity(id = "p-2"))
assertEquals(2, countRows("patients"))
}
// --- update / delete -----------------------------------------------------------------
@Test
fun update_ueberschreibt_die_felder_des_datensatzes() = runDbTest {
patientDao.insert(patientEntity())
val changed = patientEntity(lastName = "Musterfrau", city = "Neustadt", phone2 = "0111", updatedAt = 2_000L)
patientDao.update(changed)
assertEquals(changed, patientDao.getById("p-1"))
}
@Test
fun update_einer_unbekannten_id_legt_keinen_datensatz_an() = runDbTest {
patientDao.update(patientEntity(id = "geist"))
assertNull(patientDao.getById("geist"))
assertEquals(0, countRows("patients"))
}
@Test
fun delete_entfernt_nur_den_uebergebenen_patienten() = runDbTest {
patientDao.insert(patientEntity(id = "p-1"))
patientDao.insert(patientEntity(id = "p-2"))
patientDao.delete(patientEntity(id = "p-1"))
assertNull(patientDao.getById("p-1"))
assertNotNull(patientDao.getById("p-2"))
}
@Test
fun delete_eines_unbekannten_patienten_ist_wirkungslos() = runDbTest {
patientDao.insert(patientEntity())
patientDao.delete(patientEntity(id = "unbekannt"))
assertEquals(1, countRows("patients"))
}
@Test
fun delete_wird_durch_vorhandene_dokumentation_verhindert_restrict() = runDbTest {
seedDocumentation()
expectThrows<SQLiteConstraintException> { patientDao.delete(patientEntity()) }
assertNotNull(patientDao.getById("p-1"))
assertEquals(1, countRows("documentations"))
}
// --- findExisting --------------------------------------------------------------------
@Test
fun findExisting_trifft_bei_gleichem_vornamen_nachnamen_und_geburtsdatum() = runDbTest {
patientDao.insert(patientEntity())
assertEquals("p-1", patientDao.findExisting("Max", "Mustermann", "1980-03-14")?.id)
}
@Test
fun findExisting_trifft_nicht_bei_abweichendem_feld() = runDbTest {
patientDao.insert(patientEntity())
assertNull(patientDao.findExisting("Moritz", "Mustermann", "1980-03-14"))
assertNull(patientDao.findExisting("Max", "Musterfrau", "1980-03-14"))
assertNull(patientDao.findExisting("Max", "Mustermann", "1980-03-15"))
}
@Test
fun findExisting_ist_ein_exakter_abgleich_und_unterscheidet_gross_und_kleinschreibung() = runDbTest {
// Dokumentiert das aktuelle Verhalten (SQL "="): "max" findet "Max" NICHT.
patientDao.insert(patientEntity())
assertNull(patientDao.findExisting("max", "mustermann", "1980-03-14"))
}
@Test
fun findExisting_liefert_bei_mehreren_treffern_genau_einen() = runDbTest {
patientDao.insert(patientEntity(id = "p-1"))
patientDao.insert(patientEntity(id = "p-2"))
val found = patientDao.findExisting("Max", "Mustermann", "1980-03-14")
assertTrue(found?.id == "p-1" || found?.id == "p-2")
}
// --- hasDocumentations ---------------------------------------------------------------
@Test
fun hasDocumentations_ist_false_ohne_und_true_mit_dokumentation() = runDbTest {
seedPatient("p-1")
seedPatient("p-2", lastName = "Musterfrau")
seedDocumentation(id = "d-1", patientId = "p-1")
assertTrue(patientDao.hasDocumentations("p-1"))
assertFalse(patientDao.hasDocumentations("p-2"))
assertFalse(patientDao.hasDocumentations("unbekannt"))
}
// --- observeAllWithLastDocumentation -------------------------------------------------
@Test
fun observeAllWithLastDocumentation_sortiert_nach_nachname_dann_vorname() = runDbTest {
seedPatient("p-1", firstName = "Max", lastName = "Müller")
seedPatient("p-2", firstName = "Anna", lastName = "Müller")
seedPatient("p-3", firstName = "Zoe", lastName = "Adler")
val result = patientDao.observeAllWithLastDocumentation().first()
assertEquals(listOf("p-3", "p-2", "p-1"), result.map { it.patient.id })
}
@Test
fun lastDocumentationAt_ist_das_maximale_erstelldatum_unabhaengig_von_der_einfuegereihenfolge() = runDbTest {
seedPatient("p-1")
seedDocumentation(id = "d-1", patientId = "p-1", createdAt = 3_000L)
seedDocumentation(id = "d-2", patientId = "p-1", createdAt = 1_000L)
seedDocumentation(id = "d-3", patientId = "p-1", createdAt = 2_000L)
val result = patientDao.observeAllWithLastDocumentation().first().single()
assertEquals(3_000L, result.lastDocumentationAt)
}
@Test
fun lastDocumentationAt_ist_null_ohne_dokumentation_und_beruecksichtigt_nur_eigene_dokumentationen() = runDbTest {
seedPatient("p-1")
seedPatient("p-2", firstName = "Erika", lastName = "Musterfrau")
seedDocumentation(id = "d-1", patientId = "p-1", createdAt = 5_000L)
val byId = patientDao.observeAllWithLastDocumentation().first().associateBy { it.patient.id }
assertEquals(5_000L, byId.getValue("p-1").lastDocumentationAt)
assertNull(byId.getValue("p-2").lastDocumentationAt)
}
@Test
fun observeAllWithLastDocumentation_liefert_leere_liste_ohne_patienten() = runDbTest {
assertTrue(patientDao.observeAllWithLastDocumentation().first().isEmpty())
}
@Test
fun observeAllWithLastDocumentation_aktualisiert_sich_bei_aenderungen() = runDbTest {
seedPatient("p-1")
val waiting = async {
patientDao.observeAllWithLastDocumentation().awaitMatching { list ->
list.size == 2 && list.any { it.patient.id == "p-1" && it.lastDocumentationAt == 9_000L }
}
}
seedPatient("p-2", firstName = "Erika", lastName = "Musterfrau")
seedDocumentation(id = "d-1", patientId = "p-1", createdAt = 9_000L)
assertEquals(2, waiting.await().size)
}
}
@@ -0,0 +1,168 @@
package de.medapp.wunddoku.data.dao
import android.database.sqlite.SQLiteConstraintException
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.photoEntity
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureEntity
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** DAO-Tests für [PhotoDao] und [SignatureDao] (beide nach demselben Muster aufgebaut). */
class PhotoAndSignatureDaoTest : RoomTestBase() {
// --- PhotoDao ------------------------------------------------------------------------
@Test
fun photo_insert_und_getById_liefern_alle_felder() = runDbTest {
seedDocumentation()
val photo = photoEntity(fileName = "wunde_2026-08-23.jpg", orderIndex = 2, createdAt = 5_000L)
photoDao.insert(photo)
assertEquals(photo, photoDao.getById("ph-1"))
assertNull(photoDao.getById("unbekannt"))
}
@Test
fun photo_getForDocumentation_sortiert_nach_orderIndex_und_filtert_nach_dokumentation() = runDbTest {
seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
photoDao.insert(photoEntity(id = "ph-3", documentationId = "d-1", orderIndex = 2))
photoDao.insert(photoEntity(id = "ph-1", documentationId = "d-1", orderIndex = 0))
photoDao.insert(photoEntity(id = "ph-2", documentationId = "d-1", orderIndex = 1))
photoDao.insert(photoEntity(id = "ph-x", documentationId = "d-2", orderIndex = 0))
assertEquals(listOf("ph-1", "ph-2", "ph-3"), photoDao.getForDocumentation("d-1").map { it.id })
assertEquals(listOf("ph-x"), photoDao.getForDocumentation("d-2").map { it.id })
assertTrue(photoDao.getForDocumentation("unbekannt").isEmpty())
}
@Test
fun photo_getMaxOrderIndex_ist_minus_eins_ohne_fotos_sonst_der_hoechste_wert() = runDbTest {
seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
assertEquals(-1, photoDao.getMaxOrderIndex("d-1"))
photoDao.insert(photoEntity(id = "ph-1", documentationId = "d-1", orderIndex = 0))
photoDao.insert(photoEntity(id = "ph-2", documentationId = "d-1", orderIndex = 4))
photoDao.insert(photoEntity(id = "ph-3", documentationId = "d-2", orderIndex = 9))
assertEquals(4, photoDao.getMaxOrderIndex("d-1"))
assertEquals(9, photoDao.getMaxOrderIndex("d-2"))
assertEquals(-1, photoDao.getMaxOrderIndex("unbekannt"))
}
@Test
fun photo_getMaxOrderIndex_bleibt_nach_dem_loeschen_eines_mittleren_fotos_korrekt() = runDbTest {
seedDocumentation()
photoDao.insert(photoEntity(id = "ph-1", orderIndex = 0))
photoDao.insert(photoEntity(id = "ph-2", orderIndex = 1))
photoDao.insert(photoEntity(id = "ph-3", orderIndex = 2))
photoDao.delete(photoEntity(id = "ph-2", orderIndex = 1))
assertEquals(2, photoDao.getMaxOrderIndex("d-1"))
assertEquals(listOf(0, 2), photoDao.getForDocumentation("d-1").map { it.orderIndex })
}
@Test
fun photo_delete_entfernt_nur_das_angegebene_foto() = runDbTest {
seedDocumentation()
photoDao.insert(photoEntity(id = "ph-1", orderIndex = 0))
photoDao.insert(photoEntity(id = "ph-2", orderIndex = 1))
photoDao.delete(photoEntity(id = "ph-1"))
assertNull(photoDao.getById("ph-1"))
assertEquals("ph-2", photoDao.getById("ph-2")?.id)
}
@Test
fun photo_mit_gleichem_orderIndex_ist_erlaubt_kein_unique_index() = runDbTest {
seedDocumentation()
photoDao.insert(photoEntity(id = "ph-1", orderIndex = 0))
photoDao.insert(photoEntity(id = "ph-2", orderIndex = 0))
assertEquals(2, photoDao.getForDocumentation("d-1").size)
}
@Test
fun photo_mit_vorhandener_id_oder_unbekannter_dokumentation_wirft() = runDbTest {
seedDocumentation()
photoDao.insert(photoEntity(id = "ph-1"))
expectThrows<SQLiteConstraintException> { photoDao.insert(photoEntity(id = "ph-1", fileName = "andere.jpg")) }
expectThrows<SQLiteConstraintException> { photoDao.insert(photoEntity(id = "ph-2", documentationId = "gibt-es-nicht")) }
assertEquals(1, countRows("photos"))
}
// --- SignatureDao --------------------------------------------------------------------
@Test
fun signature_insert_und_getById_liefern_alle_felder() = runDbTest {
seedDocumentation()
val signature = signatureEntity(signerName = "Dr. Åsa Müller-Öz", fileName = "sig_1.png", orderIndex = 1, createdAt = 7_000L)
signatureDao.insert(signature)
assertEquals(signature, signatureDao.getById("s-1"))
assertNull(signatureDao.getById("unbekannt"))
}
@Test
fun signature_getForDocumentation_sortiert_nach_orderIndex_und_filtert_nach_dokumentation() = runDbTest {
seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
signatureDao.insert(signatureEntity(id = "s-3", documentationId = "d-1", orderIndex = 2))
signatureDao.insert(signatureEntity(id = "s-1", documentationId = "d-1", orderIndex = 0))
signatureDao.insert(signatureEntity(id = "s-2", documentationId = "d-1", orderIndex = 1))
signatureDao.insert(signatureEntity(id = "s-x", documentationId = "d-2", orderIndex = 0))
assertEquals(listOf("s-1", "s-2", "s-3"), signatureDao.getForDocumentation("d-1").map { it.id })
assertEquals(listOf("s-x"), signatureDao.getForDocumentation("d-2").map { it.id })
assertTrue(signatureDao.getForDocumentation("unbekannt").isEmpty())
}
@Test
fun signature_getMaxOrderIndex_ist_minus_eins_ohne_signaturen_sonst_der_hoechste_wert() = runDbTest {
seedDocumentation(id = "d-1")
seedDocumentation(id = "d-2")
assertEquals(-1, signatureDao.getMaxOrderIndex("d-1"))
signatureDao.insert(signatureEntity(id = "s-1", documentationId = "d-1", orderIndex = 0))
signatureDao.insert(signatureEntity(id = "s-2", documentationId = "d-1", orderIndex = 3))
signatureDao.insert(signatureEntity(id = "s-3", documentationId = "d-2", orderIndex = 7))
assertEquals(3, signatureDao.getMaxOrderIndex("d-1"))
assertEquals(7, signatureDao.getMaxOrderIndex("d-2"))
}
@Test
fun signature_delete_entfernt_nur_die_angegebene_signatur() = runDbTest {
seedDocumentation()
signatureDao.insert(signatureEntity(id = "s-1", orderIndex = 0))
signatureDao.insert(signatureEntity(id = "s-2", orderIndex = 1))
signatureDao.delete(signatureEntity(id = "s-1"))
assertNull(signatureDao.getById("s-1"))
assertEquals("s-2", signatureDao.getById("s-2")?.id)
}
@Test
fun signature_mit_vorhandener_id_oder_unbekannter_dokumentation_wirft() = runDbTest {
seedDocumentation()
signatureDao.insert(signatureEntity(id = "s-1"))
expectThrows<SQLiteConstraintException> { signatureDao.insert(signatureEntity(id = "s-1", signerName = "Anderer")) }
expectThrows<SQLiteConstraintException> { signatureDao.insert(signatureEntity(id = "s-2", documentationId = "gibt-es-nicht")) }
assertEquals(1, countRows("signatures"))
}
}
@@ -0,0 +1,229 @@
package de.medapp.wunddoku.data.dao
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.awaitMatching
import de.medapp.wunddoku.testutil.documentationEntity
import de.medapp.wunddoku.testutil.runDbTest
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Suchtests (QA-002): [DocumentationDao.search] und [PatientDao.searchWithLastDocumentation].
*
* Szenario (Erstelldatum in Klammern):
* - p-1 Max Mustermann, 1980-03-14: d-1 (1000), d-2 (3000)
* - p-2 Erika Musterfrau, 1975-12-01: d-3 (2000)
* - p-3 Hans Beispiel, 1990-07-30: d-4 (500)
* - p-4 Zora Zimmer, 2001-01-01: keine Dokumentation
*/
class SearchQueryTest : RoomTestBase() {
private suspend fun seedDoc(id: String, patientId: String, createdAt: Long, first: String, last: String, birth: String) {
documentationDao.insert(
documentationEntity(
id = id, patientId = patientId, createdAt = createdAt,
firstNameSnapshot = first, lastNameSnapshot = last, birthDateSnapshot = birth,
)
)
}
private suspend fun seedScenario() {
seedPatient("p-1", "Max", "Mustermann", "1980-03-14")
seedPatient("p-2", "Erika", "Musterfrau", "1975-12-01")
seedPatient("p-3", "Hans", "Beispiel", "1990-07-30")
seedPatient("p-4", "Zora", "Zimmer", "2001-01-01")
seedDoc("d-1", "p-1", 1_000L, "Max", "Mustermann", "1980-03-14")
seedDoc("d-2", "p-1", 3_000L, "Max", "Mustermann", "1980-03-14")
seedDoc("d-3", "p-2", 2_000L, "Erika", "Musterfrau", "1975-12-01")
seedDoc("d-4", "p-3", 500L, "Hans", "Beispiel", "1990-07-30")
}
private suspend fun searchDocs(query: String) = documentationDao.search(query).first().map { it.id }
// --- Dokumentationssuche -------------------------------------------------------------
@Test
fun dokumentationssuche_findet_ueber_den_nachnamen_neueste_zuerst() = runDbTest {
seedScenario()
assertEquals(listOf("d-2", "d-3", "d-1"), searchDocs("Muster"))
assertEquals(listOf("d-2", "d-1"), searchDocs("Mustermann"))
}
@Test
fun dokumentationssuche_findet_ueber_den_vornamen_ohne_beachtung_der_gross_und_kleinschreibung() = runDbTest {
seedScenario()
assertEquals(listOf("d-3"), searchDocs("erika"))
assertEquals(listOf("d-3"), searchDocs("ERIKA"))
}
@Test
fun dokumentationssuche_findet_ueber_das_geburtsdatum_auch_teilweise() = runDbTest {
seedScenario()
assertEquals(listOf("d-4"), searchDocs("1990-07"))
assertEquals(listOf("d-2", "d-1"), searchDocs("1980"))
assertEquals(listOf("d-3"), searchDocs("12-01"))
}
@Test
fun dokumentationssuche_ist_ein_teilstring_abgleich() = runDbTest {
seedScenario()
assertEquals(listOf("d-2", "d-1"), searchDocs("term"))
}
@Test
fun dokumentationssuche_ohne_treffer_liefert_leere_liste() = runDbTest {
seedScenario()
assertTrue(searchDocs("Zzz").isEmpty())
}
@Test
fun dokumentationssuche_liefert_keine_dubletten_wenn_mehrere_spalten_treffen() = runDbTest {
seedScenario()
// "e" trifft bei p-2 gleichzeitig Vor- und Nachnamen (Erika / Musterfrau).
val ids = searchDocs("e")
assertEquals(listOf("d-2", "d-3", "d-1", "d-4"), ids)
assertEquals(ids.size, ids.toSet().size)
}
@Test
fun dokumentationssuche_findet_keine_patienten_ohne_dokumentation() = runDbTest {
seedScenario()
assertTrue(searchDocs("Zora").isEmpty())
}
@Test
fun dokumentationssuche_mit_leerem_text_liefert_alle_dokumentationen_der_schutz_davor_liegt_im_repository() = runDbTest {
seedScenario()
assertEquals(listOf("d-2", "d-3", "d-1", "d-4"), searchDocs(""))
}
@Test
fun dokumentationssuche_sucht_gegen_den_aktuellen_namen_und_nicht_gegen_den_snapshot() = runDbTest {
seedScenario()
patientDao.update(patientDao.getById("p-1")!!.copy(lastName = "Neuname"))
assertEquals(listOf("d-2", "d-1"), searchDocs("Neuname"))
assertTrue(searchDocs("Mustermann").isEmpty())
// Die gelieferten Zeilen zeigen weiterhin den eingefrorenen Namen.
val hits = documentationDao.search("Neuname").first()
assertTrue(hits.all { it.patientLastNameSnapshot == "Mustermann" })
}
@Test
fun dokumentationssuche_behandelt_sql_sonderzeichen_als_normalen_text() = runDbTest {
seedScenario()
assertTrue(searchDocs("'; DROP TABLE patients; --").isEmpty())
assertTrue(searchDocs("\"").isEmpty())
assertTrue(searchDocs("O'Brien").isEmpty())
assertEquals(4, countRows("patients"))
}
@Test
fun dokumentationssuche_aktualisiert_sich_bei_neuen_dokumentationen_und_namensaenderungen() = runDbTest {
seedScenario()
val afterNewDoc = async { documentationDao.search("Muster").awaitMatching { it.size == 4 } }
seedDoc("d-5", "p-1", 4_000L, "Max", "Mustermann", "1980-03-14")
assertEquals("d-5", afterNewDoc.await().first().id)
val afterRename = async { documentationDao.search("Neuname").awaitMatching { it.size == 3 } }
patientDao.update(patientDao.getById("p-1")!!.copy(lastName = "Neuname"))
assertEquals(listOf("d-5", "d-2", "d-1"), afterRename.await().map { it.id })
}
// --- Patientensuche ------------------------------------------------------------------
private suspend fun searchPatients(query: String) = patientDao.searchWithLastDocumentation(query).first()
@Test
fun patientensuche_sortiert_treffer_nach_nachname_und_liefert_letztes_dokumentationsdatum() = runDbTest {
seedScenario()
val result = searchPatients("Muster")
assertEquals(listOf("p-2", "p-1"), result.map { it.patient.id }) // Musterfrau vor Mustermann
assertEquals(listOf(2_000L, 3_000L), result.map { it.lastDocumentationAt })
}
@Test
fun patientensuche_findet_ueber_vorname_nachname_und_geburtsdatum() = runDbTest {
seedScenario()
assertEquals(listOf("p-3"), searchPatients("han").map { it.patient.id })
assertEquals(listOf("p-3"), searchPatients("beispiel").map { it.patient.id })
assertEquals(listOf("p-2"), searchPatients("1975").map { it.patient.id })
assertTrue(searchPatients("Zzz").isEmpty())
}
@Test
fun patientensuche_findet_auch_patienten_ohne_dokumentation_mit_null_datum() = runDbTest {
seedScenario()
val result = searchPatients("Zora").single()
assertEquals("p-4", result.patient.id)
assertNull(result.lastDocumentationAt)
}
@Test
fun patientensuche_liefert_keine_dubletten_trotz_mehrerer_dokumentationen_und_treffer_spalten() = runDbTest {
seedScenario()
val ids = searchPatients("e").map { it.patient.id }
assertEquals(ids.size, ids.toSet().size)
assertEquals(listOf("p-3", "p-2", "p-1", "p-4"), ids) // Beispiel, Musterfrau, Mustermann, Zimmer
}
@Test
fun patientensuche_mit_leerem_text_liefert_alle_patienten_sortiert() = runDbTest {
seedScenario()
assertEquals(listOf("p-3", "p-2", "p-1", "p-4"), searchPatients("").map { it.patient.id })
}
@Test
fun patientensuche_sortiert_bei_gleichem_nachnamen_nach_vornamen() = runDbTest {
seedPatient("p-1", "Max", "Müller", "1980-01-01")
seedPatient("p-2", "Anna", "Müller", "1981-01-01")
assertEquals(listOf("p-2", "p-1"), searchPatients("Müller").map { it.patient.id })
}
@Test
fun patientensuche_behandelt_sql_sonderzeichen_als_normalen_text() = runDbTest {
seedScenario()
assertTrue(searchPatients("'; DROP TABLE patients; --").isEmpty())
assertEquals(4, countRows("patients"))
}
@Test
fun patientensuche_aktualisiert_sich_bei_neuen_patienten_und_dokumentationen() = runDbTest {
seedScenario()
val afterNewPatient = async { patientDao.searchWithLastDocumentation("Muster").awaitMatching { it.size == 3 } }
seedPatient("p-5", "Karl", "Musterkind", "2010-05-05")
assertEquals(3, afterNewPatient.await().size)
val afterNewDoc = async {
patientDao.searchWithLastDocumentation("Musterkind").awaitMatching { list -> list.singleOrNull()?.lastDocumentationAt == 8_000L }
}
seedDoc("d-9", "p-5", 8_000L, "Karl", "Musterkind", "2010-05-05")
afterNewDoc.await()
}
}
@@ -0,0 +1,128 @@
package de.medapp.wunddoku.data.dao
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.woundCarerEntity
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** DAO-Tests für [WoundCarerDao] (Wundversorger-Register). */
class WoundCarerDaoTest : RoomTestBase() {
// --- insertOrUpdate ------------------------------------------------------------------
@Test
fun insertOrUpdate_legt_neuen_eintrag_an() = runDbTest {
val carer = woundCarerEntity(updatedAt = 5_000L)
woundCarerDao.insertOrUpdate(carer)
assertEquals(carer, woundCarerDao.getMostRecentlyUsed())
}
@Test
fun insertOrUpdate_ersetzt_vorhandenen_eintrag_mit_gleicher_id_vollstaendig() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna Schmidt", signatureFileName = "alt.png", updatedAt = 1_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna Schmidt-Neu", signatureFileName = "neu.png", updatedAt = 2_000L))
assertEquals(1, countRows("wound_carers"))
val loaded = woundCarerDao.getMostRecentlyUsed()!!
assertEquals("Anna Schmidt-Neu", loaded.name)
assertEquals("neu.png", loaded.signatureFileName)
assertEquals(2_000L, loaded.updatedAt)
}
@Test
fun insertOrUpdate_mit_verschiedenen_ids_und_gleichem_namen_legt_zwei_eintraege_an() = runDbTest {
// Namens-Eindeutigkeit stellt das Repository (findByName) sicher, nicht die Datenbank.
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna"))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Anna"))
assertEquals(2, countRows("wound_carers"))
}
// --- findByName ----------------------------------------------------------------------
@Test
fun findByName_ist_ein_exakter_abgleich_ohne_beachtung_der_gross_und_kleinschreibung() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna Schmidt"))
assertEquals("w-1", woundCarerDao.findByName("Anna Schmidt")?.id)
assertEquals("w-1", woundCarerDao.findByName("anna schmidt")?.id)
assertEquals("w-1", woundCarerDao.findByName("ANNA SCHMIDT")?.id)
}
@Test
fun findByName_trifft_nicht_bei_teilstring_oder_praefix() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(name = "Anna Schmidt"))
assertNull(woundCarerDao.findByName("Anna"))
assertNull(woundCarerDao.findByName("Schmidt"))
assertNull(woundCarerDao.findByName("Anna Schmidt "))
assertNull(woundCarerDao.findByName("unbekannt"))
}
// --- searchByNamePrefix --------------------------------------------------------------
@Test
fun searchByNamePrefix_liefert_praefix_treffer_ohne_beachtung_der_gross_und_kleinschreibung() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna Schmidt", updatedAt = 1_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Andreas Müller", updatedAt = 2_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-3", name = "Bernd Beispiel", updatedAt = 3_000L))
assertEquals(listOf("w-2", "w-1"), woundCarerDao.searchByNamePrefix("an", 10).map { it.id })
assertEquals(listOf("w-2", "w-1"), woundCarerDao.searchByNamePrefix("AN", 10).map { it.id })
assertEquals(listOf("w-3"), woundCarerDao.searchByNamePrefix("Bern", 10).map { it.id })
}
@Test
fun searchByNamePrefix_ist_kein_teilstring_abgleich() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(name = "Anna Schmidt"))
assertTrue(woundCarerDao.searchByNamePrefix("Schmidt", 10).isEmpty())
assertTrue(woundCarerDao.searchByNamePrefix("nna", 10).isEmpty())
}
@Test
fun searchByNamePrefix_sortiert_nach_letzter_verwendung_absteigend_und_beachtet_das_limit() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna A", updatedAt = 1_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Anna B", updatedAt = 4_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-3", name = "Anna C", updatedAt = 3_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-4", name = "Anna D", updatedAt = 2_000L))
assertEquals(listOf("w-2", "w-3", "w-4", "w-1"), woundCarerDao.searchByNamePrefix("Anna", 10).map { it.id })
assertEquals(listOf("w-2", "w-3"), woundCarerDao.searchByNamePrefix("Anna", 2).map { it.id })
assertTrue(woundCarerDao.searchByNamePrefix("Anna", 0).isEmpty())
}
@Test
fun searchByNamePrefix_mit_leerem_praefix_liefert_alle_eintraege_der_schutz_davor_liegt_im_repository() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna"))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Bernd", updatedAt = 2_000L))
assertEquals(2, woundCarerDao.searchByNamePrefix("", 10).size)
}
// --- getMostRecentlyUsed -------------------------------------------------------------
@Test
fun getMostRecentlyUsed_ist_null_bei_leerer_tabelle() = runDbTest {
assertNull(woundCarerDao.getMostRecentlyUsed())
}
@Test
fun getMostRecentlyUsed_liefert_den_eintrag_mit_dem_hoechsten_updatedAt() = runDbTest {
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna", updatedAt = 1_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-2", name = "Bernd", updatedAt = 3_000L))
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-3", name = "Clara", updatedAt = 2_000L))
assertEquals("w-2", woundCarerDao.getMostRecentlyUsed()?.id)
woundCarerDao.insertOrUpdate(woundCarerEntity(id = "w-1", name = "Anna", updatedAt = 9_000L))
assertEquals("w-1", woundCarerDao.getMostRecentlyUsed()?.id)
}
}
@@ -0,0 +1,222 @@
package de.medapp.wunddoku.data.database
import android.database.sqlite.SQLiteConstraintException
import androidx.room.withTransaction
import de.medapp.wunddoku.testutil.RoomTestBase
import de.medapp.wunddoku.testutil.blockStateEntity
import de.medapp.wunddoku.testutil.checkItemStateEntity
import de.medapp.wunddoku.testutil.documentationEntity
import de.medapp.wunddoku.testutil.expectThrows
import de.medapp.wunddoku.testutil.photoEntity
import de.medapp.wunddoku.testutil.runDbTest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
/**
* Transaction-Tests (QA-002): Room-Transaktionen (`db.withTransaction`) müssen Mehrtabellen-
* Schreibvorgänge - wie in DocumentationRepository.createEmpty()/continueDocumentation() -
* atomar machen.
*
* Hinweis: In-Memory-Datenbanken haben nur eine Verbindung. Deshalb wird innerhalb einer
* Transaktion ausschließlich über DAOs gelesen, nie über Roh-SQL des Test-Threads.
*/
class DatabaseTransactionTest : RoomTestBase() {
// --- Commit --------------------------------------------------------------------------
@Test
fun commit_macht_alle_schreibvorgaenge_sichtbar() = runDbTest {
seedPatient()
db.withTransaction {
documentationDao.insert(documentationEntity())
blockStateDao.insertAll(listOf(blockStateEntity()))
checkItemStateDao.insertAll(listOf(checkItemStateEntity()))
photoDao.insert(photoEntity())
}
assertEquals(1, countRows("documentations"))
assertEquals(1, countRows("block_states"))
assertEquals(1, countRows("check_item_states"))
assertEquals(1, countRows("photos"))
}
@Test
fun transaktion_gibt_das_ergebnis_des_blocks_zurueck() = runDbTest {
val result = db.withTransaction {
seedPatient()
42
}
assertEquals(42, result)
assertNotNull(patientDao.getById("p-1"))
}
@Test
fun innerhalb_der_transaktion_sind_die_eigenen_schreibvorgaenge_bereits_lesbar() = runDbTest {
seedPatient()
db.withTransaction {
documentationDao.insert(documentationEntity())
assertNotNull(documentationDao.getById("d-1"))
blockStateDao.insertAll(listOf(blockStateEntity()))
assertEquals(1, blockStateDao.getForDocumentation("d-1").size)
}
}
// --- Rollback ------------------------------------------------------------------------
@Test
fun exception_im_block_verwirft_alle_schreibvorgaenge_und_wird_weitergereicht() = runDbTest {
seedPatient()
val error = expectThrows<IllegalStateException> {
db.withTransaction {
documentationDao.insert(documentationEntity())
blockStateDao.insertAll(listOf(blockStateEntity()))
checkItemStateDao.insertAll(listOf(checkItemStateEntity()))
error("Abbruch mitten in der Transaktion")
}
}
assertEquals("Abbruch mitten in der Transaktion", error.message)
assertNull(documentationDao.getById("d-1"))
assertEquals(0, countRows("block_states"))
assertEquals(0, countRows("check_item_states"))
assertNotNull("Daten vor der Transaktion bleiben bestehen", patientDao.getById("p-1"))
}
@Test
fun constraint_verletzung_in_einem_spaeteren_schritt_verwirft_auch_die_frueheren_inserts() = runDbTest {
seedPatient()
expectThrows<SQLiteConstraintException> {
db.withTransaction {
documentationDao.insert(documentationEntity())
photoDao.insert(photoEntity(documentationId = "gibt-es-nicht")) // FK-Verletzung
}
}
assertNull(documentationDao.getById("d-1"))
assertEquals(0, countRows("photos"))
}
@Test
fun insertAll_ist_atomar_ein_fehlerhafter_eintrag_verwirft_die_ganze_liste() = runDbTest {
seedDocumentation()
expectThrows<SQLiteConstraintException> {
blockStateDao.insertAll(
listOf(
blockStateEntity(id = "b-1", blockDefinitionId = "wundart"),
blockStateEntity(id = "b-2", blockDefinitionId = "groesse"),
blockStateEntity(id = "b-3", blockDefinitionId = "wundart"), // verletzt UNIQUE
)
)
}
assertEquals(0, countRows("block_states"))
}
@Test
fun rollback_nimmt_ein_cascade_loeschen_wieder_zurueck() = runDbTest {
val doc = seedDocumentation()
blockStateDao.insertAll(listOf(blockStateEntity()))
checkItemStateDao.insertAll(listOf(checkItemStateEntity()))
photoDao.insert(photoEntity())
expectThrows<IllegalStateException> {
db.withTransaction {
documentationDao.delete(doc)
assertNull(documentationDao.getById("d-1"))
error("Abbruch nach dem Löschen")
}
}
assertNotNull(documentationDao.getById("d-1"))
assertEquals(1, countRows("block_states"))
assertEquals(1, countRows("check_item_states"))
assertEquals(1, countRows("photos"))
}
@Test
fun datenbank_bleibt_nach_einer_fehlgeschlagenen_transaktion_voll_nutzbar() = runDbTest {
seedPatient()
expectThrows<IllegalStateException> {
db.withTransaction {
documentationDao.insert(documentationEntity(id = "d-verloren"))
error("Abbruch")
}
}
db.withTransaction { documentationDao.insert(documentationEntity(id = "d-ok")) }
documentationDao.insert(documentationEntity(id = "d-direkt"))
assertNull(documentationDao.getById("d-verloren"))
assertNotNull(documentationDao.getById("d-ok"))
assertNotNull(documentationDao.getById("d-direkt"))
}
// --- Verschachtelung -----------------------------------------------------------------
@Test
fun verschachtelte_transaktionen_commiten_gemeinsam() = runDbTest {
seedPatient()
db.withTransaction {
documentationDao.insert(documentationEntity(id = "d-aussen"))
db.withTransaction { documentationDao.insert(documentationEntity(id = "d-innen")) }
}
assertEquals(2, countRows("documentations"))
}
@Test
fun fehler_in_der_inneren_transaktion_rollt_auch_die_aeussere_zurueck_selbst_wenn_er_gefangen_wird() = runDbTest {
// SQLite/Android: Wird eine verschachtelte Transaktion nicht als erfolgreich beendet,
// wird die äußere ebenfalls zurückgerollt (SQLiteDatabase.beginTransaction-Doku).
seedPatient()
db.withTransaction {
documentationDao.insert(documentationEntity(id = "d-aussen"))
try {
db.withTransaction {
documentationDao.insert(documentationEntity(id = "d-innen"))
error("Fehler innen")
}
} catch (e: IllegalStateException) {
// bewusst gefangen: "e" ist unbenutzt, der Fehler soll die äußere Transaktion nicht abbrechen
}
}
assertEquals(0, countRows("documentations"))
}
// --- Nebenläufigkeit -----------------------------------------------------------------
@Test
fun parallele_transaktionen_werden_serialisiert_und_verlieren_keine_schreibvorgaenge() = runDbTest {
seedDocumentation()
coroutineScope {
listOf("a", "b", "c").map { prefix ->
async(Dispatchers.Default) {
repeat(20) { i ->
db.withTransaction {
photoDao.insert(photoEntity(id = "$prefix-$i", fileName = "$prefix$i.jpg", orderIndex = i))
}
}
}
}.awaitAll()
}
assertEquals(60, countRows("photos"))
assertEquals(60, photoDao.getForDocumentation("d-1").size)
}
}
@@ -1,89 +0,0 @@
package de.medapp.wunddoku.data.repository
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.pdf.ExportedFilesCleaner
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.buildRepository
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/**
* Datenschutz-Löschkonzept: Beim Löschen einer Dokumentation müssen ALLE personenbezogenen
* Daten verschwinden - Datenbankzeilen, Fotos, Signaturen und abgeleitete Exporte (PDF/Bilder).
*/
@RunWith(AndroidJUnit4::class)
class DocumentationDeletionPrivacyTest {
private lateinit var env: TestEnvironment
private class RecordingCleaner(private val failure: Throwable? = null) : ExportedFilesCleaner {
val deleted = mutableListOf<String>()
override suspend fun deleteExportsFor(documentationId: String) {
deleted += documentationId
failure?.let { throw it }
}
override suspend fun deleteStaleFiles() = Unit
}
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
private suspend fun createDocumentationWithData(repo: DocumentationRepository): String {
env.insertPatient()
val id = repo.createEmpty("patient-1").id
repo.addPhoto(id, Uri.parse("content://test/1"))
repo.saveSignature(id, "Pfleger Paul", signatureBitmap())
return id
}
@Test
fun loeschen_entfernt_auch_die_exportierten_dateien() = runDbTest {
val cleaner = RecordingCleaner()
val repo = env.buildRepository(exportedFilesCleaner = cleaner)
val id = createDocumentationWithData(repo)
repo.delete(id)
assertEquals(listOf(id), cleaner.deleted)
assertNull(repo.getFullDocumentation(id))
assertTrue(env.photoStorage.deletedAll.contains(id))
assertTrue(env.signatureStorage.deletedAll.contains(id))
}
@Test
fun fehler_beim_loeschen_der_exporte_verhindert_das_loeschen_der_dokumentation_nicht() = runDbTest {
val repo = env.buildRepository(exportedFilesCleaner = RecordingCleaner(IllegalStateException("Test")))
val id = createDocumentationWithData(repo)
repo.delete(id)
assertNull(repo.getFullDocumentation(id))
assertTrue(repo.observeAll().first().isEmpty())
}
@Test
fun loeschen_einer_nicht_vorhandenen_dokumentation_loescht_keine_exporte() = runDbTest {
val cleaner = RecordingCleaner()
val repo = env.buildRepository(exportedFilesCleaner = cleaner)
repo.delete("gibt-es-nicht")
assertTrue(cleaner.deleted.isEmpty())
}
}
@@ -1,274 +0,0 @@
package de.medapp.wunddoku.data.repository
import android.database.sqlite.SQLiteFullException
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.testutil.FailingPhotoDao
import de.medapp.wunddoku.testutil.FailingPhotoStorage
import de.medapp.wunddoku.testutil.FailingSignatureDao
import de.medapp.wunddoku.testutil.FailingSignatureStorage
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.ThrowingPdfExporter
import de.medapp.wunddoku.testutil.ThrowingPdfToImagesConverter
import de.medapp.wunddoku.testutil.buildRepository
import de.medapp.wunddoku.testutil.expectThrowsSuspend
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.io.IOException
/**
* Fehler- und Datenverlust-Tests für [DocumentationRepository] gegen eine echte In-Memory-
* Room-Datenbank: Speichermangel (Datenbank/Dateisystem voll, Arbeitsspeicher), halbe
* Ergebnisse, verwaiste Dateien und das "wirft-nie"-Verhalten des PDF-Exports.
*/
@RunWith(AndroidJUnit4::class)
class DocumentationRepositoryErrorHandlingTest {
private lateinit var env: TestEnvironment
private val someUri: Uri = Uri.parse("content://test/bild/1")
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
private suspend fun newDocumentationId(): String {
env.insertPatient()
return env.documentationRepository.createEmpty("patient-1").id
}
// --- Foto-Import ---------------------------------------------------------------------
@Test
fun addPhoto_bei_fehlgeschlagenem_dateiimport_legt_keinen_db_eintrag_an() = runDbTest {
val documentationId = newDocumentationId()
val storage = FailingPhotoStorage().apply { importFailure = { IOException("Bild defekt (Test)") } }
val repo = env.buildRepository(photoStorage = storage)
expectThrowsSuspend<IOException> { repo.addPhoto(documentationId, someUri) }
assertTrue(env.db.photoDao().getForDocumentation(documentationId).isEmpty())
}
@Test
fun addPhoto_bei_speichermangel_im_arbeitsspeicher_legt_keinen_db_eintrag_an() = runDbTest {
val documentationId = newDocumentationId()
val storage = FailingPhotoStorage().apply { importFailure = { OutOfMemoryError("Test") } }
val repo = env.buildRepository(photoStorage = storage)
expectThrowsSuspend<OutOfMemoryError> { repo.addPhoto(documentationId, someUri) }
assertTrue(env.db.photoDao().getForDocumentation(documentationId).isEmpty())
}
@Test
fun addPhoto_bei_voller_datenbank_entfernt_die_bereits_importierte_datei() = runDbTest {
val documentationId = newDocumentationId()
val storage = FailingPhotoStorage()
val photoDao = FailingPhotoDao(env.db.photoDao()).apply {
insertFailure = SQLiteFullException("database or disk is full")
}
val repo = env.buildRepository(photoStorage = storage, photoDao = photoDao)
expectThrowsSuspend<SQLiteFullException> { repo.addPhoto(documentationId, someUri) }
assertEquals(1, storage.delegate.imported.size)
assertEquals("verwaiste Datei muss entfernt werden", 1, storage.delegate.deleted.size)
assertEquals(documentationId, storage.delegate.deleted.single().first)
assertTrue(env.db.photoDao().getForDocumentation(documentationId).isEmpty())
}
@Test
fun addPhoto_funktioniert_nach_einem_fehlgeschlagenen_versuch_wieder() = runDbTest {
val documentationId = newDocumentationId()
val storage = FailingPhotoStorage().apply { importFailure = { call -> if (call == 1) IOException("einmalig") else null } }
val repo = env.buildRepository(photoStorage = storage)
expectThrowsSuspend<IOException> { repo.addPhoto(documentationId, someUri) }
repo.addPhoto(documentationId, someUri)
assertEquals(1, env.db.photoDao().getForDocumentation(documentationId).size)
}
// --- Signaturen ----------------------------------------------------------------------
@Test
fun saveSignature_bei_dateifehler_legt_weder_db_eintrag_noch_registereintrag_an() = runDbTest {
val documentationId = newDocumentationId()
val storage = FailingSignatureStorage().apply { saveFailure = IOException("Speicher voll (Test)") }
val repo = env.buildRepository(signatureStorage = storage)
expectThrowsSuspend<IOException> { repo.saveSignature(documentationId, "Pfleger Paul", signatureBitmap()) }
assertTrue(env.db.signatureDao().getForDocumentation(documentationId).isEmpty())
assertNull(env.woundCarerRepository.getMostRecentlyUsed())
}
@Test
fun saveSignature_bei_voller_datenbank_entfernt_die_gespeicherte_datei() = runDbTest {
val documentationId = newDocumentationId()
val storage = FailingSignatureStorage()
val signatureDao = FailingSignatureDao(env.db.signatureDao()).apply {
insertFailure = SQLiteFullException("database or disk is full")
}
val repo = env.buildRepository(signatureStorage = storage, signatureDao = signatureDao)
expectThrowsSuspend<SQLiteFullException> { repo.saveSignature(documentationId, "Pfleger Paul", signatureBitmap()) }
assertEquals(1, storage.delegate.saved.size)
assertEquals("verwaiste PNG-Datei muss entfernt werden", 1, storage.delegate.deleted.size)
assertTrue(env.db.signatureDao().getForDocumentation(documentationId).isEmpty())
}
@Test
fun saveSignature_bleibt_erfolgreich_wenn_nur_das_wundversorger_register_scheitert() = runDbTest {
val documentationId = newDocumentationId()
val brokenRegisterStorage = FailingSignatureStorage().apply { saveFailure = IOException("Register-Speicher voll") }
val brokenRegister = WoundCarerRepository(env.db.woundCarerDao(), brokenRegisterStorage)
val repo = env.buildRepository(woundCarerRepository = brokenRegister)
val signature = repo.saveSignature(documentationId, "Pfleger Paul", signatureBitmap())
assertEquals("Pfleger Paul", signature.signerName)
assertEquals(
"Signatur der Dokumentation muss gespeichert sein",
1,
env.db.signatureDao().getForDocumentation(documentationId).size,
)
}
// --- Fortsetzen ----------------------------------------------------------------------
@Test
fun fortsetzen_raeumt_bereits_kopierte_fotos_der_gescheiterten_kopie_auf() = runDbTest {
val sourceId = newDocumentationId()
env.documentationRepository.addPhoto(sourceId, someUri)
env.documentationRepository.addPhoto(sourceId, someUri)
env.photoStorage.failOnDuplicateCall = 2
expectThrowsSuspend<IOException> { env.documentationRepository.continueDocumentation(sourceId) }
val attemptedTarget = env.photoStorage.duplicated.first().targetDocumentationId
assertTrue(
"Ordner der nie entstandenen Dokumentation muss gelöscht werden",
attemptedTarget in env.photoStorage.deletedAll,
)
assertEquals("keine halbe Dokumentation", 1, env.documentationRepository.observeAll().first().size)
assertTrue(env.db.photoDao().getForDocumentation(attemptedTarget).isEmpty())
}
@Test
fun fortsetzen_laesst_quelle_und_deren_fotos_bei_fehler_unberuehrt() = runDbTest {
val sourceId = newDocumentationId()
env.documentationRepository.addPhoto(sourceId, someUri)
env.photoStorage.failOnDuplicateCall = 1
expectThrowsSuspend<IOException> { env.documentationRepository.continueDocumentation(sourceId) }
assertEquals(1, env.db.photoDao().getForDocumentation(sourceId).size)
assertFalse(sourceId in env.photoStorage.deletedAll)
}
// --- PDF-Export wirft nie ------------------------------------------------------------
@Test
fun exportPdf_uebersetzt_out_of_memory_in_ein_fehlerergebnis() = runDbTest {
val documentationId = newDocumentationId()
val repo = env.buildRepository(pdfExporter = ThrowingPdfExporter(OutOfMemoryError("Test")))
val result = repo.exportPdf(documentationId)
assertEquals(PdfExportResult.Failure(PdfExportError.OutOfMemory), result)
}
@Test
fun exportPdf_uebersetzt_speicher_voll_in_ein_fehlerergebnis() = runDbTest {
val documentationId = newDocumentationId()
val repo = env.buildRepository(
pdfExporter = ThrowingPdfExporter(IOException("write failed: ENOSPC (No space left on device)")),
)
val result = repo.exportPdf(documentationId)
assertEquals(PdfExportResult.Failure(PdfExportError.InsufficientStorage), result)
}
@Test
fun exportPdf_gibt_bei_unerwarteten_fehlern_keine_technischen_details_weiter() = runDbTest {
val documentationId = newDocumentationId()
val repo = env.buildRepository(
pdfExporter = ThrowingPdfExporter(RuntimeException("/files/pdf/2026_Max_Mustermann.pdf kaputt")),
)
val result = repo.exportPdf(documentationId)
assertTrue(result is PdfExportResult.Failure)
val error = (result as PdfExportResult.Failure).error
assertTrue(error is PdfExportError.Unexpected)
assertFalse((error as PdfExportError.Unexpected).message.contains("Mustermann"))
}
@Test
fun exportPdf_reicht_coroutine_abbruch_weiter() = runDbTest {
val documentationId = newDocumentationId()
val repo = env.buildRepository(pdfExporter = ThrowingPdfExporter(CancellationException("abgebrochen")))
expectThrowsSuspend<CancellationException> { repo.exportPdf(documentationId) }
}
@Test
fun exportPdf_bei_geschlossener_datenbank_liefert_fehler_statt_exception() = runDbTest {
val documentationId = newDocumentationId()
env.db.close()
val result = env.documentationRepository.exportPdf(documentationId)
assertTrue("erwartet Failure, war $result", result is PdfExportResult.Failure)
}
@Test
fun convertPdfToImages_uebersetzt_exceptions_in_fehlerergebnisse() = runDbTest {
val file = File("/fake/x.pdf")
val oom = env.buildRepository(pdfToImagesConverter = ThrowingPdfToImagesConverter(OutOfMemoryError()))
.convertPdfToImages("doc", file)
val full = env.buildRepository(
pdfToImagesConverter = ThrowingPdfToImagesConverter(IOException("No space left on device")),
).convertPdfToImages("doc", file)
val other = env.buildRepository(pdfToImagesConverter = ThrowingPdfToImagesConverter(IllegalStateException("x")))
.convertPdfToImages("doc", file)
assertEquals(PdfToImagesResult.Failure.OutOfMemory, oom)
assertEquals(PdfToImagesResult.Failure.InsufficientStorage, full)
assertTrue(other is PdfToImagesResult.Failure.Unexpected)
}
// --- Sonstiges -----------------------------------------------------------------------
@Test
fun setChecked_mit_unbekannter_id_wirft_nicht() = runDbTest {
newDocumentationId()
env.documentationRepository.setChecked("gibt-es-nicht", true)
env.documentationRepository.updateFreeText("gibt-es-nicht", "Text")
}
}
@@ -1,222 +0,0 @@
package de.medapp.wunddoku.media
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import de.medapp.wunddoku.testutil.expectThrowsSuspend
import de.medapp.wunddoku.testutil.runDbTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.UUID
/**
* Fehlerbehandlung von [InternalPhotoStorage] mit ECHTEN Dateien im App-Speicher: defekte,
* leere oder nicht vorhandene Bilder, große Bilder (Speichermangel) sowie "kein halbes
* Ergebnis zurücklassen".
*/
@RunWith(AndroidJUnit4::class)
class PhotoStorageErrorHandlingTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private lateinit var storage: InternalPhotoStorage
private lateinit var documentationId: String
private lateinit var workDir: File
@Before
fun setUp() {
storage = InternalPhotoStorage(context)
// Eigene, zufällige Dokumentations-ID je Test: keine Berührung echter App-Daten.
documentationId = "test-${UUID.randomUUID()}"
workDir = File(context.cacheDir, "errtest-${UUID.randomUUID()}").apply { mkdirs() }
}
@After
fun tearDown() {
File(File(context.filesDir, "photos"), documentationId).deleteRecursively()
workDir.deleteRecursively()
}
private fun docDir() = File(File(context.filesDir, "photos"), documentationId)
private fun filesInDocDir(): List<File> = docDir().listFiles()?.toList().orEmpty()
private fun writeJpeg(name: String, width: Int, height: Int): File {
val file = File(workDir, name)
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.RED) }
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
bitmap.recycle()
return file
}
private fun uriOf(file: File): Uri = Uri.fromFile(file)
// --- Bildfehler beim Import ----------------------------------------------------------
@Test
fun import_einer_textdatei_statt_bild_wirft_und_hinterlaesst_keine_datei() = runDbTest {
val notAnImage = File(workDir, "kein_bild.jpg").apply { writeText("Das ist kein Bild.") }
expectThrowsSuspend<Exception> { storage.importFrom(documentationId, uriOf(notAnImage)) }
assertTrue(filesInDocDir().isEmpty())
}
@Test
fun import_einer_leeren_datei_wirft_und_hinterlaesst_keine_datei() = runDbTest {
val empty = File(workDir, "leer.jpg").apply { createNewFile() }
expectThrowsSuspend<Exception> { storage.importFrom(documentationId, uriOf(empty)) }
assertTrue(filesInDocDir().isEmpty())
}
@Test
fun import_einer_abgeschnittenen_zufallsdatei_wirft_statt_abzustuerzen() = runDbTest {
val garbage = File(workDir, "muell.jpg").apply { writeBytes(ByteArray(2048) { (it * 31).toByte() }) }
expectThrowsSuspend<Exception> { storage.importFrom(documentationId, uriOf(garbage)) }
assertTrue(filesInDocDir().isEmpty())
}
@Test
fun import_einer_nicht_mehr_vorhandenen_datei_wirft() = runDbTest {
val missing = File(workDir, "gibt_es_nicht.jpg")
expectThrowsSuspend<Exception> { storage.importFrom(documentationId, uriOf(missing)) }
assertTrue(filesInDocDir().isEmpty())
}
@Test
fun import_mit_ungueltiger_content_uri_wirft() = runDbTest {
val invalid = Uri.parse("content://de.medapp.gibt.es.nicht/bild/1")
expectThrowsSuspend<Exception> { storage.importFrom(documentationId, invalid) }
assertTrue(filesInDocDir().isEmpty())
}
@Test
fun nach_einem_fehlgeschlagenen_import_funktioniert_der_naechste_import_normal() = runDbTest {
val broken = File(workDir, "kaputt.jpg").apply { writeText("x") }
val good = writeJpeg("gut.jpg", 200, 100)
expectThrowsSuspend<Exception> { storage.importFrom(documentationId, uriOf(broken)) }
val fileName = storage.importFrom(documentationId, uriOf(good))
assertTrue(storage.resolve(documentationId, fileName).exists())
assertEquals(1, filesInDocDir().size)
}
// --- Speichermangel: große Bilder ----------------------------------------------------
@Test
fun sehr_grosses_bild_wird_speicherschonend_heruntergerechnet_importiert() = runDbTest {
// 8000 x 300: viele Megapixel entlang der langen Kante, aber als Testbild selbst günstig.
val big = writeJpeg("gross.jpg", 8000, 300)
val fileName = storage.importFrom(documentationId, uriOf(big))
val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true }
android.graphics.BitmapFactory.decodeFile(storage.resolve(documentationId, fileName).absolutePath, bounds)
val longest = maxOf(bounds.outWidth, bounds.outHeight)
assertTrue("gespeichert wurde ${bounds.outWidth}x${bounds.outHeight}", longest in 1 until 2 * ImageDownsampler.MAX_STORED_DIMENSION_PX)
}
// --- Datei fehlt/defekt nach dem Import (Vorschau, PDF) ------------------------------
@Test
fun thumbnail_einer_fehlenden_datei_liefert_null() = runDbTest {
assertNull(storage.decodeThumbnail(documentationId, "fehlt.jpg", 300))
}
@Test
fun thumbnail_einer_beschaedigten_datei_liefert_null_statt_abzustuerzen() = runDbTest {
val target = storage.resolve(documentationId, "defekt.jpg")
target.parentFile!!.mkdirs()
target.writeBytes(ByteArray(512) { 7 })
assertNull(storage.decodeThumbnail(documentationId, "defekt.jpg", 300))
}
@Test
fun decodeFitted_einer_fehlenden_oder_beschaedigten_datei_liefert_null() = runDbTest {
assertNull(storage.decodeFitted(documentationId, "fehlt.jpg", 500, 800))
val target = storage.resolve(documentationId, "defekt.jpg")
target.parentFile!!.mkdirs()
target.writeBytes(ByteArray(512) { 7 })
assertNull(storage.decodeFitted(documentationId, "defekt.jpg", 500, 800))
}
@Test
fun decodeFitted_mit_null_zielgroesse_stuerzt_nicht_ab() = runDbTest {
val good = writeJpeg("gut.jpg", 200, 100)
val fileName = storage.importFrom(documentationId, uriOf(good))
// Ungültige Zielgröße (0) darf höchstens null liefern, aber keine Exception werfen.
storage.decodeFitted(documentationId, fileName, 0, 0)
}
@Test
fun decodeFitted_vergroessert_nie_ueber_die_native_aufloesung() = runDbTest {
val small = writeJpeg("klein.jpg", 100, 50)
val fileName = storage.importFrom(documentationId, uriOf(small))
val bitmap = storage.decodeFitted(documentationId, fileName, 4000, 4000)
assertNotNull(bitmap)
assertTrue(bitmap!!.width <= 100 && bitmap.height <= 50)
bitmap.recycle()
}
// --- Kopieren (Dokumentation fortsetzen) ---------------------------------------------
@Test
fun duplicate_einer_fehlenden_quelldatei_wirft_und_hinterlaesst_keine_zieldatei() = runDbTest {
val targetId = "test-${UUID.randomUUID()}"
try {
expectThrowsSuspend<Exception> { storage.duplicate(documentationId, "fehlt.jpg", targetId) }
val targetDir = File(File(context.filesDir, "photos"), targetId)
assertTrue(targetDir.listFiles().orEmpty().isEmpty())
} finally {
File(File(context.filesDir, "photos"), targetId).deleteRecursively()
}
}
@Test
fun duplicate_erzeugt_eine_eigenstaendige_kopie() = runDbTest {
val good = writeJpeg("gut.jpg", 200, 100)
val fileName = storage.importFrom(documentationId, uriOf(good))
val targetId = "test-${UUID.randomUUID()}"
try {
val copy = storage.duplicate(documentationId, fileName, targetId)
storage.delete(documentationId, fileName)
assertTrue("Kopie muss das Löschen des Originals überleben", storage.resolve(targetId, copy).exists())
} finally {
File(File(context.filesDir, "photos"), targetId).deleteRecursively()
}
}
// --- Löschen -------------------------------------------------------------------------
@Test
fun loeschen_nicht_vorhandener_dateien_und_ordner_wirft_nicht() = runDbTest {
storage.delete(documentationId, "gibt_es_nicht.jpg")
storage.deleteAll(documentationId)
assertFalse(docDir().exists())
}
}
@@ -1,228 +0,0 @@
package de.medapp.wunddoku.pdf
import android.graphics.Bitmap
import android.graphics.pdf.PdfRenderer
import android.net.Uri
import android.os.ParcelFileDescriptor
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.signature.SignatureStorage
import de.medapp.wunddoku.testutil.FakePhotoStorage
import de.medapp.wunddoku.testutil.FakeSignatureStorage
import de.medapp.wunddoku.testutil.runDbTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.time.LocalDate
import java.util.UUID
/**
* Fehlerbehandlung des echten [AndroidPdfDocumentExporter] (echte Dateien im App-Speicher):
* Schreibfehler, kein Datenverlust bereits vorhandener PDFs, keine halbfertigen Dateien sowie
* graceful degradation bei fehlenden/defekten Fotos und Signaturen.
*/
@RunWith(AndroidJUnit4::class)
class PdfExportErrorHandlingTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val documentationId = "test-${UUID.randomUUID()}"
private val fileName = "2026-08-19_Max_Mustermann_1980-03-14.pdf"
private val pdfRoot get() = File(context.filesDir, "pdf")
private val targetDir get() = File(pdfRoot, documentationId)
private val targetFile get() = File(targetDir, fileName)
@Before
fun setUp() {
pdfRoot.mkdirs()
}
@After
fun tearDown() {
targetDir.deleteRecursively()
}
private fun exporter(
photoStorage: PhotoStorage = FakePhotoStorage(),
signatureStorage: SignatureStorage = FakeSignatureStorage(),
) = AndroidPdfDocumentExporter(context, photoStorage, signatureStorage)
private fun model(photoCount: Int = 0, signatureCount: Int = 0) = PdfExportModel(
documentationId = documentationId,
fileName = fileName,
patientFullName = "Max Mustermann",
patientBirthDate = LocalDate.of(1980, 3, 14),
patientDisplayAddress = "Musterstraße 1, 12345 Musterstadt",
createdAt = LocalDate.of(2026, 8, 19),
blocks = listOf(
PdfExportBlock(
title = "Wundgrund",
items = listOf(PdfExportItem(label = "Granulation", isFreeText = false, freeTextValue = "")),
),
),
signatures = (1..signatureCount).map { PdfExportSignature("Pfleger $it", "sig_$it.png") },
photos = (1..photoCount).map { PdfExportPhoto("foto_$it.jpg") },
)
private fun pageCount(file: File): Int =
ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { fd ->
PdfRenderer(fd).use { it.pageCount }
}
private fun tmpFiles(): List<File> = targetDir.listFiles().orEmpty().filter { it.name.endsWith(".tmp") }
// --- Normalfall als Referenz ---------------------------------------------------------
@Test
fun erfolgreicher_export_erzeugt_ein_lesbares_pdf_ohne_temporaere_datei() = runDbTest {
val result = exporter().export(model())
assertTrue("erwartet Success, war $result", result is PdfExportResult.Success)
assertTrue(targetFile.exists())
assertTrue(pageCount(targetFile) >= 1)
assertTrue(tmpFiles().isEmpty())
}
// --- Schreibfehler -------------------------------------------------------------------
@Test
fun zielordner_ist_eine_datei_liefert_fehler_statt_absturz() = runDbTest {
// Unter dem PDF-Wurzelordner liegt statt des Dokumentationsordners eine normale Datei.
targetDir.deleteRecursively()
targetDir.writeText("blockiert")
val result = exporter().export(model())
assertTrue("erwartet Failure, war $result", result is PdfExportResult.Failure)
assertTrue((result as PdfExportResult.Failure).error is PdfExportError.WriteFailed)
}
@Test
fun fehler_beim_schreiben_laesst_ein_vorhandenes_pdf_unangetastet() = runDbTest {
val first = exporter().export(model())
assertTrue(first is PdfExportResult.Success)
val sizeBefore = targetFile.length()
assertTrue(sizeBefore > 0)
// Zweiter Export scheitert: am Pfad der temporären Datei steht ein Ordner.
File(targetDir, "$fileName.tmp").mkdirs()
val second = exporter().export(model(signatureCount = 1))
assertTrue("erwartet Failure, war $second", second is PdfExportResult.Failure)
assertTrue("bisheriges PDF darf nicht verloren gehen", targetFile.exists())
assertEquals(sizeBefore, targetFile.length())
}
@Test
fun scheitert_das_umbenennen_bleibt_keine_halbfertige_tmp_datei_zurueck() = runDbTest {
// Am endgültigen Pfad steht ein nicht-leerer Ordner: Löschen/Umbenennen schlagen fehl.
targetFile.mkdirs()
File(targetFile, "inhalt.txt").writeText("x")
val result = exporter().export(model())
assertTrue("erwartet Failure, war $result", result is PdfExportResult.Failure)
assertTrue(tmpFiles().isEmpty())
}
@Test
fun erneuter_export_nach_einem_fehler_funktioniert() = runDbTest {
File(targetDir, "$fileName.tmp").mkdirs()
assertTrue(exporter().export(model()) is PdfExportResult.Failure)
File(targetDir, "$fileName.tmp").deleteRecursively()
val retry = exporter().export(model())
assertTrue("erwartet Success, war $retry", retry is PdfExportResult.Success)
}
@Test
fun wiederholter_export_ersetzt_die_vorhandene_datei_statt_weitere_anzulegen() = runDbTest {
exporter().export(model())
exporter().export(model(signatureCount = 1))
assertEquals(listOf(fileName), targetDir.listFiles().orEmpty().map { it.name })
}
// --- Fotos / Signaturen fehlen oder sind defekt --------------------------------------
@Test
fun fehlende_fotodateien_fuehren_zu_hinweisseiten_statt_zum_abbruch() = runDbTest {
// FakePhotoStorage.decodeFitted liefert null (= Datei fehlt/defekt).
val result = exporter().export(model(photoCount = 3))
assertTrue("erwartet Success, war $result", result is PdfExportResult.Success)
// Kopfseite + je eine Seite pro (nicht ladbarem) Foto
assertTrue("mindestens Kopfseite + 3 Fotoseiten", pageCount(targetFile) >= 4)
}
@Test
fun foto_dekodierung_mit_out_of_memory_fuehrt_nicht_zum_absturz() = runDbTest {
val oomStorage = object : PhotoStorage by FakePhotoStorage() {
override suspend fun decodeFitted(
documentationId: String,
fileName: String,
maxWidthPx: Int,
maxHeightPx: Int,
): Bitmap? = throw OutOfMemoryError("Test: zu wenig Speicher")
}
val result = exporter(photoStorage = oomStorage).export(model(photoCount = 2))
assertTrue("erwartet Success, war $result", result is PdfExportResult.Success)
assertTrue("mindestens Kopfseite + 2 Fotoseiten", pageCount(targetFile) >= 3)
assertTrue(tmpFiles().isEmpty())
}
@Test
fun foto_dekodierung_mit_io_fehler_fuehrt_nicht_zum_abbruch() = runDbTest {
val failing = object : PhotoStorage by FakePhotoStorage() {
override suspend fun decodeFitted(
documentationId: String,
fileName: String,
maxWidthPx: Int,
maxHeightPx: Int,
): Bitmap? = throw java.io.IOException("Test: Lesefehler")
}
val result = exporter(photoStorage = failing).export(model(photoCount = 1))
assertTrue(result is PdfExportResult.Success)
}
@Test
fun fehlende_signaturdatei_fuehrt_zu_platzhalter_statt_zum_abbruch() = runDbTest {
// FakeSignatureStorage.decode liefert standardmäßig null.
val result = exporter().export(model(signatureCount = 2))
assertTrue("erwartet Success, war $result", result is PdfExportResult.Success)
assertTrue(pageCount(targetFile) >= 1)
}
@Test
fun signatur_dekodierung_mit_exception_fuehrt_nicht_zum_abbruch() = runDbTest {
val failing = object : SignatureStorage by FakeSignatureStorage() {
override suspend fun decode(documentationId: String, fileName: String): Bitmap? =
throw IllegalStateException("Test: defekte Signatur")
}
val result = exporter(signatureStorage = failing).export(model(signatureCount = 1))
assertTrue("erwartet Success, war $result", result is PdfExportResult.Success)
}
@Test
fun grosser_export_mit_vielen_nicht_ladbaren_fotos_bleibt_stabil() = runDbTest {
val result = exporter().export(model(photoCount = 40, signatureCount = 3))
assertTrue("erwartet Success, war $result", result is PdfExportResult.Success)
assertFalse(tmpFiles().isNotEmpty())
assertTrue("mindestens Kopfseite + 40 Fotoseiten", pageCount(targetFile) >= 41)
}
}
@@ -1,210 +0,0 @@
package de.medapp.wunddoku.pdf
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.os.Bundle
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.UUID
/**
* Fehlerpfade von [AndroidPdfOpener], [AndroidPdfSharer] und [AndroidPdfImageSharer]:
* fehlende Datei, Datei außerhalb der FileProvider-Freigabe, keine passende App, verweigerter
* Zugriff. Der Activity-Start wird über einen [ContextWrapper] abgefangen - es öffnet sich
* dabei nie eine echte App.
*/
@RunWith(AndroidJUnit4::class)
class PdfIntentErrorHandlingTest {
private val baseContext: Context = InstrumentationRegistry.getInstrumentation().targetContext
private val id = "test-${UUID.randomUUID()}"
private lateinit var sharedPdf: File
private lateinit var sharedImage: File
private lateinit var unsharedFile: File
/** Fängt startActivity ab und wirft optional einen vorgegebenen Fehler. */
private class InterceptingContext(base: Context, private val failure: Throwable?) : ContextWrapper(base) {
val started = mutableListOf<Intent>()
override fun startActivity(intent: Intent) {
started += intent
failure?.let { throw it }
}
override fun startActivity(intent: Intent, options: Bundle?) {
started += intent
failure?.let { throw it }
}
}
@Before
fun setUp() {
// Liegt in einem per file_paths.xml freigegebenen Ordner ("pdf/" bzw. "pdf_images/").
sharedPdf = File(File(File(baseContext.filesDir, "pdf"), id), "test.pdf").apply {
parentFile!!.mkdirs()
writeBytes(byteArrayOf(1, 2, 3))
}
sharedImage = File(File(File(baseContext.filesDir, "pdf_images"), id), "seite_01.jpg").apply {
parentFile!!.mkdirs()
writeBytes(byteArrayOf(1, 2, 3))
}
// Liegt bewusst AUSSERHALB aller freigegebenen Ordner.
unsharedFile = File(baseContext.cacheDir, "nicht_freigegeben_$id.pdf").apply { writeBytes(byteArrayOf(1)) }
}
@After
fun tearDown() {
File(File(baseContext.filesDir, "pdf"), id).deleteRecursively()
File(File(baseContext.filesDir, "pdf_images"), id).deleteRecursively()
unsharedFile.delete()
}
// --- PdfOpener -----------------------------------------------------------------------
@Test
fun opener_meldet_fehlende_datei() {
val context = InterceptingContext(baseContext, null)
val result = AndroidPdfOpener(context).open(File(baseContext.filesDir, "pdf/$id/weg.pdf"))
assertEquals(PdfOpenResult.Failure.FileNotFound, result)
assertTrue(context.started.isEmpty())
}
@Test
fun opener_meldet_datei_ausserhalb_der_freigegebenen_ordner() {
val context = InterceptingContext(baseContext, null)
val result = AndroidPdfOpener(context).open(unsharedFile)
assertTrue("erwartet UriResolutionFailed, war $result", result is PdfOpenResult.Failure.UriResolutionFailed)
assertTrue(context.started.isEmpty())
}
@Test
fun opener_meldet_fehlenden_pdf_reader() {
val context = InterceptingContext(baseContext, ActivityNotFoundException("kein Reader"))
val result = AndroidPdfOpener(context).open(sharedPdf)
assertEquals(PdfOpenResult.Failure.NoViewerAvailable, result)
}
@Test
fun opener_meldet_verweigerten_zugriff_als_unerwarteten_fehler() {
val context = InterceptingContext(baseContext, SecurityException("verweigert"))
val result = AndroidPdfOpener(context).open(sharedPdf)
assertTrue("erwartet Unexpected, war $result", result is PdfOpenResult.Failure.Unexpected)
}
@Test
fun opener_faengt_beliebige_laufzeitfehler_beim_start_ab() {
val context = InterceptingContext(baseContext, IllegalStateException("kaputt"))
val result = AndroidPdfOpener(context).open(sharedPdf)
assertTrue("erwartet Unexpected, war $result", result is PdfOpenResult.Failure.Unexpected)
}
@Test
fun opener_gibt_nur_leserechte_frei() {
val context = InterceptingContext(baseContext, null)
val result = AndroidPdfOpener(context).open(sharedPdf)
assertEquals(PdfOpenResult.Success, result)
val intent = context.started.single()
assertEquals(Intent.ACTION_VIEW, intent.action)
assertEquals("application/pdf", intent.type)
assertNotEquals(0, intent.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION)
assertEquals(0, intent.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
}
// --- PdfSharer -----------------------------------------------------------------------
@Test
fun sharer_meldet_datei_ausserhalb_der_freigegebenen_ordner() {
val context = InterceptingContext(baseContext, null)
val result = AndroidPdfSharer(context).share(unsharedFile)
assertTrue("erwartet UriResolutionFailed, war $result", result is PdfShareResult.Failure.UriResolutionFailed)
assertTrue(context.started.isEmpty())
}
@Test
fun sharer_meldet_fehlende_app_zum_versenden() {
val context = InterceptingContext(baseContext, ActivityNotFoundException("keine App"))
assertEquals(PdfShareResult.Failure.NoAppAvailable, AndroidPdfSharer(context).share(sharedPdf))
}
@Test
fun sharer_meldet_verweigerten_zugriff_und_sonstige_fehler() {
assertTrue(
AndroidPdfSharer(InterceptingContext(baseContext, SecurityException("x"))).share(sharedPdf)
is PdfShareResult.Failure.Unexpected
)
assertTrue(
AndroidPdfSharer(InterceptingContext(baseContext, RuntimeException("x"))).share(sharedPdf)
is PdfShareResult.Failure.Unexpected
)
}
@Test
fun sharer_gibt_nur_leserechte_frei() {
val context = InterceptingContext(baseContext, null)
assertEquals(PdfShareResult.Success, AndroidPdfSharer(context).share(sharedPdf))
@Suppress("DEPRECATION")
val inner = context.started.single().getParcelableExtra<Intent>(Intent.EXTRA_INTENT)!!
assertEquals(Intent.ACTION_SEND, inner.action)
assertEquals("application/pdf", inner.type)
assertEquals(0, inner.flags and Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
}
// --- PdfImageSharer ------------------------------------------------------------------
@Test
fun image_sharer_meldet_leere_liste() {
val context = InterceptingContext(baseContext, null)
assertEquals(PdfImageShareResult.Failure.NoImages, AndroidPdfImageSharer(context).share(emptyList()))
assertTrue(context.started.isEmpty())
}
@Test
fun image_sharer_meldet_unfreigegebene_datei_auch_wenn_nur_eine_von_mehreren_betroffen_ist() {
val context = InterceptingContext(baseContext, null)
val result = AndroidPdfImageSharer(context).share(listOf(sharedImage, unsharedFile))
assertTrue("erwartet UriResolutionFailed, war $result", result is PdfImageShareResult.Failure.UriResolutionFailed)
assertTrue(context.started.isEmpty())
}
@Test
fun image_sharer_meldet_fehlende_app_und_verweigerten_zugriff() {
assertEquals(
PdfImageShareResult.Failure.NoAppAvailable,
AndroidPdfImageSharer(InterceptingContext(baseContext, ActivityNotFoundException("x"))).share(listOf(sharedImage)),
)
assertTrue(
AndroidPdfImageSharer(InterceptingContext(baseContext, SecurityException("x"))).share(listOf(sharedImage))
is PdfImageShareResult.Failure.Unexpected
)
}
}
@@ -1,110 +0,0 @@
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)
}
}
@@ -1,240 +0,0 @@
package de.medapp.wunddoku.privacy
import android.content.ComponentName
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.view.WindowManager
import androidx.core.content.FileProvider
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import de.medapp.wunddoku.MainActivity
import de.medapp.wunddoku.R
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.xmlpull.v1.XmlPullParser
import java.io.File
import java.util.UUID
/**
* Datenschutz-Prüfung (QA-006) der ZUSAMMENGEFÜHRTEN, tatsächlich installierten App: Das fertige
* Manifest enthält auch Einträge aus Bibliotheken, die im eigenen Manifest nicht sichtbar sind.
*/
@RunWith(AndroidJUnit4::class)
class PrivacyConfigurationTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val packageName = context.packageName
private val id = "test-${UUID.randomUUID()}"
@Before
fun setUp() {
File(context.filesDir, "photos/$id").mkdirs()
}
@After
fun tearDown() {
listOf("photos", "signatures", "pdf", "pdf_images").forEach {
File(File(context.filesDir, it), id).deleteRecursively()
}
}
@Suppress("DEPRECATION")
private fun packageInfo(flags: Int) = context.packageManager.getPackageInfo(packageName, flags)
// --- Berechtigungen ------------------------------------------------------------------
@Test
fun app_fordert_keine_gefaehrlichen_oder_unnoetigen_berechtigungen_an() {
val requested = packageInfo(PackageManager.GET_PERMISSIONS).requestedPermissions?.toList().orEmpty()
val forbidden = listOf(
"android.permission.INTERNET",
"android.permission.ACCESS_NETWORK_STATE",
"android.permission.CAMERA",
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.WRITE_EXTERNAL_STORAGE",
"android.permission.MANAGE_EXTERNAL_STORAGE",
"android.permission.READ_MEDIA_IMAGES",
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.ACCESS_COARSE_LOCATION",
"android.permission.READ_CONTACTS",
"android.permission.READ_PHONE_STATE",
"android.permission.RECORD_AUDIO",
)
assertEquals("Verbotene Berechtigungen: ${requested.filter { it in forbidden }}", emptyList<String>(), requested.filter { it in forbidden })
}
@Test
fun app_hat_ausser_dem_androidx_interna_keine_weiteren_berechtigungen() {
val requested = packageInfo(PackageManager.GET_PERMISSIONS).requestedPermissions?.toList().orEmpty()
// Die Signatur-Berechtigung von androidx (Schutz dynamischer Receiver) ist unkritisch.
val allowed = setOf("$packageName.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION")
val unexpected = requested.filterNot { it in allowed }
assertTrue("Unerwartete Berechtigungen im zusammengeführten Manifest: $unexpected", unexpected.isEmpty())
}
// --- Backup --------------------------------------------------------------------------
@Test
fun backup_ist_deaktiviert() {
val flags = context.applicationInfo.flags
assertEquals("android:allowBackup muss false sein", 0, flags and ApplicationInfo.FLAG_ALLOW_BACKUP)
}
@Test
fun data_extraction_rules_schliessen_alles_aus() {
val parser = context.resources.getXml(R.xml.data_extraction_rules)
var section: String? = null
val excluded = mutableMapOf<String, MutableSet<String>>()
var includeFound = false
while (parser.next() != XmlPullParser.END_DOCUMENT) {
if (parser.eventType == XmlPullParser.START_TAG) {
when (parser.name) {
"cloud-backup", "device-transfer" -> {
section = parser.name
excluded[parser.name] = mutableSetOf()
}
"exclude" -> {
val domain = parser.getAttributeValue(null, "domain")
val current = section
if (domain != null && current != null) excluded[current]?.add(domain)
}
"include" -> includeFound = true
}
} else if (parser.eventType == XmlPullParser.END_TAG && parser.name == section) {
section = null
}
}
assertFalse("Kein <include> erlaubt", includeFound)
assertEquals(setOf("cloud-backup", "device-transfer"), excluded.keys)
excluded.forEach { (name, domains) ->
assertTrue("$name muss database, file und sharedpref ausschließen, war $domains", domains.containsAll(setOf("database", "file", "sharedpref")))
}
}
// --- Exportierte Komponenten ---------------------------------------------------------
@Test
fun ausser_der_start_activity_sind_keine_eigenen_komponenten_exportiert() {
val info = packageInfo(
PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or
PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS
)
// Debug-Builds enthalten zusätzlich Test-/Tooling-Activities von androidx (nicht im Release).
val toolingPrefixes = listOf("androidx.compose.ui.tooling", "androidx.activity.ComponentActivity", "androidx.compose.ui.test")
val exportedActivities = info.activities.orEmpty().filter { it.exported }.map { it.name }
.filterNot { name -> toolingPrefixes.any { name.startsWith(it) } }
assertEquals(listOf(MainActivity::class.java.name), exportedActivities)
val exportedServices = info.services.orEmpty().filter { it.exported }.map { it.name }
assertTrue("Exportierte Services: $exportedServices", exportedServices.isEmpty())
val exportedProviders = info.providers.orEmpty().filter { it.exported }.map { it.name }
assertTrue("Exportierte ContentProvider: $exportedProviders", exportedProviders.isEmpty())
// androidx.profileinstaller ist per Systemberechtigung (DUMP) geschützt und unkritisch.
val exportedReceivers = info.receivers.orEmpty().filter { it.exported }.map { it.name }
.filterNot { it == "androidx.profileinstaller.ProfileInstallReceiver" }
assertTrue("Exportierte Receiver: $exportedReceivers", exportedReceivers.isEmpty())
}
@Test
fun fileprovider_ist_nicht_exportiert_und_vergibt_nur_gezielte_rechte() {
val provider = context.packageManager.getProviderInfo(
ComponentName(packageName, FileProvider::class.java.name),
PackageManager.GET_META_DATA,
)
assertFalse(provider.exported)
assertTrue(provider.grantUriPermissions)
assertEquals("$packageName.fileprovider", provider.authority)
}
// --- FileProvider-Freigaben (minimale Freigabe) --------------------------------------
private fun uriFor(file: File) = FileProvider.getUriForFile(context, "$packageName.fileprovider", file)
@Test
fun pdf_und_bildexporte_sind_freigegeben() {
val pdf = File(context.filesDir, "pdf/$id/x.pdf").apply { parentFile!!.mkdirs(); writeText("x") }
val image = File(context.filesDir, "pdf_images/$id/s.jpg").apply { parentFile!!.mkdirs(); writeText("x") }
assertTrue(uriFor(pdf).toString().startsWith("content://$packageName.fileprovider/"))
assertTrue(uriFor(image).toString().startsWith("content://$packageName.fileprovider/"))
}
@Test
fun originalfotos_und_signaturen_sind_nicht_ueber_den_fileprovider_freigabefaehig() {
val photo = File(context.filesDir, "photos/$id/foto.jpg").apply { parentFile!!.mkdirs(); writeText("x") }
val signature = File(context.filesDir, "signatures/$id/sig.png").apply { parentFile!!.mkdirs(); writeText("x") }
for (file in listOf(photo, signature)) {
try {
uriFor(file)
fail("${file.parentFile!!.parentFile!!.name}/ darf nicht freigebbar sein")
} catch (expected: IllegalArgumentException) {
// erwartet: nicht in file_paths.xml konfiguriert
}
}
}
@Test
fun datenbank_und_sonstige_interne_dateien_sind_nicht_freigabefaehig() {
val database = context.getDatabasePath("irgendeine.db")
val other = File(context.filesDir, "geheim_$id.txt").apply { writeText("x") }
try {
uriFor(other)
fail("Dateien im Wurzelordner von filesDir dürfen nicht freigebbar sein")
} catch (expected: IllegalArgumentException) {
} finally {
other.delete()
}
try {
uriFor(database)
fail("Die Datenbank darf nie freigebbar sein")
} catch (expected: IllegalArgumentException) {
}
}
// --- Speicherort ---------------------------------------------------------------------
@Test
fun dokumentationsdaten_liegen_im_privaten_internen_speicher_und_sind_nicht_fuer_andere_lesbar() {
val file = File(context.filesDir, "photos/$id/foto.jpg").apply { writeText("x") }
assertTrue(file.canonicalPath.startsWith(context.filesDir.canonicalPath))
val external = context.getExternalFilesDir(null)?.canonicalPath
if (external != null) assertFalse(file.canonicalPath.startsWith(external))
val permissions = java.nio.file.Files.getPosixFilePermissions(file.toPath())
assertFalse("Datei darf nicht 'others'-lesbar sein: $permissions", permissions.contains(java.nio.file.attribute.PosixFilePermission.OTHERS_READ))
assertFalse("Datei darf nicht 'others'-schreibbar sein: $permissions", permissions.contains(java.nio.file.attribute.PosixFilePermission.OTHERS_WRITE))
}
// --- Bildschirmschutz ----------------------------------------------------------------
@Test
fun hauptfenster_verwendet_flag_secure_gegen_screenshots_und_app_switcher_vorschau() {
var flags = 0
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
scenario.onActivity { activity -> flags = activity.window.attributes.flags }
}
assertNotEquals("FLAG_SECURE muss gesetzt sein", 0, flags and WindowManager.LayoutParams.FLAG_SECURE)
}
}
@@ -1,114 +0,0 @@
package de.medapp.wunddoku.signature
import android.graphics.Bitmap
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import de.medapp.wunddoku.testutil.blankBitmap
import de.medapp.wunddoku.testutil.expectThrowsSuspend
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.UUID
/**
* Fehlerbehandlung von [InternalSignatureStorage] mit echten Dateien (eigenes Test-
* Wurzelverzeichnis, berührt keine echten Signaturen).
*/
@RunWith(AndroidJUnit4::class)
class SignatureStorageErrorHandlingTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val rootDirName = "test_signatures_${UUID.randomUUID()}"
private lateinit var storage: InternalSignatureStorage
private val documentationId = "doc-1"
@Before
fun setUp() {
storage = InternalSignatureStorage(context, rootDirName)
}
@After
fun tearDown() {
File(context.filesDir, rootDirName).deleteRecursively()
}
private fun filesInDocDir(): List<File> =
File(File(context.filesDir, rootDirName), documentationId).listFiles()?.toList().orEmpty()
@Test
fun speichern_einer_recycelten_bitmap_wirft_und_hinterlaesst_keine_datei() = runDbTest {
val recycled = signatureBitmap().also { it.recycle() }
expectThrowsSuspend<Exception> { storage.save(documentationId, recycled) }
assertTrue("keine leere PNG-Datei als scheinbar gültige Signatur", filesInDocDir().isEmpty())
}
@Test
fun nach_fehlgeschlagenem_speichern_funktioniert_das_naechste_speichern() = runDbTest {
val recycled = signatureBitmap().also { it.recycle() }
expectThrowsSuspend<Exception> { storage.save(documentationId, recycled) }
val fileName = storage.save(documentationId, signatureBitmap())
assertTrue(storage.resolve(documentationId, fileName).exists())
assertEquals(1, filesInDocDir().size)
}
@Test
fun gespeicherte_signatur_laesst_sich_wieder_dekodieren() = runDbTest {
val fileName = storage.save(documentationId, signatureBitmap())
val decoded = storage.decode(documentationId, fileName)
assertNotNull(decoded)
assertEquals(40, decoded!!.width)
decoded.recycle()
}
@Test
fun decode_einer_fehlenden_datei_liefert_null() = runDbTest {
assertNull(storage.decode(documentationId, "fehlt.png"))
}
@Test
fun decode_einer_beschaedigten_datei_liefert_null_statt_abzustuerzen() = runDbTest {
val target = storage.resolve(documentationId, "defekt.png")
target.parentFile!!.mkdirs()
target.writeBytes(ByteArray(256) { 3 })
assertNull(storage.decode(documentationId, "defekt.png"))
}
@Test
fun decode_einer_leeren_datei_liefert_null() = runDbTest {
val target = storage.resolve(documentationId, "leer.png")
target.parentFile!!.mkdirs()
target.createNewFile()
assertNull(storage.decode(documentationId, "leer.png"))
}
@Test
fun loeschen_nicht_vorhandener_dateien_wirft_nicht() = runDbTest {
storage.delete(documentationId, "gibt_es_nicht.png")
storage.deleteAll(documentationId)
assertFalse(File(File(context.filesDir, rootDirName), documentationId).exists())
}
@Test
fun leere_zeichnung_wird_von_der_validierung_erkannt() {
val blank: Bitmap = blankBitmap()
assertTrue(SignatureBitmapValidation.isBlank(blank))
assertFalse(SignatureBitmapValidation.isBlank(signatureBitmap()))
}
}
@@ -0,0 +1,154 @@
package de.medapp.wunddoku.testutil
import de.medapp.wunddoku.data.entity.BlockStateEntity
import de.medapp.wunddoku.data.entity.CheckItemStateEntity
import de.medapp.wunddoku.data.entity.DocumentationEntity
import de.medapp.wunddoku.data.entity.PatientEntity
import de.medapp.wunddoku.data.entity.PhotoEntity
import de.medapp.wunddoku.data.entity.SignatureEntity
import de.medapp.wunddoku.data.entity.WoundCarerEntity
import de.medapp.wunddoku.domain.model.DocumentationStatus
/** Builder für Room-Entities mit sinnvollen Standardwerten - Tests überschreiben nur, was sie brauchen. */
fun patientEntity(
id: String = "p-1",
firstName: String = "Max",
lastName: String = "Mustermann",
birthDate: String = "1980-03-14",
street: String = "Musterstraße 1",
postalCode: String = "12345",
city: String = "Musterstadt",
phone1: String = "0123 456789",
phone2: String? = null,
phone3: String? = null,
createdAt: Long = 1_000L,
updatedAt: Long = createdAt,
) = PatientEntity(
id = id,
firstName = firstName,
lastName = lastName,
birthDate = birthDate,
street = street,
postalCode = postalCode,
city = city,
phone1 = phone1,
phone2 = phone2,
phone3 = phone3,
createdAt = createdAt,
updatedAt = updatedAt,
)
fun documentationEntity(
id: String = "d-1",
patientId: String = "p-1",
status: DocumentationStatus = DocumentationStatus.IN_PROGRESS,
createdAt: Long = 1_000L,
updatedAt: Long = createdAt,
continuedFromDocumentationId: String? = null,
pdfLastGeneratedAt: Long? = null,
catalogVersion: Int = 4,
firstNameSnapshot: String = "Max",
lastNameSnapshot: String = "Mustermann",
birthDateSnapshot: String = "1980-03-14",
streetSnapshot: String = "Musterstraße 1",
postalCodeSnapshot: String = "12345",
citySnapshot: String = "Musterstadt",
) = DocumentationEntity(
id = id,
patientId = patientId,
status = status,
createdAt = createdAt,
updatedAt = updatedAt,
continuedFromDocumentationId = continuedFromDocumentationId,
pdfLastGeneratedAt = pdfLastGeneratedAt,
catalogVersion = catalogVersion,
patientFirstNameSnapshot = firstNameSnapshot,
patientLastNameSnapshot = lastNameSnapshot,
patientBirthDateSnapshot = birthDateSnapshot,
patientStreetSnapshot = streetSnapshot,
patientPostalCodeSnapshot = postalCodeSnapshot,
patientCitySnapshot = citySnapshot,
)
fun blockStateEntity(
id: String = "b-1",
documentationId: String = "d-1",
blockDefinitionId: String = "wundart",
title: String = "Wundart",
order: Int = 1,
updatedAt: Long = 1_000L,
) = BlockStateEntity(
id = id,
documentationId = documentationId,
blockDefinitionId = blockDefinitionId,
titleSnapshot = title,
blockOrder = order,
updatedAt = updatedAt,
)
fun checkItemStateEntity(
id: String = "c-1",
blockStateId: String = "b-1",
definitionId: String = "item_1",
label: String = "Eintrag 1",
order: Int = 1,
checked: Boolean = false,
isFreeText: Boolean = false,
freeTextValue: String = "",
isStaticHint: Boolean = false,
inputSpec: String = "",
) = CheckItemStateEntity(
id = id,
blockStateId = blockStateId,
checkItemDefinitionId = definitionId,
labelSnapshot = label,
itemOrder = order,
checked = checked,
isFreeText = isFreeText,
freeTextValue = freeTextValue,
isStaticHint = isStaticHint,
inputSpec = inputSpec,
)
fun photoEntity(
id: String = "ph-1",
documentationId: String = "d-1",
fileName: String = "foto.jpg",
orderIndex: Int = 0,
createdAt: Long = 1_000L,
) = PhotoEntity(
id = id,
documentationId = documentationId,
fileName = fileName,
orderIndex = orderIndex,
createdAt = createdAt,
)
fun signatureEntity(
id: String = "s-1",
documentationId: String = "d-1",
signerName: String = "Anna Schmidt",
fileName: String = "unterschrift.png",
orderIndex: Int = 0,
createdAt: Long = 1_000L,
) = SignatureEntity(
id = id,
documentationId = documentationId,
signerName = signerName,
fileName = fileName,
orderIndex = orderIndex,
createdAt = createdAt,
)
fun woundCarerEntity(
id: String = "w-1",
name: String = "Anna Schmidt",
signatureFileName: String = "w-sig.png",
updatedAt: Long = 1_000L,
) = WoundCarerEntity(
id = id,
name = name,
signatureFileName = signatureFileName,
updatedAt = updatedAt,
)
@@ -1,198 +0,0 @@
package de.medapp.wunddoku.testutil
import android.graphics.Bitmap
import android.net.Uri
import de.medapp.wunddoku.data.dao.CheckItemStateDao
import de.medapp.wunddoku.data.dao.DocumentationDao
import de.medapp.wunddoku.data.dao.PatientDao
import de.medapp.wunddoku.data.entity.PatientEntity
import de.medapp.wunddoku.data.entity.relations.DocumentationWithDetails
import de.medapp.wunddoku.data.dao.PhotoDao
import de.medapp.wunddoku.data.dao.SignatureDao
import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.data.repository.WoundCarerRepository
import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.pdf.DocumentationPdfExporter
import de.medapp.wunddoku.pdf.PdfToImagesConverter
import de.medapp.wunddoku.signature.SignatureStorage
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.delay
import java.io.File
/**
* Hilfsklassen für die Fehlerbehandlungs-Tests. Die bestehenden Fakes (Fakes.kt) bleiben
* unverändert; hier werden sie per Delegation um gezielt auslösbare Fehler ergänzt.
*/
/** [PhotoStorage] mit konfigurierbaren Fehlern pro Aufruf (1-basierte Aufrufnummer). */
class FailingPhotoStorage(
val delegate: FakePhotoStorage = FakePhotoStorage(),
) : PhotoStorage by delegate {
/** Liefert für die n-te [importFrom]-Anweisung einen Fehler (oder `null` = Erfolg). */
var importFailure: (Int) -> Throwable? = { null }
private var importCalls = 0
override suspend fun importFrom(documentationId: String, sourceUri: Uri): String {
importCalls++
importFailure(importCalls)?.let { throw it }
return delegate.importFrom(documentationId, sourceUri)
}
}
/** [SignatureStorage] mit gezielt auslösbarem Fehler beim Speichern. */
class FailingSignatureStorage(
val delegate: FakeSignatureStorage = FakeSignatureStorage(),
) : SignatureStorage by delegate {
var saveFailure: Throwable? = null
override suspend fun save(documentationId: String, bitmap: Bitmap): String {
saveFailure?.let { throw it }
return delegate.save(documentationId, bitmap)
}
}
/** Exporter, der eine beliebige Exception/Error wirft (statt ein Failure-Ergebnis zu liefern). */
class ThrowingPdfExporter(private val error: Throwable) : DocumentationPdfExporter {
override suspend fun export(model: de.medapp.wunddoku.pdf.PdfExportModel): de.medapp.wunddoku.pdf.PdfExportResult =
throw error
}
/** Converter, der eine beliebige Exception/Error wirft. */
class ThrowingPdfToImagesConverter(private val error: Throwable) : PdfToImagesConverter {
override suspend fun convert(documentationId: String, pdfFile: File): de.medapp.wunddoku.pdf.PdfToImagesResult =
throw error
}
/** [CheckItemStateDao] mit abschaltbaren Schreibfehlern (z. B. "database or disk is full"). */
class FailingCheckItemStateDao(
private val delegate: CheckItemStateDao,
) : CheckItemStateDao by delegate {
@Volatile var failure: Throwable? = null
override suspend fun setChecked(id: String, checked: Boolean) {
failure?.let { throw it }
delegate.setChecked(id, checked)
}
override suspend fun updateFreeTextValue(id: String, value: String) {
failure?.let { throw it }
delegate.updateFreeTextValue(id, value)
}
}
/** [DocumentationDao] mit abschaltbarem Fehler beim Laden einer kompletten Dokumentation. */
class FailingDocumentationDao(
private val delegate: DocumentationDao,
) : DocumentationDao by delegate {
@Volatile var loadFailure: Throwable? = null
override suspend fun getFullDocumentation(id: String): DocumentationWithDetails? {
loadFailure?.let { throw it }
return delegate.getFullDocumentation(id)
}
}
/** [PatientDao] mit abschaltbarem Fehler beim Einfügen. */
class FailingPatientDao(
private val delegate: PatientDao,
) : PatientDao by delegate {
@Volatile var insertFailure: Throwable? = null
override suspend fun insert(patient: PatientEntity) {
insertFailure?.let { throw it }
delegate.insert(patient)
}
}
/** [PhotoDao] mit abschaltbarem Fehler beim Einfügen. */
class FailingPhotoDao(
private val delegate: PhotoDao,
) : PhotoDao by delegate {
@Volatile var insertFailure: Throwable? = null
override suspend fun insert(photo: de.medapp.wunddoku.data.entity.PhotoEntity) {
insertFailure?.let { throw it }
delegate.insert(photo)
}
}
/** [SignatureDao] mit abschaltbarem Fehler beim Einfügen. */
class FailingSignatureDao(
private val delegate: SignatureDao,
) : SignatureDao by delegate {
@Volatile var insertFailure: Throwable? = null
override suspend fun insert(signature: de.medapp.wunddoku.data.entity.SignatureEntity) {
insertFailure?.let { throw it }
delegate.insert(signature)
}
}
/** Baut ein [DocumentationRepository] auf der echten In-Memory-DB mit austauschbaren Bausteinen. */
fun TestEnvironment.buildRepository(
photoStorage: PhotoStorage = this.photoStorage,
signatureStorage: SignatureStorage = this.signatureStorage,
woundCarerRepository: WoundCarerRepository = this.woundCarerRepository,
pdfExporter: DocumentationPdfExporter = this.pdfExporter,
pdfToImagesConverter: PdfToImagesConverter = this.pdfToImagesConverter,
checkItemStateDao: CheckItemStateDao = db.checkItemStateDao(),
photoDao: PhotoDao = db.photoDao(),
signatureDao: SignatureDao = db.signatureDao(),
documentationDao: DocumentationDao = db.documentationDao(),
exportedFilesCleaner: de.medapp.wunddoku.pdf.ExportedFilesCleaner = de.medapp.wunddoku.pdf.ExportedFilesCleaner.None,
): DocumentationRepository = DocumentationRepository(
documentationDao = documentationDao,
blockStateDao = db.blockStateDao(),
checkItemStateDao = checkItemStateDao,
photoDao = photoDao,
signatureDao = signatureDao,
patientDao = db.patientDao(),
photoStorage = photoStorage,
signatureStorage = signatureStorage,
woundCarerRepository = woundCarerRepository,
pdfExporter = pdfExporter,
pdfToImagesConverter = pdfToImagesConverter,
db = db,
exportedFilesCleaner = exportedFilesCleaner,
)
/** Erwartet, dass der Suspend-Block eine Exception vom Typ [T] wirft, und liefert sie zurück. */
suspend inline fun <reified T : Throwable> expectThrowsSuspend(block: () -> Unit): T {
try {
block()
} catch (e: Throwable) {
if (e is T) return e
throw e
}
throw AssertionError("Erwartet: ${T::class.java.simpleName}, es wurde aber keine Exception geworfen")
}
/** Wartet (max. [timeoutMs]) bis [condition] wahr ist - für asynchrone ViewModel-Zustände. */
suspend fun awaitCondition(timeoutMs: Long = 5_000, condition: () -> Boolean) {
withTimeout(timeoutMs) {
while (!condition()) delay(20)
}
}
/** Wie [awaitCondition], aber mit Suspend-Bedingung (z. B. Abfrage der Datenbank). */
suspend fun awaitUntil(timeoutMs: Long = 5_000, condition: suspend () -> Boolean) {
withTimeout(timeoutMs) {
while (!condition()) delay(20)
}
}
/** Führt [block] auf dem Main-Thread aus (ViewModels greifen intern auf Main-State zu). */
fun <T> onMain(block: () -> T): T {
var result: Result<T>? = null
androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().runOnMainSync {
result = runCatching(block)
}
return result!!.getOrThrow()
}
@@ -0,0 +1,107 @@
package de.medapp.wunddoku.testutil
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import de.medapp.wunddoku.data.dao.BlockStateDao
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.dao.PhotoDao
import de.medapp.wunddoku.data.dao.SignatureDao
import de.medapp.wunddoku.data.dao.WoundCarerDao
import de.medapp.wunddoku.data.database.AppDatabase
import de.medapp.wunddoku.data.entity.DocumentationEntity
import de.medapp.wunddoku.data.entity.PatientEntity
import de.medapp.wunddoku.domain.model.DocumentationStatus
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Before
import org.junit.runner.RunWith
/**
* Basisklasse für alle Room-Tests (QA-002): echte In-Memory-Datenbank mit demselben Schema,
* denselben Foreign Keys und denselben Indizes wie in Produktion. Jeder Test bekommt eine
* frische, leere Datenbank.
*/
@RunWith(AndroidJUnit4::class)
abstract class RoomTestBase {
protected lateinit var db: AppDatabase
protected val patientDao: PatientDao get() = db.patientDao()
protected val documentationDao: DocumentationDao get() = db.documentationDao()
protected val blockStateDao: BlockStateDao get() = db.blockStateDao()
protected val checkItemStateDao: CheckItemStateDao get() = db.checkItemStateDao()
protected val photoDao: PhotoDao get() = db.photoDao()
protected val signatureDao: SignatureDao get() = db.signatureDao()
protected val woundCarerDao: WoundCarerDao get() = db.woundCarerDao()
@Before
fun createDatabase() {
db = Room.inMemoryDatabaseBuilder(
InstrumentationRegistry.getInstrumentation().targetContext,
AppDatabase::class.java,
).build()
}
@After
fun closeDatabase() {
db.close()
}
// --- Seed-Helfer ---------------------------------------------------------------------
protected suspend fun seedPatient(
id: String = "p-1",
firstName: String = "Max",
lastName: String = "Mustermann",
birthDate: String = "1980-03-14",
): PatientEntity {
val entity = patientEntity(id = id, firstName = firstName, lastName = lastName, birthDate = birthDate)
patientDao.insert(entity)
return entity
}
/** Legt eine Dokumentation an; fehlt der Patient, wird er mit Standarddaten angelegt. */
protected suspend fun seedDocumentation(
id: String = "d-1",
patientId: String = "p-1",
createdAt: Long = 1_000L,
status: DocumentationStatus = DocumentationStatus.IN_PROGRESS,
): DocumentationEntity {
if (patientDao.getById(patientId) == null) seedPatient(id = patientId)
val entity = documentationEntity(id = id, patientId = patientId, createdAt = createdAt, status = status)
documentationDao.insert(entity)
return entity
}
// --- Roh-SQL-Helfer (unabhängig von DAO-Logik prüfen, was wirklich in der DB steht) ----
protected fun queryInt(sql: String): Int =
db.openHelper.readableDatabase.query(sql).use { cursor ->
cursor.moveToFirst()
cursor.getInt(0)
}
protected fun queryString(sql: String): String =
db.openHelper.readableDatabase.query(sql).use { cursor ->
cursor.moveToFirst()
cursor.getString(0)
}
protected fun countRows(table: String): Int = queryInt("SELECT COUNT(*) FROM $table")
protected fun execSql(sql: String) {
db.openHelper.writableDatabase.execSQL(sql)
}
}
/**
* Wartet (mit Timeout), bis ein Flow einen Wert liefert, der [predicate] erfüllt.
* Robust für Reaktivitäts-Tests, egal ob Änderung vor oder nach dem ersten Emit passiert.
*/
suspend fun <T> Flow<T>.awaitMatching(timeoutMs: Long = 5_000L, predicate: (T) -> Boolean): T =
withTimeout(timeoutMs) { first { predicate(it) } }
@@ -1,473 +0,0 @@
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)
}
}
@@ -1,151 +0,0 @@
package de.medapp.wunddoku.ui.patients.form
import android.database.sqlite.SQLiteFullException
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.data.repository.PatientRepository
import de.medapp.wunddoku.testutil.FailingDocumentationDao
import de.medapp.wunddoku.testutil.FailingPatientDao
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.awaitCondition
import de.medapp.wunddoku.testutil.buildRepository
import de.medapp.wunddoku.testutil.onMain
import de.medapp.wunddoku.testutil.runDbTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/**
* Fehlerbehandlung beim Speichern im [PatientFormViewModel]: voller Speicher/Datenbankfehler
* dürfen weder abstürzen noch zu einer scheinbar erfolgreichen Navigation führen, und die
* Eingaben des Nutzers bleiben im Formular erhalten.
*/
@RunWith(AndroidJUnit4::class)
class PatientFormViewModelErrorHandlingTest {
private lateinit var env: TestEnvironment
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
private fun fill(viewModel: PatientFormViewModel) = onMain {
viewModel.onFirstNameChanged("Max")
viewModel.onLastNameChanged("Mustermann")
viewModel.onBirthDateDigitsChanged("14031980")
viewModel.onStreetChanged("Musterstraße 1")
viewModel.onPostalCodeChanged("12345")
viewModel.onCityChanged("Musterstadt")
viewModel.onPhone1Changed("0123456789")
}
private suspend fun createViewModel(
patientRepository: PatientRepository,
startsNewDocumentation: Boolean,
documentationDao: FailingDocumentationDao? = null,
): PatientFormViewModel {
val documentationRepository = if (documentationDao != null) {
env.buildRepository(documentationDao = documentationDao)
} else {
env.documentationRepository
}
val viewModel = onMain {
PatientFormViewModel(
patientId = null,
startsNewDocumentation = startsNewDocumentation,
repository = patientRepository,
documentationRepository = documentationRepository,
)
}
awaitCondition { !viewModel.uiState.value.isLoading }
return viewModel
}
@Test
fun speicherfehler_beim_anlegen_des_patienten_zeigt_meldung_und_behaelt_die_eingaben() = runDbTest {
val failingDao = FailingPatientDao(env.db.patientDao())
.apply { insertFailure = SQLiteFullException("database or disk is full") }
val viewModel = createViewModel(PatientRepository(failingDao), startsNewDocumentation = true)
fill(viewModel)
onMain { viewModel.save() }
awaitCondition { viewModel.uiState.value.validationError != null }
val state = viewModel.uiState.value
assertTrue(state.validationError!!.contains("Speicherplatz"))
assertFalse(state.patientSaved)
assertNull(state.createdDocumentationId)
assertEquals("Max", state.firstName)
assertEquals("Mustermann", state.lastName)
}
@Test
fun sonstiger_datenbankfehler_beim_speichern_stuerzt_nicht_ab() = runDbTest {
val failingDao = FailingPatientDao(env.db.patientDao())
.apply { insertFailure = IllegalStateException("Test") }
val viewModel = createViewModel(PatientRepository(failingDao), startsNewDocumentation = false)
fill(viewModel)
onMain { viewModel.save() }
awaitCondition { viewModel.uiState.value.validationError != null }
assertFalse(viewModel.uiState.value.patientSaved)
}
@Test
fun fehler_beim_anlegen_der_dokumentation_meldet_fehler_ohne_navigation() = runDbTest {
val failingDocDao = FailingDocumentationDao(env.db.documentationDao())
.apply { loadFailure = SQLiteFullException("database or disk is full") }
val viewModel = createViewModel(
env.patientRepository,
startsNewDocumentation = true,
documentationDao = failingDocDao,
)
fill(viewModel)
onMain { viewModel.save() }
awaitCondition { viewModel.uiState.value.validationError != null }
assertNull("keine Navigation zu einer nicht angelegten Dokumentation", viewModel.uiState.value.createdDocumentationId)
assertNotNull(viewModel.uiState.value.validationError)
// Die Dokumentation wurde per Transaktion zurückgerollt: es gibt keine halbe Dokumentation.
assertEquals(0, env.db.documentationDao().countForPatient(env.db.patientDao().findExisting("Max", "Mustermann", "1980-03-14")!!.id))
}
@Test
fun speichern_funktioniert_nach_einem_fehler_erneut() = runDbTest {
val failingDao = FailingPatientDao(env.db.patientDao())
.apply { insertFailure = IllegalStateException("Test") }
val viewModel = createViewModel(PatientRepository(failingDao), startsNewDocumentation = false)
fill(viewModel)
onMain { viewModel.save() }
awaitCondition { viewModel.uiState.value.validationError != null }
failingDao.insertFailure = null
onMain { viewModel.save() }
awaitCondition { viewModel.uiState.value.patientSaved }
assertNull(viewModel.uiState.value.validationError)
}
@Test
fun ungueltige_eingaben_werden_weiterhin_fachlich_abgelehnt() = runDbTest {
val viewModel = createViewModel(env.patientRepository, startsNewDocumentation = false)
onMain { viewModel.save() }
assertNotNull(viewModel.uiState.value.validationError)
assertFalse(viewModel.uiState.value.patientSaved)
}
}
+1 -8
View File
@@ -1,17 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android">
xmlns:tools="http://schemas.android.com/tools">
<!-- Keine Speicher-/Kamera-Berechtigungen nötig: Der Photo Picker (Abschnitt 11 der <!-- Keine Speicher-/Kamera-Berechtigungen nötig: Der Photo Picker (Abschnitt 11 der
Architektur) benötigt keine READ_MEDIA_IMAGES-Berechtigung, und Fotos/Signaturen/ Architektur) benötigt keine READ_MEDIA_IMAGES-Berechtigung, und Fotos/Signaturen/
PDFs werden ausschließlich im privaten internen App-Speicher abgelegt. --> PDFs werden ausschließlich im privaten internen App-Speicher abgelegt. -->
<!-- Die App arbeitet vollständig offline. Falls eine Bibliothek (jetzt oder nach einem
Update) die INTERNET-Berechtigung mitbringt, wird sie beim Zusammenführen der Manifeste
entfernt - Patientendaten können das Gerät so nicht über das Netzwerk verlassen. -->
<uses-permission android:name="android.permission.INTERNET" tools:node="remove" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
<application <application
android:name=".WunddokuApplication" android:name=".WunddokuApplication"
android:allowBackup="false" android:allowBackup="false"
@@ -2,25 +2,14 @@ package de.medapp.wunddoku
import android.app.Application import android.app.Application
import de.medapp.wunddoku.di.AppContainer import de.medapp.wunddoku.di.AppContainer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class WunddokuApplication : Application() { class WunddokuApplication : Application() {
lateinit var container: AppContainer lateinit var container: AppContainer
private set private set
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
container = AppContainer(this) container = AppContainer(this)
// Datenschutz: veraltete PDF-/Bildexporte und liegengebliebene Kameraaufnahmen entfernen
// (Aufbewahrungsfristen siehe InternalExportedFilesCleaner). Läuft im Hintergrund,
// wirft nie und verzögert den Start nicht.
applicationScope.launch { container.exportedFilesCleaner.deleteStaleFiles() }
} }
} }
@@ -18,8 +18,6 @@ import de.medapp.wunddoku.data.entity.SignatureEntity
import de.medapp.wunddoku.data.mapper.toDomain import de.medapp.wunddoku.data.mapper.toDomain
import de.medapp.wunddoku.data.mapper.toSummary import de.medapp.wunddoku.data.mapper.toSummary
import de.medapp.wunddoku.domain.catalog.DocumentationCatalog import de.medapp.wunddoku.domain.catalog.DocumentationCatalog
import de.medapp.wunddoku.domain.error.FailureCategory
import de.medapp.wunddoku.domain.error.runCatchingCancellable
import de.medapp.wunddoku.domain.model.Documentation import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.DocumentationStatus import de.medapp.wunddoku.domain.model.DocumentationStatus
import de.medapp.wunddoku.domain.model.DocumentationSummary import de.medapp.wunddoku.domain.model.DocumentationSummary
@@ -29,7 +27,6 @@ import de.medapp.wunddoku.domain.model.Signature
import de.medapp.wunddoku.media.ImageDownsampler import de.medapp.wunddoku.media.ImageDownsampler
import de.medapp.wunddoku.media.PhotoStorage import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.pdf.DocumentationPdfExporter import de.medapp.wunddoku.pdf.DocumentationPdfExporter
import de.medapp.wunddoku.pdf.ExportedFilesCleaner
import de.medapp.wunddoku.pdf.DocumentationToPdfExportModelMapper import de.medapp.wunddoku.pdf.DocumentationToPdfExportModelMapper
import de.medapp.wunddoku.pdf.PdfExportError import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult import de.medapp.wunddoku.pdf.PdfExportResult
@@ -37,7 +34,6 @@ import de.medapp.wunddoku.pdf.PdfToImagesConverter
import de.medapp.wunddoku.pdf.PdfToImagesResult import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.signature.SignatureBitmapValidation import de.medapp.wunddoku.signature.SignatureBitmapValidation
import de.medapp.wunddoku.signature.SignatureStorage import de.medapp.wunddoku.signature.SignatureStorage
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import java.io.File import java.io.File
@@ -70,7 +66,6 @@ class DocumentationRepository(
private val pdfExporter: DocumentationPdfExporter, private val pdfExporter: DocumentationPdfExporter,
private val pdfToImagesConverter: PdfToImagesConverter, private val pdfToImagesConverter: PdfToImagesConverter,
private val db: AppDatabase, private val db: AppDatabase,
private val exportedFilesCleaner: ExportedFilesCleaner = ExportedFilesCleaner.None,
) { ) {
fun observeAll(): Flow<List<DocumentationSummary>> = fun observeAll(): Flow<List<DocumentationSummary>> =
@@ -206,29 +201,21 @@ class DocumentationRepository(
// Unterschriften werden bewusst NICHT kopiert (siehe Doc-Kommentar oben). // Unterschriften werden bewusst NICHT kopiert (siehe Doc-Kommentar oben).
if (copyPhotos) { if (copyPhotos) {
try { for (sourcePhoto in source.photos) {
for (sourcePhoto in source.photos) { val copiedFileName = photoStorage.duplicate(
val copiedFileName = photoStorage.duplicate( sourceDocumentationId = source.documentation.id,
sourceDocumentationId = source.documentation.id, sourceFileName = sourcePhoto.fileName,
sourceFileName = sourcePhoto.fileName, targetDocumentationId = newDocumentationId,
targetDocumentationId = newDocumentationId, )
photoDao.insert(
PhotoEntity(
id = UUID.randomUUID().toString(),
documentationId = newDocumentationId,
fileName = copiedFileName,
orderIndex = sourcePhoto.orderIndex,
createdAt = now,
) )
photoDao.insert( )
PhotoEntity(
id = UUID.randomUUID().toString(),
documentationId = newDocumentationId,
fileName = copiedFileName,
orderIndex = sourcePhoto.orderIndex,
createdAt = now,
)
)
}
} catch (e: Throwable) {
// Die Room-Transaktion rollt die DB-Zeilen zurück - bereits kopierte Fotodateien
// der nie entstandenen Dokumentation würden aber verwaist im Speicher bleiben
// (bei Speichermangel besonders ärgerlich). Aufräumen, dann Fehler weiterreichen.
runCatching { photoStorage.deleteAll(newDocumentationId) }
throw e
} }
} }
@@ -263,22 +250,16 @@ class DocumentationRepository(
suspend fun addPhoto(documentationId: String, sourceUri: Uri): Photo { suspend fun addPhoto(documentationId: String, sourceUri: Uri): Photo {
val fileName = photoStorage.importFrom(documentationId, sourceUri) val fileName = photoStorage.importFrom(documentationId, sourceUri)
val nextOrder = photoDao.getMaxOrderIndex(documentationId) + 1
val now = Instant.now() val now = Instant.now()
val entity = try { val entity = PhotoEntity(
val nextOrder = photoDao.getMaxOrderIndex(documentationId) + 1 id = UUID.randomUUID().toString(),
PhotoEntity( documentationId = documentationId,
id = UUID.randomUUID().toString(), fileName = fileName,
documentationId = documentationId, orderIndex = nextOrder,
fileName = fileName, createdAt = now.toEpochMilli(),
orderIndex = nextOrder, )
createdAt = now.toEpochMilli(), photoDao.insert(entity)
).also { photoDao.insert(it) }
} catch (e: Throwable) {
// DB-Eintrag fehlgeschlagen (z. B. Datenbank voll): die gerade importierte Datei
// wäre sonst eine verwaiste Datei ohne Referenz.
runCatching { photoStorage.delete(documentationId, fileName) }
throw e
}
return Photo(entity.id, entity.documentationId, entity.fileName, entity.orderIndex, now) return Photo(entity.id, entity.documentationId, entity.fileName, entity.orderIndex, now)
} }
@@ -303,22 +284,10 @@ class DocumentationRepository(
* verwendet) und delegiert die eigentliche Erzeugung an [pdfExporter]. * verwendet) und delegiert die eigentliche Erzeugung an [pdfExporter].
*/ */
suspend fun exportPdf(documentationId: String): PdfExportResult { suspend fun exportPdf(documentationId: String): PdfExportResult {
return try { val documentation = getFullDocumentation(documentationId)
val documentation = getFullDocumentation(documentationId) ?: return PdfExportResult.Failure(PdfExportError.DocumentationNotFound)
?: return PdfExportResult.Failure(PdfExportError.DocumentationNotFound) val model = DocumentationToPdfExportModelMapper.map(documentation)
val model = DocumentationToPdfExportModelMapper.map(documentation) return pdfExporter.export(model)
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))
}
}
} }
/** /**
@@ -329,17 +298,7 @@ class DocumentationRepository(
* erneutes Laden wäre unnötige Doppelarbeit. * erneutes Laden wäre unnötige Doppelarbeit.
*/ */
suspend fun convertPdfToImages(documentationId: String, pdfFile: File): PdfToImagesResult = suspend fun convertPdfToImages(documentationId: String, pdfFile: File): PdfToImagesResult =
try { pdfToImagesConverter.convert(documentationId, pdfFile)
pdfToImagesConverter.convert(documentationId, pdfFile)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
when (FailureCategory.of(e)) {
FailureCategory.OUT_OF_MEMORY -> PdfToImagesResult.Failure.OutOfMemory
FailureCategory.OUT_OF_STORAGE -> PdfToImagesResult.Failure.InsufficientStorage
FailureCategory.OTHER -> PdfToImagesResult.Failure.Unexpected(e::class.java.simpleName)
}
}
/** /**
* Liefert eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter * Liefert eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter
@@ -393,24 +352,18 @@ class DocumentationRepository(
require(!SignatureBitmapValidation.isBlank(bitmap)) { "Eine leere Signatur kann nicht gespeichert werden." } require(!SignatureBitmapValidation.isBlank(bitmap)) { "Eine leere Signatur kann nicht gespeichert werden." }
val fileName = signatureStorage.save(documentationId, bitmap) val fileName = signatureStorage.save(documentationId, bitmap)
val nextOrderIndex = signatureDao.getMaxOrderIndex(documentationId) + 1
val now = Instant.now() val now = Instant.now()
val entity = try { val entity = SignatureEntity(
SignatureEntity( id = UUID.randomUUID().toString(),
id = UUID.randomUUID().toString(), documentationId = documentationId,
documentationId = documentationId, signerName = trimmedName,
signerName = trimmedName, fileName = fileName,
fileName = fileName, orderIndex = nextOrderIndex,
orderIndex = signatureDao.getMaxOrderIndex(documentationId) + 1, createdAt = now.toEpochMilli(),
createdAt = now.toEpochMilli(), )
).also { signatureDao.insert(it) } signatureDao.insert(entity)
} catch (e: Throwable) { woundCarerRepository.recordSignatureUsage(trimmedName, bitmap)
runCatching { signatureStorage.delete(documentationId, fileName) }
throw e
}
// Das Wundversorger-Register ist nur eine Komfortfunktion (Namensvorschläge). Scheitert
// es, ist die Signatur der Dokumentation trotzdem bereits sicher gespeichert - der
// Nutzer darf dann keine Fehlermeldung bekommen, die ihn die Signatur erneut erfassen lässt.
runCatchingCancellable { woundCarerRepository.recordSignatureUsage(trimmedName, bitmap) }
return Signature(entity.id, entity.documentationId, entity.signerName, entity.fileName, entity.orderIndex, now) return Signature(entity.id, entity.documentationId, entity.signerName, entity.fileName, entity.orderIndex, now)
} }
@@ -436,9 +389,6 @@ class DocumentationRepository(
// geladen werden. // geladen werden.
runCatching { photoStorage.deleteAll(documentationId) } runCatching { photoStorage.deleteAll(documentationId) }
runCatching { signatureStorage.deleteAll(documentationId) } runCatching { signatureStorage.deleteAll(documentationId) }
// Datenschutz (Löschkonzept): Auch abgeleitete Kopien der Patientendaten - exportierte
// PDFs und deren Seitenbilder - müssen mit der Dokumentation verschwinden.
runCatching { exportedFilesCleaner.deleteExportsFor(documentationId) }
} }
/** /**
@@ -10,8 +10,6 @@ import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.pdf.AndroidPdfDocumentExporter import de.medapp.wunddoku.pdf.AndroidPdfDocumentExporter
import de.medapp.wunddoku.pdf.AndroidPdfToImagesConverter import de.medapp.wunddoku.pdf.AndroidPdfToImagesConverter
import de.medapp.wunddoku.pdf.DocumentationPdfExporter import de.medapp.wunddoku.pdf.DocumentationPdfExporter
import de.medapp.wunddoku.pdf.ExportedFilesCleaner
import de.medapp.wunddoku.pdf.InternalExportedFilesCleaner
import de.medapp.wunddoku.pdf.PdfToImagesConverter import de.medapp.wunddoku.pdf.PdfToImagesConverter
import de.medapp.wunddoku.signature.InternalSignatureStorage import de.medapp.wunddoku.signature.InternalSignatureStorage
import de.medapp.wunddoku.signature.SignatureStorage import de.medapp.wunddoku.signature.SignatureStorage
@@ -44,11 +42,6 @@ class AppContainer(context: Context) {
} }
private val pdfToImagesConverter: PdfToImagesConverter by lazy { AndroidPdfToImagesConverter(context) } private val pdfToImagesConverter: PdfToImagesConverter by lazy { AndroidPdfToImagesConverter(context) }
/** Löscht Exporte gelöschter Dokumentationen sowie veraltete Exporte/Kameraaufnahmen. */
val exportedFilesCleaner: ExportedFilesCleaner by lazy {
InternalExportedFilesCleaner(filesDir = context.filesDir, cacheDir = context.cacheDir)
}
val patientRepository: PatientRepository by lazy { val patientRepository: PatientRepository by lazy {
PatientRepository(patientDao = database.patientDao()) PatientRepository(patientDao = database.patientDao())
} }
@@ -74,7 +67,6 @@ class AppContainer(context: Context) {
pdfExporter = pdfExporter, pdfExporter = pdfExporter,
pdfToImagesConverter = pdfToImagesConverter, pdfToImagesConverter = pdfToImagesConverter,
db = database, db = database,
exportedFilesCleaner = exportedFilesCleaner,
) )
} }
} }
@@ -96,7 +96,7 @@ object DocumentationCatalog {
title = "Lokalisation", title = "Lokalisation",
order = 2, order = 2,
items = listOf( items = listOf(
CheckItemDefinition(id = "lokalisation_multiple_wunden", label = "multiple Wunden im Bereich", order = 1), CheckItemDefinition(id = "lokalisation_multiple_wunden", label = "Multiple Wunden im Bereich", order = 1),
), ),
), ),
BlockDefinition( BlockDefinition(
@@ -120,9 +120,13 @@ object DocumentationCatalog {
), ),
CheckItemDefinition(id = "groesse_freitext_masse", label = "Freitext für die Maße", order = 3, isFreeText = true), CheckItemDefinition(id = "groesse_freitext_masse", label = "Freitext für die Maße", order = 3, isFreeText = true),
CheckItemDefinition(id = "groesse_multiple_wunden_max", label = "Multiple Wunden mit max. (cm)", order = 4, isFreeText = true), CheckItemDefinition(id = "groesse_multiple_wunden_max", label = "Multiple Wunden mit max. (cm)", order = 4, isFreeText = true),
CheckItemDefinition(id = "groesse_hinweis_areal", label = "Zu versorgendes Areal", order = 5, isStaticHint = true), // Bewusst OHNE separate Hinweis-Überschrift (anders als bei "Maße" oben): Die
CheckItemDefinition(id = "groesse_areal", label = "Zu versorgendes Areal", order = 6), // Checkbox selbst trägt mit "Zu versorgendes Areal" bereits den fachlich
CheckItemDefinition(id = "groesse_freitext_areal", label = "Freitext für zu versorgendes Areal", order = 7, isFreeText = true), // gewünschten Namen - eine zusätzliche, wortgleiche Überschrift direkt darüber
// würde im PDF als zwei identische, nicht unterscheidbare Zeilen hintereinander
// erscheinen (Hinweistext und Checkbox-Label werden dort gleich dargestellt).
CheckItemDefinition(id = "groesse_areal", label = "Zu versorgendes Areal", order = 5),
CheckItemDefinition(id = "groesse_freitext_areal", label = "Freitext für zu versorgendes Areal", order = 6, isFreeText = true),
), ),
), ),
BlockDefinition( BlockDefinition(
@@ -344,8 +348,8 @@ object DocumentationCatalog {
CheckItemDefinition(id = "vitalwerte_arteria_dorsalis_pedis_links", label = "Arteria dorsalis pedis links", order = 2), CheckItemDefinition(id = "vitalwerte_arteria_dorsalis_pedis_links", label = "Arteria dorsalis pedis links", order = 2),
CheckItemDefinition(id = "vitalwerte_arteria_tibialis_posterior_rechts", label = "Arteria tibialis posterior rechts", order = 3), CheckItemDefinition(id = "vitalwerte_arteria_tibialis_posterior_rechts", label = "Arteria tibialis posterior rechts", order = 3),
CheckItemDefinition(id = "vitalwerte_arteria_dorsalis_pedis_rechts", label = "Arteria dorsalis pedis rechts", order = 4), CheckItemDefinition(id = "vitalwerte_arteria_dorsalis_pedis_rechts", label = "Arteria dorsalis pedis rechts", order = 4),
CheckItemDefinition(id = "vitalwerte_per_doppler_taschendoppler", label = "per Doppler (Taschendoppler)", order = 5), CheckItemDefinition(id = "vitalwerte_per_doppler_taschendoppler", label = "Per Doppler (Taschendoppler)", order = 5),
CheckItemDefinition(id = "vitalwerte_per_palpation", label = "per Palpation", order = 6), CheckItemDefinition(id = "vitalwerte_per_palpation", label = "Per Palpation", order = 6),
CheckItemDefinition(id = "vitalwerte_darstellbar", label = "Darstellbar", order = 7), CheckItemDefinition(id = "vitalwerte_darstellbar", label = "Darstellbar", order = 7),
CheckItemDefinition(id = "vitalwerte_abgeschwaecht_veraendert", label = "Abgeschwächt/verändert", order = 8), CheckItemDefinition(id = "vitalwerte_abgeschwaecht_veraendert", label = "Abgeschwächt/verändert", order = 8),
CheckItemDefinition(id = "vitalwerte_nicht_darstellbar", label = "Nicht darstellbar", order = 9), CheckItemDefinition(id = "vitalwerte_nicht_darstellbar", label = "Nicht darstellbar", order = 9),
@@ -1,59 +0,0 @@
package de.medapp.wunddoku.domain.error
import kotlinx.coroutines.CancellationException
/**
* Grobe Einordnung eines Fehlers für die Anzeige und Weiterbehandlung.
*
* Bewusst rein in Kotlin/JVM gehalten (keine Android-Klassen), damit die Einordnung per
* einfachem JVM-Unit-Test prüfbar ist. Android-spezifische Typen (z. B. `SQLiteFullException`)
* werden deshalb über Klassenname bzw. Meldungstext erkannt.
*/
enum class FailureCategory {
/** Arbeitsspeicher erschöpft ([OutOfMemoryError]). */
OUT_OF_MEMORY,
/** Geräte-/Datenbankspeicher voll (ENOSPC, `SQLiteFullException`, "disk is full"). */
OUT_OF_STORAGE,
/** Alles andere. */
OTHER,
;
companion object {
private const val SQLITE_FULL_EXCEPTION = "android.database.sqlite.SQLiteFullException"
private val STORAGE_FULL_MARKERS = listOf(
"no space left on device",
"enospc",
"database or disk is full",
"disk full",
"sqlite_full",
)
/** Durchsucht [throwable] samt gesamter Ursachenkette. */
fun of(throwable: Throwable): FailureCategory {
val seen = HashSet<Throwable>()
var current: Throwable? = throwable
while (current != null && seen.add(current)) {
if (current is OutOfMemoryError) return OUT_OF_MEMORY
if (current.javaClass.name == SQLITE_FULL_EXCEPTION) return OUT_OF_STORAGE
val message = current.message?.lowercase()
if (message != null && STORAGE_FULL_MARKERS.any { message.contains(it) }) return OUT_OF_STORAGE
current = current.cause
}
return OTHER
}
}
}
/**
* Wie [runCatching], reicht aber eine Coroutine-Abbruch-Exception immer weiter - ein
* abgebrochener Vorgang darf nicht als "Fehler" verschluckt werden.
*/
inline fun <T> runCatchingCancellable(block: () -> T): Result<T> = try {
Result.success(block())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
@@ -117,13 +117,13 @@ class InternalPhotoStorage(private val context: Context) : PhotoStorage {
} }
private fun documentationDirPath(documentationId: String): File = private fun documentationDirPath(documentationId: String): File =
SafePaths.resolveChild(photosRootDir, documentationId) File(photosRootDir, documentationId)
private fun ensureDocumentationDir(documentationId: String): File = private fun ensureDocumentationDir(documentationId: String): File =
documentationDirPath(documentationId).apply { mkdirs() } documentationDirPath(documentationId).apply { mkdirs() }
override fun resolve(documentationId: String, fileName: String): File = override fun resolve(documentationId: String, fileName: String): File =
SafePaths.resolveChild(documentationDirPath(documentationId), fileName) File(documentationDirPath(documentationId), fileName)
override suspend fun importFrom(documentationId: String, sourceUri: Uri): String = override suspend fun importFrom(documentationId: String, sourceUri: Uri): String =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -172,37 +172,22 @@ class InternalPhotoStorage(private val context: Context) : PhotoStorage {
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL) }.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
} ?: ExifInterface.ORIENTATION_NORMAL } ?: ExifInterface.ORIENTATION_NORMAL
val corrected = try { val corrected = ImageDownsampler.applyExifOrientation(decoded, orientation)
ImageDownsampler.applyExifOrientation(decoded, orientation)
} catch (e: Throwable) {
decoded.recycle()
throw e
}
// 4) Als neue, eigenständige JPEG-Datei im dokumentationsspezifischen Verzeichnis // 4) Als neue, eigenständige JPEG-Datei im dokumentationsspezifischen Verzeichnis
// speichern. Erneutes Kodieren als JPEG verwirft dabei nebenbei die ursprünglichen // speichern. Erneutes Kodieren als JPEG verwirft dabei nebenbei die ursprünglichen
// EXIF-Metadaten (z. B. GPS-Position) des Originalfotos. // EXIF-Metadaten (z. B. GPS-Position) des Originalfotos, statt sie 1:1 zu
// // übernehmen - für sensible medizinische Dokumentationsfotos ein willkommener
// Fehlerbehandlung: Bitmap.compress() liefert bei einem Schreibfehler (z. B. Speicher // Nebeneffekt, keine bewusste Anforderung dieser Aufgabe.
// voll) nur `false` statt zu werfen - das wird hier zu einer Exception, und eine
// halb geschriebene Datei wird in jedem Fehlerfall wieder entfernt, damit nie eine
// defekte Bilddatei im Dokumentationsordner liegen bleibt.
val targetDir = ensureDocumentationDir(documentationId) val targetDir = ensureDocumentationDir(documentationId)
val fileName = "${UUID.randomUUID()}.jpg" val fileName = "${UUID.randomUUID()}.jpg"
val targetFile = File(targetDir, fileName) File(targetDir, fileName).outputStream().use { out ->
try { corrected.compress(Bitmap.CompressFormat.JPEG, ImageDownsampler.JPEG_QUALITY, out)
val written = targetFile.outputStream().use { out ->
corrected.compress(Bitmap.CompressFormat.JPEG, ImageDownsampler.JPEG_QUALITY, out)
}
if (!written) throw java.io.IOException("Bild konnte nicht gespeichert werden.")
} catch (e: Throwable) {
targetFile.delete()
throw e
} finally {
if (corrected !== decoded) decoded.recycle()
corrected.recycle()
} }
if (corrected !== decoded) decoded.recycle()
corrected.recycle()
fileName fileName
} }
@@ -214,13 +199,7 @@ class InternalPhotoStorage(private val context: Context) : PhotoStorage {
val sourceFile = resolve(sourceDocumentationId, sourceFileName) val sourceFile = resolve(sourceDocumentationId, sourceFileName)
val targetFileName = "${UUID.randomUUID()}.jpg" val targetFileName = "${UUID.randomUUID()}.jpg"
val targetFile = File(ensureDocumentationDir(targetDocumentationId), targetFileName) val targetFile = File(ensureDocumentationDir(targetDocumentationId), targetFileName)
try { sourceFile.copyTo(targetFile, overwrite = false)
sourceFile.copyTo(targetFile, overwrite = false)
} catch (e: Throwable) {
// Keine halb kopierte Datei zurücklassen.
targetFile.delete()
throw e
}
targetFileName targetFileName
} }
@@ -1,32 +0,0 @@
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
}
}
@@ -7,7 +7,6 @@ import android.graphics.Paint
import android.graphics.Typeface import android.graphics.Typeface
import android.graphics.pdf.PdfDocument import android.graphics.pdf.PdfDocument
import android.text.TextPaint import android.text.TextPaint
import de.medapp.wunddoku.domain.error.FailureCategory
import de.medapp.wunddoku.domain.model.InputSpecCodec import de.medapp.wunddoku.domain.model.InputSpecCodec
import de.medapp.wunddoku.media.PhotoStorage import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.signature.SignatureStorage import de.medapp.wunddoku.signature.SignatureStorage
@@ -186,18 +185,11 @@ class AndroidPdfDocumentExporter(
} }
PdfExportResult.Success(targetFile) PdfExportResult.Success(targetFile)
} catch (e: Throwable) { } catch (e: Exception) {
tempFile.delete() tempFile.delete()
if (e is kotlinx.coroutines.CancellationException) throw e PdfExportResult.Failure(
// Bewusst Throwable: OutOfMemoryError ist ein Error, keine Exception, und würde PdfExportError.WriteFailed(e.message ?: "Unbekannter Fehler bei der PDF-Erzeugung.")
// sonst die App beenden. Eine bereits vorhandene PDF am Zielpfad bleibt unangetastet. )
when (FailureCategory.of(e)) {
FailureCategory.OUT_OF_MEMORY -> PdfExportResult.Failure(PdfExportError.OutOfMemory)
FailureCategory.OUT_OF_STORAGE -> PdfExportResult.Failure(PdfExportError.InsufficientStorage)
FailureCategory.OTHER -> PdfExportResult.Failure(
PdfExportError.WriteFailed(e.message ?: "Unbekannter Fehler bei der PDF-Erzeugung.")
)
}
} finally { } finally {
pdfDocument.close() pdfDocument.close()
// Erst JETZT sicher, da writeTo() (im try-Block oben) zu diesem Zeitpunkt entweder // Erst JETZT sicher, da writeTo() (im try-Block oben) zu diesem Zeitpunkt entweder
@@ -38,25 +38,4 @@ sealed interface PdfExportError {
data class DirectoryCreationFailed(val path: String) : PdfExportError data class DirectoryCreationFailed(val path: String) : PdfExportError
data class WriteFailed(val message: String) : PdfExportError data class WriteFailed(val message: String) : PdfExportError
data class Unexpected(val message: String) : PdfExportError data class Unexpected(val message: String) : PdfExportError
/** Arbeitsspeicher reichte für die PDF-Erzeugung nicht aus (z. B. sehr viele Fotos). */
data object OutOfMemory : PdfExportError
/** Auf dem Gerät ist nicht genug freier Speicherplatz für die PDF-Datei vorhanden. */
data object InsufficientStorage : PdfExportError
}
/**
* Verständliche Anzeige-Meldung ohne technische Details (keine Pfade, keine Exception-Texte -
* Datenschutz: es können Patientendaten im Dateinamen/Pfad stecken).
*/
fun PdfExportError.toUserMessage(): String = when (this) {
PdfExportError.DocumentationNotFound -> "Die Dokumentation wurde nicht gefunden."
is PdfExportError.DirectoryCreationFailed -> "Zielverzeichnis konnte nicht angelegt werden."
is PdfExportError.WriteFailed -> "Beim Schreiben der PDF-Datei ist ein Fehler aufgetreten."
is PdfExportError.Unexpected -> "Es ist ein unerwarteter Fehler aufgetreten."
PdfExportError.OutOfMemory ->
"Nicht genügend Arbeitsspeicher. Bitte andere Apps schließen oder weniger Fotos verwenden."
PdfExportError.InsufficientStorage ->
"Nicht genügend freier Speicherplatz auf dem Gerät."
} }
@@ -1,80 +0,0 @@
package de.medapp.wunddoku.pdf
import de.medapp.wunddoku.media.SafePaths
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
/**
* Löscht abgeleitete, personenbezogene Dateien, die NICHT zur Dokumentation selbst gehören:
* exportierte PDFs (`filesDir/pdf/<documentationId>/`), daraus erzeugte Seitenbilder
* (`filesDir/pdf_images/<documentationId>/`) und liegengebliebene Kameraaufnahmen
* (`cacheDir/camera/`).
*
* Datenschutz: PDFs enthalten Name, Geburtsdatum, Anschrift und Wundbefund. Sie dürfen nach dem
* Löschen einer Dokumentation nicht im Speicher zurückbleiben (Löschkonzept) und werden darüber
* hinaus nach einer Aufbewahrungsfrist automatisch entfernt - sie lassen sich jederzeit aus den
* Daten der Dokumentation neu erzeugen.
*/
interface ExportedFilesCleaner {
/** Entfernt alle PDF- und Bildexporte der Dokumentation [documentationId]. Wirft nie. */
suspend fun deleteExportsFor(documentationId: String)
/** Entfernt veraltete Exporte und liegengebliebene Kameraaufnahmen. Wirft nie. */
suspend fun deleteStaleFiles()
/** Tut nichts (Standard für Tests und Umgebungen ohne Dateisystem). */
object None : ExportedFilesCleaner {
override suspend fun deleteExportsFor(documentationId: String) = Unit
override suspend fun deleteStaleFiles() = Unit
}
}
class InternalExportedFilesCleaner(
filesDir: File,
cacheDir: File,
private val exportMaxAgeMillis: Long = DEFAULT_EXPORT_MAX_AGE_MILLIS,
private val captureMaxAgeMillis: Long = DEFAULT_CAPTURE_MAX_AGE_MILLIS,
private val now: () -> Long = System::currentTimeMillis,
) : ExportedFilesCleaner {
private val pdfRoot = File(filesDir, "pdf")
private val imagesRoot = File(filesDir, "pdf_images")
private val captureDir = File(cacheDir, "camera")
override suspend fun deleteExportsFor(documentationId: String) = withContext(Dispatchers.IO) {
listOf(pdfRoot, imagesRoot).forEach { root ->
runCatching { SafePaths.resolveChild(root, documentationId).deleteRecursively() }
}
Unit
}
override suspend fun deleteStaleFiles() = withContext(Dispatchers.IO) {
val currentTime = now()
runCatching { deleteOlderThan(pdfRoot, currentTime - exportMaxAgeMillis) }
runCatching { deleteOlderThan(imagesRoot, currentTime - exportMaxAgeMillis) }
// Kameraaufnahmen nur nach längerer Frist löschen: Wurde die App während der Aufnahme
// beendet, wird die Datei nach dem Neustart noch für den Import gebraucht.
runCatching { deleteOlderThan(captureDir, currentTime - captureMaxAgeMillis) }
Unit
}
/** Löscht Dateien (rekursiv) mit `lastModified < cutoff`; leere Unterordner werden entfernt. */
private fun deleteOlderThan(dir: File, cutoff: Long) {
val children = dir.listFiles() ?: return
for (child in children) {
if (child.isDirectory) {
deleteOlderThan(child, cutoff)
if (child.listFiles().isNullOrEmpty()) child.delete()
} else if (child.lastModified() < cutoff) {
child.delete()
}
}
}
companion object {
const val DEFAULT_EXPORT_MAX_AGE_MILLIS: Long = 7L * 24 * 60 * 60 * 1000
const val DEFAULT_CAPTURE_MAX_AGE_MILLIS: Long = 24L * 60 * 60 * 1000
}
}
@@ -5,7 +5,6 @@ import android.graphics.Bitmap
import android.graphics.Color import android.graphics.Color
import android.graphics.pdf.PdfRenderer import android.graphics.pdf.PdfRenderer
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import de.medapp.wunddoku.domain.error.FailureCategory
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
@@ -42,12 +41,6 @@ sealed interface PdfToImagesResult {
data object EmptyDocument : Failure data object EmptyDocument : Failure
data class Unexpected(val message: String) : Failure data class Unexpected(val message: String) : Failure
/** Arbeitsspeicher reichte zum Rendern der Seiten nicht aus. */
data object OutOfMemory : Failure
/** Nicht genug freier Speicherplatz für die Bilddateien. */
data object InsufficientStorage : Failure
} }
} }
@@ -58,10 +51,6 @@ fun PdfToImagesResult.Failure.toUserMessage(): String = when (this) {
"Die PDF-Datei enthält keine Seiten." "Die PDF-Datei enthält keine Seiten."
is PdfToImagesResult.Failure.Unexpected -> is PdfToImagesResult.Failure.Unexpected ->
"Die PDF-Datei konnte nicht in Bilder umgewandelt werden." "Die PDF-Datei konnte nicht in Bilder umgewandelt werden."
PdfToImagesResult.Failure.OutOfMemory ->
"Nicht genügend Arbeitsspeicher zum Umwandeln in Bilder."
PdfToImagesResult.Failure.InsufficientStorage ->
"Nicht genügend freier Speicherplatz für die Bilder."
} }
class AndroidPdfToImagesConverter(private val context: Context) : PdfToImagesConverter { class AndroidPdfToImagesConverter(private val context: Context) : PdfToImagesConverter {
@@ -113,17 +102,8 @@ class AndroidPdfToImagesConverter(private val context: Context) : PdfToImagesCon
PdfToImagesResult.Success(imageFiles) PdfToImagesResult.Success(imageFiles)
} }
} }
} catch (e: Throwable) { } catch (e: Exception) {
// Abbruch der Coroutine nie verschlucken. PdfToImagesResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler bei der Bildkonvertierung.")
if (e is kotlinx.coroutines.CancellationException) throw e
// Halbfertigen Bildsatz entfernen: ein unvollständiger Satz darf nie geteilt werden.
outputDir.deleteRecursively()
when (FailureCategory.of(e)) {
FailureCategory.OUT_OF_MEMORY -> PdfToImagesResult.Failure.OutOfMemory
FailureCategory.OUT_OF_STORAGE -> PdfToImagesResult.Failure.InsufficientStorage
FailureCategory.OTHER ->
PdfToImagesResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler bei der Bildkonvertierung.")
}
} }
} }
@@ -1,6 +1,5 @@
package de.medapp.wunddoku.signature package de.medapp.wunddoku.signature
import de.medapp.wunddoku.media.SafePaths
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
@@ -93,32 +92,23 @@ class InternalSignatureStorage(
} }
private fun documentationDirPath(documentationId: String): File = private fun documentationDirPath(documentationId: String): File =
SafePaths.resolveChild(rootDir, documentationId) File(rootDir, documentationId)
private fun ensureDocumentationDir(documentationId: String): File = private fun ensureDocumentationDir(documentationId: String): File =
documentationDirPath(documentationId).apply { mkdirs() } documentationDirPath(documentationId).apply { mkdirs() }
override fun resolve(documentationId: String, fileName: String): File = override fun resolve(documentationId: String, fileName: String): File =
SafePaths.resolveChild(documentationDirPath(documentationId), fileName) File(documentationDirPath(documentationId), fileName)
override suspend fun save(documentationId: String, bitmap: Bitmap): String = override suspend fun save(documentationId: String, bitmap: Bitmap): String =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val fileName = "${UUID.randomUUID()}.png" val fileName = "${UUID.randomUUID()}.png"
val targetDir = ensureDocumentationDir(documentationId) val targetDir = ensureDocumentationDir(documentationId)
val targetFile = File(targetDir, fileName) FileOutputStream(File(targetDir, fileName)).use { out ->
try { // Qualität 100 ist bei PNG (verlustfreies Format) bedeutungslos - wird trotzdem
val written = FileOutputStream(targetFile).use { out -> // explizit angegeben, da Bitmap.compress() den Parameter unabhängig vom Format
// Qualität 100 ist bei PNG (verlustfreies Format) bedeutungslos - wird trotzdem // verlangt.
// explizit angegeben, da Bitmap.compress() den Parameter unabhängig vom Format bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
// verlangt.
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
}
// compress() meldet Schreibfehler (z. B. Speicher voll) nur über `false`.
if (!written) throw java.io.IOException("Signatur konnte nicht gespeichert werden.")
} catch (e: Throwable) {
// Keine leere/halbe PNG-Datei als scheinbar gültige Signatur zurücklassen.
targetFile.delete()
throw e
} }
fileName fileName
} }
@@ -252,13 +252,6 @@ fun DocumentationEditScreen(
snackbarHostState.showSnackbar(message) snackbarHostState.showSnackbar(message)
viewModel.dismissShareMessage() viewModel.dismissShareMessage()
} }
// Speicherfehler beim Autosave/Löschen: der Nutzer muss erfahren, dass eine Änderung NICHT
// gespeichert wurde (sonst entsteht unbemerkter Datenverlust).
LaunchedEffect(uiState.saveError) {
val message = uiState.saveError ?: return@LaunchedEffect
snackbarHostState.showSnackbar(message)
viewModel.dismissSaveError()
}
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -11,15 +11,13 @@ import androidx.navigation.toRoute
import de.medapp.wunddoku.data.repository.DocumentationRepository import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.data.repository.WoundCarerRepository import de.medapp.wunddoku.data.repository.WoundCarerRepository
import de.medapp.wunddoku.di.wunddokuApplication import de.medapp.wunddoku.di.wunddokuApplication
import de.medapp.wunddoku.domain.error.FailureCategory
import de.medapp.wunddoku.domain.error.runCatchingCancellable
import de.medapp.wunddoku.domain.model.Documentation import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.WoundCarer import de.medapp.wunddoku.domain.model.WoundCarer
import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult import de.medapp.wunddoku.pdf.PdfExportResult
import de.medapp.wunddoku.pdf.PdfToImagesResult import de.medapp.wunddoku.pdf.PdfToImagesResult
import de.medapp.wunddoku.pdf.toUserMessage import de.medapp.wunddoku.pdf.toUserMessage
import de.medapp.wunddoku.ui.navigation.Screen import de.medapp.wunddoku.ui.navigation.Screen
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -92,10 +90,6 @@ data class DocumentationEditUiState(
/** Analog [imagesToShare], nur für die Variante "als PDF teilen" - Gegenstück /** Analog [imagesToShare], nur für die Variante "als PDF teilen" - Gegenstück
* [DocumentationEditViewModel.onPdfShareHandled]. */ * [DocumentationEditViewModel.onPdfShareHandled]. */
val pdfToShare: File? = null, val pdfToShare: File? = null,
/** Einmalige Fehlermeldung, wenn eine Änderung (Haken, Freitext, Löschen) NICHT in der
* Datenbank gespeichert werden konnte - z. B. bei vollem Speicher. Die Anzeige zeigt dann
* wieder den tatsächlich gespeicherten Stand, nie einen nur scheinbar gespeicherten. */
val saveError: String? = null,
) { ) {
val blockCount: Int get() = documentation?.blocks?.size ?: 0 val blockCount: Int get() = documentation?.blocks?.size ?: 0
@@ -146,14 +140,7 @@ class DocumentationEditViewModel(
private suspend fun loadDocumentation() { private suspend fun loadDocumentation() {
_uiState.update { it.copy(isLoading = true, error = null) } _uiState.update { it.copy(isLoading = true, error = null) }
val documentation = try { val documentation = repository.getFullDocumentation(documentationId)
repository.getFullDocumentation(documentationId)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, error = "Dokumentation konnte nicht geladen werden.") }
return
}
_uiState.update { _uiState.update {
if (documentation != null) { if (documentation != null) {
it.copy(documentation = documentation, isLoading = false) it.copy(documentation = documentation, isLoading = false)
@@ -173,48 +160,11 @@ class DocumentationEditViewModel(
} }
private suspend fun persistFreeText(checkItemStateId: String, text: String) { private suspend fun persistFreeText(checkItemStateId: String, text: String) {
// Fehler (z. B. voller Speicher) dürfen weder die Debounce-Schleife beenden noch die App repository.updateFreeText(checkItemStateId, text)
// abstürzen lassen. Der Wert bleibt in [pendingFreeText] und wird beim nächsten Flush
// (ON_STOP) bzw. bei der nächsten Eingabe erneut geschrieben - kein stiller Datenverlust.
val result = runCatchingCancellable { repository.updateFreeText(checkItemStateId, text) }
if (result.isFailure) {
reportSaveFailure(result.exceptionOrNull())
return
}
// Nur entfernen, wenn zwischenzeitlich kein neuerer Wert eingetroffen ist. // Nur entfernen, wenn zwischenzeitlich kein neuerer Wert eingetroffen ist.
if (pendingFreeText[checkItemStateId] == text) pendingFreeText.remove(checkItemStateId) if (pendingFreeText[checkItemStateId] == text) pendingFreeText.remove(checkItemStateId)
} }
private fun reportSaveFailure(cause: Throwable?, fallback: String = "Die Änderung konnte nicht gespeichert werden.") {
val message = when (cause?.let { FailureCategory.of(it) }) {
FailureCategory.OUT_OF_STORAGE -> "Nicht genügend Speicherplatz - die Änderung konnte nicht gespeichert werden."
FailureCategory.OUT_OF_MEMORY -> "Nicht genügend Arbeitsspeicher - die Änderung konnte nicht gespeichert werden."
else -> fallback
}
_uiState.update { it.copy(saveError = message) }
}
/** Lädt den tatsächlich gespeicherten Stand neu (best effort), z. B. nach einem
* fehlgeschlagenen Schreibvorgang, damit die Anzeige nie vom Datenbankstand abweicht. */
private suspend fun refreshFromDatabase() {
val refreshed = runCatchingCancellable { repository.getFullDocumentation(documentationId) }.getOrNull()
if (refreshed != null) {
// Noch nicht gespeicherte Freitexte (pendingFreeText) bleiben in der Anzeige erhalten.
val merged = if (pendingFreeText.isEmpty()) refreshed else refreshed.copy(
blocks = refreshed.blocks.map { block ->
block.copy(items = block.items.map { item ->
pendingFreeText[item.checkItemStateId]?.let { item.copy(freeTextValue = it) } ?: item
})
},
)
_uiState.update { it.copy(documentation = merged) }
}
}
fun dismissSaveError() {
_uiState.update { it.copy(saveError = null) }
}
/** /**
* [checkItemStateId] referenziert die Freitext-Option eines Blocks (CheckItem.isFreeText = * [checkItemStateId] referenziert die Freitext-Option eines Blocks (CheckItem.isFreeText =
* true), NICHT den Block selbst - der Freitext ist seit der Umstellung auf eine * true), NICHT den Block selbst - der Freitext ist seit der Umstellung auf eine
@@ -252,13 +202,7 @@ class DocumentationEditViewModel(
state.copy(documentation = documentation.copy(blocks = updatedBlocks)) state.copy(documentation = documentation.copy(blocks = updatedBlocks))
} }
// 2) sofort persistieren (kein Debounce nötig, einzelnes günstiges UPDATE) // 2) sofort persistieren (kein Debounce nötig, einzelnes günstiges UPDATE)
viewModelScope.launch { viewModelScope.launch { repository.setChecked(checkItemStateId, checked) }
val result = runCatchingCancellable { repository.setChecked(checkItemStateId, checked) }
if (result.isFailure) {
reportSaveFailure(result.exceptionOrNull())
refreshFromDatabase()
}
}
} }
/** /**
@@ -317,40 +261,25 @@ class DocumentationEditViewModel(
try { try {
photoImportMutex.withLock { photoImportMutex.withLock {
var failureCount = 0 var failureCount = 0
var outOfMemoryCount = 0
var outOfStorageCount = 0
for (uri in uris) { for (uri in uris) {
try { try {
repository.addPhoto(documentationId, uri) repository.addPhoto(documentationId, uri)
} catch (e: CancellationException) { } catch (e: Exception) {
throw e
} catch (e: Throwable) {
// Throwable (statt Exception): OutOfMemoryError beim Dekodieren großer
// Fotos darf die App nicht beenden - das Bild gilt als fehlgeschlagen,
// die übrigen Bilder werden weiter importiert.
failureCount++ failureCount++
if (FailureCategory.of(e) == FailureCategory.OUT_OF_MEMORY) outOfMemoryCount++ // Bewusst OHNE Uri/Dateipfad geloggt (Datenschutz-Vorgabe: keine Bild-Uris/
if (FailureCategory.of(e) == FailureCategory.OUT_OF_STORAGE) outOfStorageCount++ // 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( android.util.Log.w(
"DocumentationEdit", "DocumentationEdit",
"Fotoimport fehlgeschlagen: ${e::class.simpleName}", "Fotoimport fehlgeschlagen: ${e::class.simpleName}: ${e.message}",
) )
} }
} }
val refreshed = runCatchingCancellable { repository.getFullDocumentation(documentationId) }.getOrNull() val refreshed = repository.getFullDocumentation(documentationId)
val reason = when {
outOfStorageCount > 0 -> " Nicht genügend Speicherplatz."
outOfMemoryCount > 0 -> " Nicht genügend Arbeitsspeicher."
else -> ""
}
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
documentation = refreshed ?: state.documentation, documentation = refreshed ?: state.documentation,
photoImportError = if (failureCount > 0) { photoImportError = if (failureCount > 0) {
"$failureCount von ${uris.size} Bild(ern) konnten nicht importiert werden.$reason" "$failureCount von ${uris.size} Bild(ern) konnten nicht importiert werden."
} else { } else {
state.photoImportError state.photoImportError
}, },
@@ -388,9 +317,8 @@ class DocumentationEditViewModel(
fun confirmDeletePhoto() { fun confirmDeletePhoto() {
val photoId = _uiState.value.pendingDeletePhotoId ?: return val photoId = _uiState.value.pendingDeletePhotoId ?: return
viewModelScope.launch { viewModelScope.launch {
val result = runCatchingCancellable { repository.removePhoto(photoId) } repository.removePhoto(photoId)
if (result.isFailure) reportSaveFailure(result.exceptionOrNull(), "Das Foto konnte nicht gelöscht werden.") val refreshed = repository.getFullDocumentation(documentationId)
val refreshed = runCatchingCancellable { repository.getFullDocumentation(documentationId) }.getOrNull()
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
documentation = refreshed ?: state.documentation, documentation = refreshed ?: state.documentation,
@@ -430,17 +358,6 @@ class DocumentationEditViewModel(
} }
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
_uiState.update { it.copy(signatureError = e.message ?: "Signatur konnte nicht gespeichert werden.") } _uiState.update { it.copy(signatureError = e.message ?: "Signatur konnte nicht gespeichert werden.") }
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
// Speicherfehler o. Ä.: Dialog bleibt offen, die gezeichnete Signatur geht nicht
// verloren und kann nach Freigabe von Speicher erneut gespeichert werden.
val message = when (FailureCategory.of(e)) {
FailureCategory.OUT_OF_STORAGE -> "Nicht genügend Speicherplatz - die Signatur konnte nicht gespeichert werden."
FailureCategory.OUT_OF_MEMORY -> "Nicht genügend Arbeitsspeicher - die Signatur konnte nicht gespeichert werden."
FailureCategory.OTHER -> "Die Signatur konnte nicht gespeichert werden."
}
_uiState.update { it.copy(signatureError = message) }
} }
} }
} }
@@ -462,9 +379,8 @@ class DocumentationEditViewModel(
fun confirmDeleteSignature() { fun confirmDeleteSignature() {
val signatureId = _uiState.value.pendingDeleteSignatureId ?: return val signatureId = _uiState.value.pendingDeleteSignatureId ?: return
viewModelScope.launch { viewModelScope.launch {
val result = runCatchingCancellable { repository.removeSignature(signatureId) } repository.removeSignature(signatureId)
if (result.isFailure) reportSaveFailure(result.exceptionOrNull(), "Die Signatur konnte nicht gelöscht werden.") val refreshed = repository.getFullDocumentation(documentationId)
val refreshed = runCatchingCancellable { repository.getFullDocumentation(documentationId) }.getOrNull()
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
documentation = refreshed ?: state.documentation, documentation = refreshed ?: state.documentation,
@@ -623,6 +539,13 @@ class DocumentationEditViewModel(
_uiState.update { it.copy(shareMessage = null) } _uiState.update { it.copy(shareMessage = null) }
} }
private fun PdfExportError.toUserMessage(): String = when (this) {
PdfExportError.DocumentationNotFound -> "Die Dokumentation wurde nicht gefunden."
is PdfExportError.DirectoryCreationFailed -> "Zielverzeichnis konnte nicht angelegt werden."
is PdfExportError.WriteFailed -> "Beim Schreiben der PDF-Datei ist ein Fehler aufgetreten."
is PdfExportError.Unexpected -> "Es ist ein unerwarteter Fehler aufgetreten."
}
/** /**
* Lädt eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter. * Lädt eine speicherschonende Vorschauversion eines Fotos für die Anzeige im Fotogitter.
* Bewusst NICHT im zentralen UiState gehalten (kein `Bitmap`-Feld in * Bewusst NICHT im zentralen UiState gehalten (kein `Bitmap`-Feld in
@@ -666,10 +589,9 @@ class DocumentationEditViewModel(
fun flushPendingChanges() { fun flushPendingChanges() {
if (pendingFreeText.isEmpty()) return if (pendingFreeText.isEmpty()) return
val toFlush = pendingFreeText.toMap() val toFlush = pendingFreeText.toMap()
pendingFreeText.clear()
viewModelScope.launch { viewModelScope.launch {
// Einträge werden erst NACH erfolgreichem Schreiben aus pendingFreeText entfernt - toFlush.forEach { (checkItemStateId, text) -> repository.updateFreeText(checkItemStateId, text) }
// scheitert das Schreiben, bleibt der Text für den nächsten Versuch erhalten.
toFlush.forEach { (checkItemStateId, text) -> persistFreeText(checkItemStateId, text) }
} }
} }
@@ -9,7 +9,6 @@ import androidx.navigation.toRoute
import de.medapp.wunddoku.data.repository.DocumentationRepository import de.medapp.wunddoku.data.repository.DocumentationRepository
import de.medapp.wunddoku.data.repository.PatientRepository import de.medapp.wunddoku.data.repository.PatientRepository
import de.medapp.wunddoku.di.wunddokuApplication import de.medapp.wunddoku.di.wunddokuApplication
import de.medapp.wunddoku.domain.error.FailureCategory
import de.medapp.wunddoku.domain.model.Patient import de.medapp.wunddoku.domain.model.Patient
import de.medapp.wunddoku.domain.model.PatientListItem import de.medapp.wunddoku.domain.model.PatientListItem
import de.medapp.wunddoku.ui.components.format.DATE_MAX_DIGITS import de.medapp.wunddoku.ui.components.format.DATE_MAX_DIGITS
@@ -19,7 +18,6 @@ import de.medapp.wunddoku.ui.components.format.filterDigits
import de.medapp.wunddoku.ui.components.format.germanDateDigitsToLocalDateOrNull import de.medapp.wunddoku.ui.components.format.germanDateDigitsToLocalDateOrNull
import de.medapp.wunddoku.ui.components.format.toGermanDateDigits import de.medapp.wunddoku.ui.components.format.toGermanDateDigits
import de.medapp.wunddoku.ui.navigation.Screen import de.medapp.wunddoku.ui.navigation.Screen
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -189,7 +187,7 @@ class PatientFormViewModel(
fun useExistingPatient() { fun useExistingPatient() {
val existing = _uiState.value.duplicateWarning ?: return val existing = _uiState.value.duplicateWarning ?: return
_uiState.update { it.copy(duplicateWarning = null) } _uiState.update { it.copy(duplicateWarning = null) }
viewModelScope.launch { guardedFinish(existing.id) } viewModelScope.launch { finish(existing.id) }
} }
/** Der Nutzer entscheidet sich im Duplikat-Dialog, trotzdem einen neuen Patienten anzulegen. */ /** Der Nutzer entscheidet sich im Duplikat-Dialog, trotzdem einen neuen Patienten anzulegen. */
@@ -243,39 +241,12 @@ class PatientFormViewModel(
createdAt = Instant.EPOCH, // wird von PatientRepository.upsert() korrekt gesetzt createdAt = Instant.EPOCH, // wird von PatientRepository.upsert() korrekt gesetzt
updatedAt = Instant.EPOCH, updatedAt = Instant.EPOCH,
) )
val saved = try { val saved = repository.upsert(patient)
repository.upsert(patient)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
reportPersistenceFailure(e, "Der Patient konnte nicht gespeichert werden.")
return@launch
}
_uiState.update { it.copy(validationError = null) } _uiState.update { it.copy(validationError = null) }
guardedFinish(saved.id) finish(saved.id)
} }
} }
/** Wie [finish], fängt aber Datenbankfehler (z. B. voller Speicher) ab: Das Formular bleibt
* mit allen Eingaben offen und zeigt eine Meldung, statt dass die App abstürzt. */
private suspend fun guardedFinish(resolvedPatientId: String) {
try {
finish(resolvedPatientId)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
reportPersistenceFailure(e, "Die Dokumentation konnte nicht angelegt werden.")
}
}
private fun reportPersistenceFailure(cause: Throwable, fallback: String) {
val message = when (FailureCategory.of(cause)) {
FailureCategory.OUT_OF_STORAGE -> "Nicht genügend Speicherplatz - bitte Speicher freigeben und erneut versuchen."
else -> fallback
}
_uiState.update { it.copy(validationError = message) }
}
/** /**
* Gemeinsamer Abschluss für beide Wege, an einen gültigen Patienten zu kommen (neu * Gemeinsamer Abschluss für beide Wege, an einen gültigen Patienten zu kommen (neu
* gespeichert oder als Duplikat übernommen): Je nach [startsNewDocumentation] entweder eine * gespeichert oder als Duplikat übernommen): Je nach [startsNewDocumentation] entweder eine
+3 -4
View File
@@ -1,16 +1,15 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
Gibt ausschließlich Unterordner des privaten internen App-Speichers (context.filesDir) Gibt ausschließlich Unterordner des privaten internen App-Speichers (context.filesDir)
für die Freigabe über den FileProvider frei - und zwar NUR das, was die App tatsächlich für die Freigabe über den FileProvider frei. Es gibt bewusst keinen <external-path>-
nach außen gibt (PDF-Export, Seitenbilder, Kameraaufnahme). Die Ordner photos/ und
signatures/ (Originalfotos, Unterschriften) sind bewusst NICHT freigegeben (Prinzip der
minimalen Freigabe; Datenschutz-Review QA-006). Es gibt bewusst keinen <external-path>-
oder <external-files-path>-Eintrag: Fotos, Signaturen und PDFs liegen grundsätzlich oder <external-files-path>-Eintrag: Fotos, Signaturen und PDFs liegen grundsätzlich
intern (nur die kurzlebigen Kameraaufnahmen zusätzlich im internen Cache) (Abschnitt "Storage-Strategie" der Architektur, v3). intern (nur die kurzlebigen Kameraaufnahmen zusätzlich im internen Cache) (Abschnitt "Storage-Strategie" der Architektur, v3).
--> -->
<paths xmlns:android="http://schemas.android.com/apk/res/android"> <paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="pdf_exports" path="pdf/" /> <files-path name="pdf_exports" path="pdf/" />
<files-path name="pdf_images" path="pdf_images/" /> <files-path name="pdf_images" path="pdf_images/" />
<files-path name="photos" path="photos/" />
<files-path name="signatures" path="signatures/" />
<!-- Temporäre Kameraaufnahmen (Kamerabutton im Fotobereich): nur bis zum Import in die <!-- Temporäre Kameraaufnahmen (Kamerabutton im Fotobereich): nur bis zum Import in die
Dokumentation, danach sofort gelöscht. --> Dokumentation, danach sofort gelöscht. -->
<cache-path name="camera_captures" path="camera/" /> <cache-path name="camera_captures" path="camera/" />
@@ -1,91 +0,0 @@
package de.medapp.wunddoku.domain.error
import kotlinx.coroutines.CancellationException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import java.io.FileNotFoundException
import java.io.IOException
/**
* Reine JVM-Tests für die Fehlereinordnung (Speichermangel erkennen) und
* [runCatchingCancellable].
*/
class FailureCategoryTest {
// --- Arbeitsspeicher -----------------------------------------------------------------
@Test
fun out_of_memory_error_wird_erkannt() {
assertEquals(FailureCategory.OUT_OF_MEMORY, FailureCategory.of(OutOfMemoryError("Failed to allocate")))
}
@Test
fun out_of_memory_error_wird_auch_als_ursache_erkannt() {
val wrapped = RuntimeException("Export fehlgeschlagen", OutOfMemoryError())
assertEquals(FailureCategory.OUT_OF_MEMORY, FailureCategory.of(wrapped))
}
// --- Speicherplatz -------------------------------------------------------------------
@Test
fun enospc_ioexception_wird_als_speicher_voll_erkannt() {
val e = IOException("write failed: ENOSPC (No space left on device)")
assertEquals(FailureCategory.OUT_OF_STORAGE, FailureCategory.of(e))
}
@Test
fun sqlite_disk_full_meldung_wird_als_speicher_voll_erkannt() {
val e = RuntimeException("database or disk is full (code 13 SQLITE_FULL)")
assertEquals(FailureCategory.OUT_OF_STORAGE, FailureCategory.of(e))
}
@Test
fun erkennung_ist_unabhaengig_von_gross_und_kleinschreibung() {
assertEquals(FailureCategory.OUT_OF_STORAGE, FailureCategory.of(IOException("NO SPACE LEFT ON DEVICE")))
}
@Test
fun speicher_voll_in_tiefer_ursachenkette_wird_erkannt() {
val root = IOException("No space left on device")
val chain = IllegalStateException("a", RuntimeException("b", RuntimeException("c", root)))
assertEquals(FailureCategory.OUT_OF_STORAGE, FailureCategory.of(chain))
}
// --- Sonstige Fehler -----------------------------------------------------------------
@Test
fun gewoehnliche_fehler_sind_other() {
assertEquals(FailureCategory.OTHER, FailureCategory.of(FileNotFoundException("weg")))
assertEquals(FailureCategory.OTHER, FailureCategory.of(IllegalStateException()))
assertEquals(FailureCategory.OTHER, FailureCategory.of(RuntimeException(null as String?)))
}
@Test
fun zyklische_ursachenkette_fuehrt_nicht_zur_endlosschleife() {
val a = RuntimeException("a")
val b = RuntimeException("b", a)
a.initCause(b)
assertEquals(FailureCategory.OTHER, FailureCategory.of(a))
}
// --- runCatchingCancellable ----------------------------------------------------------
@Test
fun runCatchingCancellable_liefert_erfolg_und_fehler_als_result() {
assertEquals(42, runCatchingCancellable { 42 }.getOrNull())
val failure = runCatchingCancellable<Int> { throw IOException("x") }
assertTrue(failure.exceptionOrNull() is IOException)
}
@Test
fun runCatchingCancellable_reicht_coroutine_abbruch_weiter() {
try {
runCatchingCancellable<Unit> { throw CancellationException("abgebrochen") }
fail("CancellationException darf nicht verschluckt werden")
} catch (expected: CancellationException) {
assertEquals("abgebrochen", expected.message)
}
}
}
@@ -1,80 +0,0 @@
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)
}
}
@@ -1,81 +0,0 @@
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
}
}
}
@@ -1,106 +0,0 @@
package de.medapp.wunddoku.pdf
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Reine JVM-Tests für die Fehlermeldungen des PDF-Bereichs: jede Fehlerart hat eine
* verständliche deutsche Meldung, und technische Details (Pfade, Exception-Texte - können
* Patientennamen enthalten) gelangen nie in die Anzeige.
*/
class ErrorMessagesTest {
private val sensitive = "/data/user/0/de.medapp.wunddoku/files/pdf/2026-08-19_Max_Mustermann_1980-03-14.pdf"
private val exportErrors: List<PdfExportError> = listOf(
PdfExportError.DocumentationNotFound,
PdfExportError.DirectoryCreationFailed(sensitive),
PdfExportError.WriteFailed(sensitive),
PdfExportError.Unexpected(sensitive),
PdfExportError.OutOfMemory,
PdfExportError.InsufficientStorage,
)
private val openFailures: List<PdfOpenResult.Failure> = listOf(
PdfOpenResult.Failure.FileNotFound,
PdfOpenResult.Failure.UriResolutionFailed(sensitive),
PdfOpenResult.Failure.NoViewerAvailable,
PdfOpenResult.Failure.Unexpected(sensitive),
)
private val shareFailures: List<PdfShareResult.Failure> = listOf(
PdfShareResult.Failure.UriResolutionFailed(sensitive),
PdfShareResult.Failure.NoAppAvailable,
PdfShareResult.Failure.Unexpected(sensitive),
)
private val imageShareFailures: List<PdfImageShareResult.Failure> = listOf(
PdfImageShareResult.Failure.NoImages,
PdfImageShareResult.Failure.UriResolutionFailed(sensitive),
PdfImageShareResult.Failure.NoAppAvailable,
PdfImageShareResult.Failure.Unexpected(sensitive),
)
private val imageConversionFailures: List<PdfToImagesResult.Failure> = listOf(
PdfToImagesResult.Failure.FileNotFound,
PdfToImagesResult.Failure.EmptyDocument,
PdfToImagesResult.Failure.Unexpected(sensitive),
PdfToImagesResult.Failure.OutOfMemory,
PdfToImagesResult.Failure.InsufficientStorage,
)
private fun assertSafe(message: String) {
assertTrue("Meldung darf nicht leer sein", message.isNotBlank())
assertFalse("Meldung enthält Pfad/Patientendaten: $message", message.contains("Mustermann"))
assertFalse("Meldung enthält Pfad: $message", message.contains("/data/"))
assertFalse("Meldung enthält Dateiendung/Pfad: $message", message.contains(".pdf"))
}
@Test
fun alle_pdf_export_fehler_haben_eine_sichere_meldung() {
exportErrors.forEach { assertSafe(it.toUserMessage()) }
}
@Test
fun alle_oeffnen_fehler_haben_eine_sichere_meldung() {
openFailures.forEach { assertSafe(it.toUserMessage()) }
}
@Test
fun alle_pdf_teilen_fehler_haben_eine_sichere_meldung() {
shareFailures.forEach { assertSafe(it.toUserMessage()) }
}
@Test
fun alle_bilder_teilen_fehler_haben_eine_sichere_meldung() {
imageShareFailures.forEach { assertSafe(it.toUserMessage()) }
}
@Test
fun alle_bildkonvertierungs_fehler_haben_eine_sichere_meldung() {
imageConversionFailures.forEach { assertSafe(it.toUserMessage()) }
}
@Test
fun speichermangel_hat_eine_eigene_aussagekraeftige_meldung() {
assertNotEquals(PdfExportError.OutOfMemory.toUserMessage(), PdfExportError.InsufficientStorage.toUserMessage())
assertNotEquals(
PdfExportError.OutOfMemory.toUserMessage(),
PdfExportError.Unexpected("x").toUserMessage(),
)
assertNotEquals(
PdfToImagesResult.Failure.OutOfMemory.toUserMessage(),
PdfToImagesResult.Failure.InsufficientStorage.toUserMessage(),
)
assertTrue(PdfExportError.InsufficientStorage.toUserMessage().contains("Speicher"))
assertTrue(PdfExportError.OutOfMemory.toUserMessage().contains("Arbeitsspeicher"))
}
@Test
fun unterschiedliche_fehlerarten_erhalten_unterschiedliche_meldungen() {
val messages = exportErrors.map { it.toUserMessage() }
assertTrue(messages.toSet().size == messages.size)
}
}
@@ -1,157 +0,0 @@
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())
}
}
@@ -1,219 +0,0 @@
package de.medapp.wunddoku.privacy
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
/**
* Datenschutz-Regressionstests (QA-006) auf Quelltext- und Konfigurationsebene.
*
* Läuft als reiner JVM-Test ohne Gerät und liest die Projektdateien direkt. Er soll verhindern,
* dass später versehentlich etwas eingebaut wird, das die Datenschutz-Zusagen der App bricht
* (Netzwerkzugriff, sensible Logs, öffentlicher Speicher, Backup, zu weite Freigaben).
* Schlägt ein Test fehl, ist das ein Hinweis zum Prüfen - nicht zwingend ein Fehler: dann
* bewusst entscheiden und die Datenschutz-Dokumentation anpassen.
*/
class PrivacySourceScanTest {
private lateinit var mainDir: File
@Before
fun locateSources() {
mainDir = listOf(File("src/main"), File("app/src/main"))
.firstOrNull { File(it, "AndroidManifest.xml").exists() }
?: File("")
assumeTrue("Quellverzeichnis nicht gefunden (Arbeitsverzeichnis: ${File(".").absolutePath})", mainDir.path.isNotEmpty())
}
// --- Hilfsfunktionen -----------------------------------------------------------------
private fun kotlinSources(): List<Pair<String, String>> =
File(mainDir, "java").walkTopDown()
.filter { it.isFile && it.extension == "kt" }
.map { it.relativeTo(mainDir).path to stripComments(it.readText()) }
.toList()
/** Entfernt Kommentare, damit Dokumentationstext keine Fehlalarme auslöst. */
private fun stripComments(source: String): String =
source
.replace(Regex("/\\*[\\s\\S]*?\\*/"), "")
.replace(Regex("//[^\\n]*"), "")
private fun findings(pattern: Regex, sources: List<Pair<String, String>> = kotlinSources()): List<String> =
sources.flatMap { (path, text) ->
pattern.findAll(text).map { "$path: ${it.value}" }.toList()
}
private fun assertNone(description: String, pattern: Regex) {
val found = findings(pattern)
assertTrue("$description - gefunden: $found", found.isEmpty())
}
private fun parseXml(relativePath: String) =
DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(File(mainDir, relativePath))
// --- Logging -------------------------------------------------------------------------
@Test
fun keine_konsolenausgaben_oder_stacktrace_ausgaben() {
assertNone(
"println/printStackTrace/System.out/Timber können Patientendaten in Logs schreiben",
Regex("""\bprintln\s*\(|printStackTrace\s*\(|System\.(out|err)\b|\bTimber\b"""),
)
}
@Test
fun log_aufrufe_enthalten_keine_nachrichten_pfade_uris_oder_personendaten() {
val callStart = Regex("""\bLog\s*\.\s*(v|d|i|w|e|wtf)\s*\(""")
val forbidden = Regex(
"""(?i)\.message\b|localizedMessage|stackTraceToString|getStackTraceString|\buri\b|\bpath\b|absolutePath|fileName|patient|firstName|lastName|birthDate|signer|street|phone"""
)
val violations = mutableListOf<String>()
for ((path, text) in kotlinSources()) {
for (match in callStart.findAll(text)) {
val openIndex = match.range.last
var depth = 0
var end = openIndex
for (i in openIndex until text.length) {
when (text[i]) {
'(' -> depth++
')' -> { depth--; if (depth == 0) { end = i; break } }
}
}
val call = text.substring(match.range.first, end + 1)
if (forbidden.containsMatchIn(call)) violations += "$path: ${call.replace(Regex("\\s+"), " ")}"
}
}
assertTrue("Log-Aufrufe mit potenziell sensiblem Inhalt: $violations", violations.isEmpty())
}
// --- Speicherorte --------------------------------------------------------------------
@Test
fun kein_zugriff_auf_oeffentlichen_oder_externen_speicher() {
assertNone(
"Patientendaten dürfen nur im privaten internen Speicher liegen",
Regex(
"""getExternalFilesDir|getExternalFilesDirs|externalCacheDir|externalMediaDirs|getExternalStorage|Environment\.getExternal|MediaStore\.|WRITE_EXTERNAL_STORAGE|READ_EXTERNAL_STORAGE|MANAGE_EXTERNAL_STORAGE|MODE_WORLD_READABLE|MODE_WORLD_WRITEABLE|DIRECTORY_(PICTURES|DOWNLOADS|DOCUMENTS)"""
),
)
}
@Test
fun keine_nutzung_der_zwischenablage() {
assertNone(
"Die Zwischenablage ist für andere Apps lesbar",
Regex("""ClipboardManager|setPrimaryClip|ClipboardManager"""),
)
}
@Test
fun keine_personendaten_als_text_oder_betreff_in_share_intents() {
assertNone(
"Share-Intents dürfen nur die PDF/Bilder (EXTRA_STREAM), keinen Text mit Patientendaten tragen",
Regex("""EXTRA_TEXT|EXTRA_SUBJECT|EXTRA_EMAIL|EXTRA_CC|EXTRA_BCC|EXTRA_HTML_TEXT"""),
)
}
// --- Netzwerk ------------------------------------------------------------------------
@Test
fun kein_netzwerkcode_im_quelltext() {
assertNone(
"Die App arbeitet offline - kein Netzwerkzugriff",
Regex("""HttpURLConnection|HttpsURLConnection|java\.net\.URL\b|URLConnection|\bSocket\s*\(|okhttp|retrofit|\bktor\b|WebView|firebase|crashlytics|analytics""", RegexOption.IGNORE_CASE),
)
}
@Test
fun keine_netzwerk_bibliotheken_in_den_abhaengigkeiten() {
val gradle = listOf(File("app/build.gradle.kts"), File("build.gradle.kts")).firstOrNull { it.exists() }
assumeTrue("build.gradle.kts nicht gefunden", gradle != null)
val dependencies = gradle!!.readLines()
.filter { it.trim().startsWith("implementation") || it.trim().startsWith("api(") }
val forbidden = Regex("""okhttp|retrofit|\bktor\b|volley|firebase|crashlytics|analytics|sentry|coil|glide|picasso|play-services""", RegexOption.IGNORE_CASE)
val found = dependencies.filter { forbidden.containsMatchIn(it) }
assertTrue("Abhängigkeiten mit Netzwerk-/Tracking-Bezug: $found", found.isEmpty())
}
// --- Manifest ------------------------------------------------------------------------
private fun manifest(): String = File(mainDir, "AndroidManifest.xml").readText()
@Test
fun manifest_fordert_keine_berechtigungen_an() {
val permissions = Regex("""<uses-permission[^>]*>""").findAll(manifest()).map { it.value }.toList()
val requested = permissions.filterNot { it.contains("tools:node=\"remove\"") }
assertTrue("Unnötige Berechtigungen im Manifest: $requested", requested.isEmpty())
}
@Test
fun manifest_entfernt_die_internet_berechtigung_explizit() {
val internet = Regex("""<uses-permission[^>]*android\.permission\.INTERNET[^>]*>""").find(manifest())
assertTrue("INTERNET muss per tools:node=\"remove\" ausgeschlossen sein", internet != null && internet.value.contains("tools:node=\"remove\""))
}
@Test
fun manifest_deaktiviert_backup_und_verlangt_keine_cleartext_oder_debug_freigaben() {
val text = manifest()
assertTrue("allowBackup muss false sein", text.contains("""android:allowBackup="false""""))
assertTrue("dataExtractionRules müssen gesetzt sein", text.contains("android:dataExtractionRules="))
assertTrue(!text.contains("""android:usesCleartextTraffic="true""""))
assertTrue(!text.contains("android:debuggable"))
assertTrue(!text.contains("""android:allowBackup="true""""))
}
@Test
fun nur_die_start_activity_ist_exportiert_und_der_fileprovider_nicht() {
val text = manifest()
assertEquals(
"Genau eine Komponente (MainActivity) darf exportiert sein",
1,
Regex("""android:exported="true"""").findAll(text).count(),
)
val provider = Regex("""<provider[\s\S]*?</provider>""").find(text)!!.value
assertTrue(provider.contains("""android:exported="false""""))
assertTrue(provider.contains("""android:grantUriPermissions="true""""))
}
// --- Backup-Regeln -------------------------------------------------------------------
@Test
fun data_extraction_rules_schliessen_alle_daten_aus_cloud_backup_und_geraetetransfer_aus() {
val doc = parseXml("res/xml/data_extraction_rules.xml")
for (section in listOf("cloud-backup", "device-transfer")) {
val nodes = doc.getElementsByTagName(section)
assertEquals("<$section> muss vorhanden sein", 1, nodes.length)
val element = nodes.item(0) as org.w3c.dom.Element
assertEquals("<$section> darf kein <include> enthalten", 0, element.getElementsByTagName("include").length)
val excluded = (0 until element.getElementsByTagName("exclude").length)
.map { (element.getElementsByTagName("exclude").item(it) as org.w3c.dom.Element).getAttribute("domain") }
.toSet()
assertTrue("<$section> muss database, file, sharedpref ausschließen, war $excluded", excluded.containsAll(setOf("database", "file", "sharedpref")))
}
}
// --- FileProvider-Freigaben ----------------------------------------------------------
@Test
fun fileprovider_gibt_nur_export_und_kameraordner_frei() {
val doc = parseXml("res/xml/file_paths.xml")
val paths = doc.getElementsByTagName("paths").item(0).childNodes
val entries = (0 until paths.length).map { paths.item(it) }
.filterIsInstance<org.w3c.dom.Element>()
.map { it.tagName to it.getAttribute("path") }
val allowed = setOf("files-path" to "pdf/", "files-path" to "pdf_images/", "cache-path" to "camera/")
val unexpected = entries.filterNot { it in allowed }
assertTrue(
"Zu weite FileProvider-Freigaben (nur $allowed erlaubt): $unexpected",
unexpected.isEmpty(),
)
}
}
-212
View File
@@ -1,212 +0,0 @@
# VulneraDoc – Datenschutz- und Datensicherheits-Dokumentation
Stand: Datenschutz-Review QA-006 (Projektstand `dev-QA004` + Maßnahmen aus diesem Review)
> **Hinweis:** Dieses Dokument beschreibt die *technische* Umsetzung des Datenschutzes in der App
> (Datenflüsse, Speicherorte, Schutzmaßnahmen). Es ist keine Rechtsberatung. Die rechtliche
> Bewertung (Rechtsgrundlage, Verzeichnis der Verarbeitungstätigkeiten, ggf. Datenschutz-
> Folgenabschätzung, Auftragsverarbeitung, Einwilligungen) muss durch die verantwortliche Stelle
> bzw. deren Datenschutzbeauftragte erfolgen. Die Abschnitte 9 und 10 liefern dafür die
> technischen Eingangsdaten.
---
## 1. Zweck und Einordnung
VulneraDoc ist eine **Offline-Android-App zur Wunddokumentation**. Pflegekräfte erfassen Patienten,
dokumentieren Wunden (Checklisten, Freitext, Fotos, Unterschriften) und erzeugen daraus ein PDF,
das sie öffnen oder teilen können.
Verarbeitet werden **Gesundheitsdaten** (Wundbefunde, Wundfotos) und weitere personenbezogene Daten.
Gesundheitsdaten zählen nach Art. 9 DSGVO zu den besonderen Kategorien personenbezogener Daten und
verlangen ein erhöhtes Schutzniveau.
**Grundprinzip der App:** Die Daten verlassen das Gerät **ausschließlich auf ausdrückliche
Handlung des Nutzers** (PDF/Bilder öffnen oder teilen). Es gibt keinen Server, keine Cloud, kein
Konto, keine Analyse- oder Absturzberichts-Dienste.
---
## 2. Verarbeitete Daten
| Datenart | Felder / Inhalt | Speicherort (App-intern) |
|---|---|---|
| Patientenstammdaten | Vorname, Nachname, Geburtsdatum, Straße, PLZ, Ort, bis zu 3 Telefonnummern | Datenbank `wunddoku.db` |
| Dokumentation | Erstelldatum, Checklisten-Zustände, Freitexte (Wundbefund, Maßnahmen) | Datenbank |
| Wundfotos | Fotos aus Kamera bzw. Photo Picker | `files/photos/<Dokumentations-ID>/` |
| Unterschriften | Name des Unterzeichners, Unterschrift als PNG | Datenbank + `files/signatures/<Dokumentations-ID>/` |
| Wundversorger-Register | Name und Unterschrift der Pflegekraft (für Wiederverwendung) | Datenbank + `files/wound_carer_signatures/` |
| PDF-Export | Vollständiger Bericht inkl. Stammdaten, Befund, Fotos, Unterschriften | `files/pdf/<Dokumentations-ID>/` |
| Bildexport | Seiten des PDFs als JPEG (zum Teilen als Bilder) | `files/pdf_images/<Dokumentations-ID>/` |
| Kameraaufnahme (temporär) | Foto bis zum Import in die Dokumentation | `cache/camera/` |
Alle Speicherorte liegen im **privaten internen App-Speicher**. Es wird kein öffentlicher
Speicher (Galerie, Downloads, SD-Karte) beschrieben.
**Hinweis Dateiname:** Das PDF heißt `Erstelldatum_Vorname_Nachname_Geburtsdatum.pdf` und enthält
damit personenbezogene Daten. Der Name wird beim Teilen/Öffnen an die gewählte Ziel-App übergeben
(siehe Abschnitt 6, Restrisiko R4).
---
## 3. Berechtigungen
Die App fordert **keine einzige Berechtigung** an.
| Funktion | Technik | Berechtigung nötig? |
|---|---|---|
| Fotos auswählen | System-Photo-Picker | Nein |
| Foto aufnehmen | System-Kamera-App über `ACTION_IMAGE_CAPTURE` (die App greift nicht selbst auf die Kamera zu) | Nein (`CAMERA` nicht deklariert) |
| PDF speichern | Privater App-Speicher | Nein |
| PDF öffnen/teilen | `FileProvider` + Android-Sharesheet | Nein |
| Netzwerk | nicht vorhanden | `INTERNET` ist im Manifest **ausdrücklich entfernt** (`tools:node="remove"`) |
Durchsetzung: Das Manifest entfernt `INTERNET` und `ACCESS_NETWORK_STATE` beim Zusammenführen mit
Bibliotheks-Manifesten. Der Test `PrivacyConfigurationTest` prüft das fertig zusammengeführte
Manifest auf dem Gerät.
---
## 4. Datenflüsse nach außen
Daten verlassen das Gerät nur in diesen Fällen – jeweils durch eine bewusste Aktion des Nutzers:
1. **PDF öffnen** – Übergabe der PDF per `content://`-URI mit *Lese*recht an die gewählte PDF-App.
2. **PDF teilen / Bilder teilen** – Übergabe per Android-Sharesheet mit *Lese*recht, nur
`EXTRA_STREAM` (die Datei). Es wird **kein** Text, Betreff oder Empfänger mit Patientendaten
vorbelegt.
3. **Fotoimport** – Fotos werden *in* die App kopiert (Photo Picker / Kamera); die Originale
verbleiben dort, wo sie der Nutzer gespeichert hat. Beim Import wird das Foto neu als JPEG
kodiert; Metadaten des Originals (z. B. GPS-Position) werden dadurch **nicht** übernommen.
Nicht vorhanden: Netzwerkzugriff, Cloud-Backup, Geräte-zu-Geräte-Transfer, Analyse/Tracking,
Absturzberichte, Werbung, Zwischenablage, Weitergabe an Dritte durch die App selbst.
**Verantwortung nach dem Teilen:** Sobald eine PDF an eine andere App übergeben wurde, liegt der
weitere Umgang außerhalb der Kontrolle von VulneraDoc (z. B. E-Mail-Programm, Messenger,
Cloud-Speicher). Das ist organisatorisch zu regeln (Abschnitt 9).
---
## 5. Technische und organisatorische Schutzmaßnahmen in der App
### 5.1 Speicherung
- Ausschließlich privater interner Speicher (`filesDir`, `cacheDir`); kein `getExternalFilesDir`,
keine `MediaStore`-Nutzung, keine `MODE_WORLD_*`-Dateien.
- Dateien sind für andere Apps nicht lesbar (Android-App-Sandbox, Dateirechte).
- **Pfadsicherheit:** Datei-/Ordnernamen werden über `SafePaths` aufgelöst (kein `../`, keine
Pfadtrenner, keine Symlink-Ausbrüche).
- **Atomares Schreiben der PDF:** Erst temporäre Datei, dann Umbenennen – kein halbfertiges PDF,
eine vorhandene PDF geht bei einem Fehler nicht verloren.
### 5.2 Freigabe über FileProvider (Prinzip der minimalen Freigabe)
Nur diese Ordner können nach außen gegeben werden:
`files/pdf/`, `files/pdf_images/` (Exporte) und `cache/camera/` (Kameraaufnahme).
**Nicht** freigegeben: Originalfotos (`photos/`), Unterschriften (`signatures/`), Datenbank,
Wundversorger-Register. Der `FileProvider` ist nicht exportiert (`exported="false"`), Rechte werden
nur pro Vorgang als Leserecht vergeben.
### 5.3 Backup
- `android:allowBackup="false"` – kein Cloud-/Auto-Backup.
- `data_extraction_rules.xml` schließt für `cloud-backup` **und** `device-transfer` die Bereiche
`database`, `file` und `sharedpref` aus; es gibt kein `<include>`.
- Folge: Beim Gerätewechsel oder nach Deinstallation sind die Daten **nicht** automatisch
wiederherstellbar. Das ist gewollt (Datenschutz), muss aber organisatorisch bekannt sein
(z. B. PDFs als Archiv in der Einrichtungsdokumentation ablegen).
### 5.4 Oberfläche
- `FLAG_SECURE` im Hauptfenster: keine Screenshots, keine Bildschirmaufnahme, keine Vorschau im
App-Wechsler.
### 5.5 Protokollierung (Logs)
- Es gibt keine Konsolenausgaben und keinen `printStackTrace`-Aufruf.
- Es existiert genau ein Log-Aufruf (Fehler beim Fotoimport). Er schreibt **nur den Typ** der
Exception, nie die Fehlermeldung (diese kann Dateipfade oder URIs enthalten), nie Namen oder
Patientendaten.
- Fehlermeldungen in der Oberfläche sind allgemein formuliert und enthalten keine Pfade oder
Exception-Texte.
### 5.6 Löschkonzept
| Auslöser | Was wird gelöscht |
|---|---|
| Dokumentation löschen | Datenbankzeilen (inkl. Checklisten, Foto- und Signatur-Einträge), Fotos, Signaturen **und** zugehörige PDF- und Bildexporte |
| Einzelnes Foto / einzelne Signatur löschen | Datenbankzeile und Datei |
| Patient löschen | Nur möglich, wenn keine Dokumentation mehr existiert |
| Automatisch beim App-Start | PDF-/Bildexporte älter als **7 Tage**; liegengebliebene Kameraaufnahmen älter als **24 Stunden** |
| Deinstallation der App | Alle App-Daten (Android entfernt den privaten Speicher) |
Die automatisch gelöschten Exporte lassen sich jederzeit aus der Dokumentation neu erzeugen.
Die Fristen stehen als Konstanten in `InternalExportedFilesCleaner` und können angepasst werden.
### 5.7 Fehlerbehandlung als Datenschutz-/Integritätsmaßnahme
Fehlerfälle (Speicher voll, defekte Bilder, PDF-Fehler) führen nicht zu Datenverlust oder zu
verwaisten Dateien (siehe Tests aus QA-004): fehlgeschlagene Importe hinterlassen keine
Halbdateien, Datenbank und Dateien bleiben konsistent, Autosave-Fehler werden dem Nutzer gemeldet.
---
## 6. Bekannte Restrisiken und Empfehlungen
| Nr. | Restrisiko | Bewertung | Empfehlung |
|---|---|---|---|
| R1 | **Datenbank und Dateien sind nicht zusätzlich verschlüsselt** (nur Android-Sandbox + Gerätespeicher-Verschlüsselung). Auf gerooteten Geräten oder bei kompromittiertem Gerät sind Daten lesbar. | mittel | Gerät mit Bildschirmsperre und aktivierter Speicherverschlüsselung betreiben (Standard seit Android 10). Bei erhöhtem Schutzbedarf Datenbankverschlüsselung (z. B. SQLCipher mit Schlüssel im Android Keystore) einplanen. |
| R2 | **Keine App-eigene Sperre** (PIN/Biometrie). Wer das entsperrte Gerät hat, sieht die Daten. | mittel | Gerätesperre verbindlich vorschreiben; optional App-Sperre mit `BiometricPrompt` einführen. |
| R3 | **Tastatur-Lernfunktion/Autofill:** Eingaben (Namen, Befund-Freitexte) können von der Bildschirmtastatur oder einem Autofill-Dienst mitgelernt werden. | gering–mittel | Geräte-Richtlinie: nur vertrauenswürdige Tastatur, Personalisierung/Cloud-Funktionen der Tastatur deaktivieren. |
| R4 | **Dateiname des PDFs enthält Name und Geburtsdatum** und ist in Ziel-Apps (Mailanhang, Messenger) sichtbar. | gering–mittel | Fachliche Entscheidung: neutralen Dateinamen (z. B. nur Dokumentations-ID/Datum) anbieten. |
| R5 | **Weitergabe über andere Apps** liegt nach dem Teilen außerhalb der App-Kontrolle. | organisatorisch | Zulässige Übertragungswege festlegen (z. B. nur dienstliches Postfach/Messenger); Mitarbeiter schulen. |
| R6 | **Release-Build ohne Code-Verkleinerung/Obfuskation** (`isMinifyEnabled = false`). | gering | Für die Veröffentlichung R8 aktivieren (bessere Härtung gegen Reverse Engineering; kein Ersatz für Datenschutzmaßnahmen). |
| R7 | **Kein automatischer Abgleich/Backup**: Geräteverlust oder Defekt = Datenverlust. | Verfügbarkeit | Organisatorisches Archiv (z. B. PDFs in die Patientenakte der Einrichtung). |
| R8 | **Kamera-App des Herstellers** speichert Aufnahmen evtl. zusätzlich in der Galerie/Cloud (bei `ACTION_IMAGE_CAPTURE` mit Ziel-URI normalerweise nicht, herstellerabhängig). | gering | Auf Testgeräten prüfen, ob Aufnahmen zusätzlich in der Galerie erscheinen. |
---
## 7. Automatisierte Datenschutz-Tests
| Test | Art | Prüft |
|---|---|---|
| `PrivacySourceScanTest` | JVM | Keine Netzwerk-/Tracking-Bibliotheken, keine `println`/`printStackTrace`, Log-Aufrufe ohne Nachricht/Pfad/Personendaten, kein externer Speicher, keine Zwischenablage, keine Texte in Share-Intents, Manifest ohne Berechtigungen, `allowBackup=false`, nur eine exportierte Komponente, Backup-Regeln, FileProvider-Freigaben |
| `SafePathsTest` | JVM | Schutz vor Path-Traversal |
| `ExportedFilesCleanerTest` | JVM | Löschen der Exporte, Aufbewahrungsfristen, kein Löschen außerhalb der Exportordner |
| `PrivacyConfigurationTest` | Gerät/Emulator | Zusammengeführtes Manifest (Berechtigungen, exportierte Komponenten), Backup, FileProvider-Freigaben, private Dateirechte, `FLAG_SECURE` |
| `DocumentationDeletionPrivacyTest` | Gerät/Emulator | Löschen einer Dokumentation entfernt auch die Exporte |
Diese Tests dienen als Regressionsschutz: Sie schlagen an, wenn künftig z. B. eine Berechtigung,
eine Netzwerkbibliothek oder eine zu weite Freigabe hinzukommt.
---
## 8. Checkliste für Releases
- [ ] Alle Tests grün (insbesondere die Datenschutz-Tests aus Abschnitt 7)
- [ ] Merged Manifest geprüft (Android Studio → `AndroidManifest.xml` → Tab *Merged Manifest*): keine neuen Berechtigungen/exportierten Komponenten
- [ ] Neue Abhängigkeiten auf Netzwerk-/Tracking-Funktionen geprüft
- [ ] Neue Log-Ausgaben enthalten keine Patientendaten, Pfade, URIs oder Exception-Nachrichten
- [ ] Neue Speicherorte liegen im privaten Speicher; neue Freigaben sind in `file_paths.xml` minimal
- [ ] Neue Datenfelder sind in Abschnitt 2 dieses Dokuments ergänzt und im Löschkonzept berücksichtigt
- [ ] Release-Build signiert, nicht debuggable
---
## 9. Organisatorische Hinweise (für die verantwortliche Stelle)
Diese Punkte kann die App nicht technisch lösen:
- **Rechtsgrundlage** der Verarbeitung (Behandlungsvertrag / Dokumentationspflicht, Art. 9 Abs. 2
lit. h DSGVO) und **Verzeichnis der Verarbeitungstätigkeiten** durch die verantwortliche Stelle
- Prüfung, ob eine **Datenschutz-Folgenabschätzung** (Art. 35 DSGVO) erforderlich ist
(Gesundheitsdaten, ggf. in größerem Umfang)
- **Geräterichtlinie:** Bildschirmsperre, Geräteverschlüsselung, Updates, ggf. MDM, Regelung
privater vs. dienstlicher Geräte
- **Übertragungswege** für das Teilen von PDFs, Aufbewahrungs- und Löschfristen im Archiv
- **Betroffenenrechte** (Auskunft, Berichtigung, Löschung): Löschung ist je Dokumentation in der
App möglich; Auskunft über den PDF-Export
- Schulung der Anwender, Meldeweg bei Geräteverlust (Meldepflicht bei Datenpannen, Art. 33 DSGVO)
## 10. Technische Eckdaten für Datenschutzdokumente
- Verarbeitung ausschließlich lokal auf dem Gerät; keine Empfänger durch die App
- Keine Auftragsverarbeiter durch die App (kein Server, keine Cloud, keine Analyse-/Crash-Dienste)
- Keine Datenübermittlung in Drittländer durch die App
- Keine Cookies, kein Tracking, keine Werbe-ID
- Speicherdauer: bis zur Löschung durch den Nutzer; Exporte automatisch nach 7 Tagen
- Zugriffsschutz: Android-App-Sandbox, Gerätesperre; `FLAG_SECURE`
-50
View File
@@ -1,50 +0,0 @@
# QA-006 – Datenschutz-Review: Befunde und Maßnahmen
Geprüft wurde der Projektstand `dev-QA004` (Manifest, Backup-Regeln, FileProvider, Quelltext,
Abhängigkeiten, Speicher-, Lösch- und Exportabläufe). Die App wurde **nicht** auf einem Gerät
dynamisch untersucht (kein Netzwerk-Mitschnitt, kein Test der Hersteller-Kamera-App).
## Bereits gut umgesetzt (keine Änderung nötig)
| Prüfpunkt | Ergebnis |
|---|---|
| Berechtigungen | Keine deklariert; Photo Picker und System-Kamera-App statt eigener Kamera-/Speicherzugriffe |
| Netzwerk | Kein Netzwerkcode, keine Netzwerk-/Tracking-Bibliotheken |
| Backup | `allowBackup=false` und `data_extraction_rules` (Cloud-Backup + Geräte-Transfer) schließen alles aus |
| Speicherort | Nur privater interner Speicher, kein öffentlicher/externer Speicher |
| Bildschirm | `FLAG_SECURE` (keine Screenshots, keine App-Wechsler-Vorschau) |
| Komponenten | Nur `MainActivity` exportiert; `FileProvider` nicht exportiert |
| Share-Intents | Nur Datei (`EXTRA_STREAM`), kein Text/Betreff mit Patientendaten |
| Datenbank | Keine destruktive Migration (kein stiller Datenverlust bei Updates) |
| Metadaten in Fotos | Beim Import neu kodiert → GPS/EXIF des Originals wird nicht übernommen |
| Fehlermeldungen in der UI | Ohne Pfade/Exception-Texte |
## Befunde und umgesetzte Maßnahmen
| Nr. | Befund | Schwere | Maßnahme | Datei(en) |
|---|---|---|---|---|
| B1 | **Log beim Fotoimport schrieb `e.message`.** Bei Dateifehlern enthält die Meldung häufig Pfad bzw. Bild-URI – entgegen dem Kommentar im Code. | mittel | Es wird nur noch der Exception-Typ geloggt. | `DocumentationEditViewModel.kt` |
| B2 | **PDF- und Bildexporte blieben nach dem Löschen einer Dokumentation im Speicher** (enthalten Name, Geburtsdatum, Anschrift, Befund, Fotos). | hoch | Löschen der Dokumentation entfernt jetzt auch `pdf/<id>/` und `pdf_images/<id>/`. | `ExportedFilesCleaner.kt` (neu), `DocumentationRepository.kt`, `AppContainer.kt` |
| B3 | **Exporte hatten keine Aufbewahrungsfrist.** Alte PDFs lagen unbegrenzt im App-Speicher. | mittel | Beim App-Start werden Exporte älter als 7 Tage gelöscht (jederzeit neu erzeugbar). | `ExportedFilesCleaner.kt`, `WunddokuApplication.kt` |
| B4 | **Kameraaufnahmen konnten im Cache liegen bleiben**, wenn die App zwischen Aufnahme und Import beendet wurde. | gering–mittel | Aufnahmen älter als 24 h werden beim App-Start gelöscht (Frist bewusst lang, damit eine noch ausstehende Aufnahme nach einem Neustart importierbar bleibt). | `ExportedFilesCleaner.kt` |
| B5 | **FileProvider gab `photos/` und `signatures/` frei**, obwohl nie darüber geteilt wird. | gering (Prinzip der minimalen Freigabe) | Freigaben auf `pdf/`, `pdf_images/`, `cache/camera/` reduziert. | `file_paths.xml` |
| B6 | **`INTERNET` konnte über eine künftige Bibliothek unbemerkt ins Manifest gelangen.** | gering (Vorsorge) | `INTERNET` und `ACCESS_NETWORK_STATE` werden beim Manifest-Merge explizit entfernt. | `AndroidManifest.xml` |
| B7 | **Pfadauflösung ohne Traversal-Schutz** (`File(dir, name)`). Namen stammen aus der eigenen DB (UUIDs), daher kein akutes Risiko. | gering (Defense-in-Depth) | `SafePaths.resolveChild` in Foto-/Signatur-Speicher und Cleaner. | `SafePaths.kt` (neu), `PhotoStorage.kt`, `SignatureStorage.kt` |
## Offene Punkte (bewusst NICHT geändert – Produktentscheidung nötig)
| Nr. | Thema | Empfehlung |
|---|---|---|
| O1 | Datenbank/Dateien nicht zusätzlich verschlüsselt (Restrisiko R1) | Bei erhöhtem Schutzbedarf SQLCipher + Android Keystore; sonst Geräteverschlüsselung + Sperre vorschreiben |
| O2 | Keine App-Sperre (PIN/Biometrie) (R2) | `BiometricPrompt` beim Start/Wiederöffnen erwägen |
| O3 | PDF-Dateiname enthält Name + Geburtsdatum (R4) | Neutralen Dateinamen als Option; würde `PdfFileNameGenerator` und dessen Tests ändern |
| O4 | Tastatur-Lernfunktion/Autofill (R3) | Geräterichtlinie; in Compose nur eingeschränkt steuerbar |
| O5 | `isMinifyEnabled = false` (R6) | R8 vor Veröffentlichung aktivieren und Release-Build testen |
| O6 | Dynamische Prüfung auf echten Geräten (Netzwerkmitschnitt, Hersteller-Kamera, Galerie-Kopien) | Vor Produktivbetrieb einmal durchführen |
## Wichtig zu den Änderungen B2/B3
B2 und B3 ändern das Verhalten: Exportierte PDFs verschwinden nach 7 Tagen und beim Löschen der
Dokumentation. Wenn die App PDFs länger vorhalten soll, `DEFAULT_EXPORT_MAX_AGE_MILLIS` in
`InternalExportedFilesCleaner` erhöhen. Bereits **geteilte** Kopien in anderen Apps sind davon
natürlich nicht betroffen.