Compare commits

...

6 Commits

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

    Keine unnötigen Berechtigungen
    Keine sensiblen Logs
    Sichere Dateispeicherung
    Backup-Verhalten geprüft
    Datenschutz-Dokumentation erstellt
    usw.
2026-10-15 17:40:35 +02:00
niklas.ortmann 742ef39f05 Merge pull request 'Fehlerbehandlung' (#19) from dev-QA004 into dev
Reviewed-on: #19
2026-10-14 17:14:48 +02:00
31 changed files with 1785 additions and 111 deletions
+6 -6
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
abgeschlossenen Dokumentationen PDF-Dateien.
> **Projektstatus:** beta1.1
> **Projektstatus:** beta1.4
------------------------------------------------------------------------
@@ -398,12 +398,12 @@ werden kann.
### Phase 6 -- Qualität und Stabilität
- [ ] Unit Tests
- [ ] Room Tests
- [ ] UI Tests
- [ ] Fehlerbehandlung
- [x] Unit Tests
- [x] Room Tests
- [x] UI Tests
- [x] Fehlerbehandlung
- [ ] Tablet-Optimierung
- [ ] Datenschutz-Review
- [x] Datenschutz-Review
### Phase 7 -- Multi-Device-Synchronisation
+2 -2
View File
@@ -61,8 +61,8 @@ android {
// im gesamten Projekt spürbar.
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
versionCode = 5
versionName = "1.5"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
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": 5,
"versionName": "1.5",
"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,89 @@
package de.medapp.wunddoku.data.repository
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.medapp.wunddoku.pdf.ExportedFilesCleaner
import de.medapp.wunddoku.testutil.TestEnvironment
import de.medapp.wunddoku.testutil.buildRepository
import de.medapp.wunddoku.testutil.runDbTest
import de.medapp.wunddoku.testutil.signatureBitmap
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/**
* Datenschutz-Löschkonzept: Beim Löschen einer Dokumentation müssen ALLE personenbezogenen
* Daten verschwinden - Datenbankzeilen, Fotos, Signaturen und abgeleitete Exporte (PDF/Bilder).
*/
@RunWith(AndroidJUnit4::class)
class DocumentationDeletionPrivacyTest {
private lateinit var env: TestEnvironment
private class RecordingCleaner(private val failure: Throwable? = null) : ExportedFilesCleaner {
val deleted = mutableListOf<String>()
override suspend fun deleteExportsFor(documentationId: String) {
deleted += documentationId
failure?.let { throw it }
}
override suspend fun deleteStaleFiles() = Unit
}
@Before
fun setUp() {
env = TestEnvironment()
}
@After
fun tearDown() {
env.close()
}
private suspend fun createDocumentationWithData(repo: DocumentationRepository): String {
env.insertPatient()
val id = repo.createEmpty("patient-1").id
repo.addPhoto(id, Uri.parse("content://test/1"))
repo.saveSignature(id, "Pfleger Paul", signatureBitmap())
return id
}
@Test
fun loeschen_entfernt_auch_die_exportierten_dateien() = runDbTest {
val cleaner = RecordingCleaner()
val repo = env.buildRepository(exportedFilesCleaner = cleaner)
val id = createDocumentationWithData(repo)
repo.delete(id)
assertEquals(listOf(id), cleaner.deleted)
assertNull(repo.getFullDocumentation(id))
assertTrue(env.photoStorage.deletedAll.contains(id))
assertTrue(env.signatureStorage.deletedAll.contains(id))
}
@Test
fun fehler_beim_loeschen_der_exporte_verhindert_das_loeschen_der_dokumentation_nicht() = runDbTest {
val repo = env.buildRepository(exportedFilesCleaner = RecordingCleaner(IllegalStateException("Test")))
val id = createDocumentationWithData(repo)
repo.delete(id)
assertNull(repo.getFullDocumentation(id))
assertTrue(repo.observeAll().first().isEmpty())
}
@Test
fun loeschen_einer_nicht_vorhandenen_dokumentation_loescht_keine_exporte() = runDbTest {
val cleaner = RecordingCleaner()
val repo = env.buildRepository(exportedFilesCleaner = cleaner)
repo.delete("gibt-es-nicht")
assertTrue(cleaner.deleted.isEmpty())
}
}
@@ -0,0 +1,240 @@
package de.medapp.wunddoku.privacy
import android.content.ComponentName
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.view.WindowManager
import androidx.core.content.FileProvider
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import de.medapp.wunddoku.MainActivity
import de.medapp.wunddoku.R
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.xmlpull.v1.XmlPullParser
import java.io.File
import java.util.UUID
/**
* Datenschutz-Prüfung (QA-006) der ZUSAMMENGEFÜHRTEN, tatsächlich installierten App: Das fertige
* Manifest enthält auch Einträge aus Bibliotheken, die im eigenen Manifest nicht sichtbar sind.
*/
@RunWith(AndroidJUnit4::class)
class PrivacyConfigurationTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val packageName = context.packageName
private val id = "test-${UUID.randomUUID()}"
@Before
fun setUp() {
File(context.filesDir, "photos/$id").mkdirs()
}
@After
fun tearDown() {
listOf("photos", "signatures", "pdf", "pdf_images").forEach {
File(File(context.filesDir, it), id).deleteRecursively()
}
}
@Suppress("DEPRECATION")
private fun packageInfo(flags: Int) = context.packageManager.getPackageInfo(packageName, flags)
// --- Berechtigungen ------------------------------------------------------------------
@Test
fun app_fordert_keine_gefaehrlichen_oder_unnoetigen_berechtigungen_an() {
val requested = packageInfo(PackageManager.GET_PERMISSIONS).requestedPermissions?.toList().orEmpty()
val forbidden = listOf(
"android.permission.INTERNET",
"android.permission.ACCESS_NETWORK_STATE",
"android.permission.CAMERA",
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.WRITE_EXTERNAL_STORAGE",
"android.permission.MANAGE_EXTERNAL_STORAGE",
"android.permission.READ_MEDIA_IMAGES",
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.ACCESS_COARSE_LOCATION",
"android.permission.READ_CONTACTS",
"android.permission.READ_PHONE_STATE",
"android.permission.RECORD_AUDIO",
)
assertEquals("Verbotene Berechtigungen: ${requested.filter { it in forbidden }}", emptyList<String>(), requested.filter { it in forbidden })
}
@Test
fun app_hat_ausser_dem_androidx_interna_keine_weiteren_berechtigungen() {
val requested = packageInfo(PackageManager.GET_PERMISSIONS).requestedPermissions?.toList().orEmpty()
// Die Signatur-Berechtigung von androidx (Schutz dynamischer Receiver) ist unkritisch.
val allowed = setOf("$packageName.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION")
val unexpected = requested.filterNot { it in allowed }
assertTrue("Unerwartete Berechtigungen im zusammengeführten Manifest: $unexpected", unexpected.isEmpty())
}
// --- Backup --------------------------------------------------------------------------
@Test
fun backup_ist_deaktiviert() {
val flags = context.applicationInfo.flags
assertEquals("android:allowBackup muss false sein", 0, flags and ApplicationInfo.FLAG_ALLOW_BACKUP)
}
@Test
fun data_extraction_rules_schliessen_alles_aus() {
val parser = context.resources.getXml(R.xml.data_extraction_rules)
var section: String? = null
val excluded = mutableMapOf<String, MutableSet<String>>()
var includeFound = false
while (parser.next() != XmlPullParser.END_DOCUMENT) {
if (parser.eventType == XmlPullParser.START_TAG) {
when (parser.name) {
"cloud-backup", "device-transfer" -> {
section = parser.name
excluded[parser.name] = mutableSetOf()
}
"exclude" -> {
val domain = parser.getAttributeValue(null, "domain")
val current = section
if (domain != null && current != null) excluded[current]?.add(domain)
}
"include" -> includeFound = true
}
} else if (parser.eventType == XmlPullParser.END_TAG && parser.name == section) {
section = null
}
}
assertFalse("Kein <include> erlaubt", includeFound)
assertEquals(setOf("cloud-backup", "device-transfer"), excluded.keys)
excluded.forEach { (name, domains) ->
assertTrue("$name muss database, file und sharedpref ausschließen, war $domains", domains.containsAll(setOf("database", "file", "sharedpref")))
}
}
// --- Exportierte Komponenten ---------------------------------------------------------
@Test
fun ausser_der_start_activity_sind_keine_eigenen_komponenten_exportiert() {
val info = packageInfo(
PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or
PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS
)
// Debug-Builds enthalten zusätzlich Test-/Tooling-Activities von androidx (nicht im Release).
val toolingPrefixes = listOf("androidx.compose.ui.tooling", "androidx.activity.ComponentActivity", "androidx.compose.ui.test")
val exportedActivities = info.activities.orEmpty().filter { it.exported }.map { it.name }
.filterNot { name -> toolingPrefixes.any { name.startsWith(it) } }
assertEquals(listOf(MainActivity::class.java.name), exportedActivities)
val exportedServices = info.services.orEmpty().filter { it.exported }.map { it.name }
assertTrue("Exportierte Services: $exportedServices", exportedServices.isEmpty())
val exportedProviders = info.providers.orEmpty().filter { it.exported }.map { it.name }
assertTrue("Exportierte ContentProvider: $exportedProviders", exportedProviders.isEmpty())
// androidx.profileinstaller ist per Systemberechtigung (DUMP) geschützt und unkritisch.
val exportedReceivers = info.receivers.orEmpty().filter { it.exported }.map { it.name }
.filterNot { it == "androidx.profileinstaller.ProfileInstallReceiver" }
assertTrue("Exportierte Receiver: $exportedReceivers", exportedReceivers.isEmpty())
}
@Test
fun fileprovider_ist_nicht_exportiert_und_vergibt_nur_gezielte_rechte() {
val provider = context.packageManager.getProviderInfo(
ComponentName(packageName, FileProvider::class.java.name),
PackageManager.GET_META_DATA,
)
assertFalse(provider.exported)
assertTrue(provider.grantUriPermissions)
assertEquals("$packageName.fileprovider", provider.authority)
}
// --- FileProvider-Freigaben (minimale Freigabe) --------------------------------------
private fun uriFor(file: File) = FileProvider.getUriForFile(context, "$packageName.fileprovider", file)
@Test
fun pdf_und_bildexporte_sind_freigegeben() {
val pdf = File(context.filesDir, "pdf/$id/x.pdf").apply { parentFile!!.mkdirs(); writeText("x") }
val image = File(context.filesDir, "pdf_images/$id/s.jpg").apply { parentFile!!.mkdirs(); writeText("x") }
assertTrue(uriFor(pdf).toString().startsWith("content://$packageName.fileprovider/"))
assertTrue(uriFor(image).toString().startsWith("content://$packageName.fileprovider/"))
}
@Test
fun originalfotos_und_signaturen_sind_nicht_ueber_den_fileprovider_freigabefaehig() {
val photo = File(context.filesDir, "photos/$id/foto.jpg").apply { parentFile!!.mkdirs(); writeText("x") }
val signature = File(context.filesDir, "signatures/$id/sig.png").apply { parentFile!!.mkdirs(); writeText("x") }
for (file in listOf(photo, signature)) {
try {
uriFor(file)
fail("${file.parentFile!!.parentFile!!.name}/ darf nicht freigebbar sein")
} catch (expected: IllegalArgumentException) {
// erwartet: nicht in file_paths.xml konfiguriert
}
}
}
@Test
fun datenbank_und_sonstige_interne_dateien_sind_nicht_freigabefaehig() {
val database = context.getDatabasePath("irgendeine.db")
val other = File(context.filesDir, "geheim_$id.txt").apply { writeText("x") }
try {
uriFor(other)
fail("Dateien im Wurzelordner von filesDir dürfen nicht freigebbar sein")
} catch (expected: IllegalArgumentException) {
} finally {
other.delete()
}
try {
uriFor(database)
fail("Die Datenbank darf nie freigebbar sein")
} catch (expected: IllegalArgumentException) {
}
}
// --- Speicherort ---------------------------------------------------------------------
@Test
fun dokumentationsdaten_liegen_im_privaten_internen_speicher_und_sind_nicht_fuer_andere_lesbar() {
val file = File(context.filesDir, "photos/$id/foto.jpg").apply { writeText("x") }
assertTrue(file.canonicalPath.startsWith(context.filesDir.canonicalPath))
val external = context.getExternalFilesDir(null)?.canonicalPath
if (external != null) assertFalse(file.canonicalPath.startsWith(external))
val permissions = java.nio.file.Files.getPosixFilePermissions(file.toPath())
assertFalse("Datei darf nicht 'others'-lesbar sein: $permissions", permissions.contains(java.nio.file.attribute.PosixFilePermission.OTHERS_READ))
assertFalse("Datei darf nicht 'others'-schreibbar sein: $permissions", permissions.contains(java.nio.file.attribute.PosixFilePermission.OTHERS_WRITE))
}
// --- Bildschirmschutz ----------------------------------------------------------------
@Test
fun hauptfenster_verwendet_flag_secure_gegen_screenshots_und_app_switcher_vorschau() {
var flags = 0
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
scenario.onActivity { activity -> flags = activity.window.attributes.flags }
}
assertNotEquals("FLAG_SECURE muss gesetzt sein", 0, flags and WindowManager.LayoutParams.FLAG_SECURE)
}
}
@@ -146,6 +146,7 @@ fun TestEnvironment.buildRepository(
photoDao: PhotoDao = db.photoDao(),
signatureDao: SignatureDao = db.signatureDao(),
documentationDao: DocumentationDao = db.documentationDao(),
exportedFilesCleaner: de.medapp.wunddoku.pdf.ExportedFilesCleaner = de.medapp.wunddoku.pdf.ExportedFilesCleaner.None,
): DocumentationRepository = DocumentationRepository(
documentationDao = documentationDao,
blockStateDao = db.blockStateDao(),
@@ -159,6 +160,7 @@ fun TestEnvironment.buildRepository(
pdfExporter = pdfExporter,
pdfToImagesConverter = pdfToImagesConverter,
db = db,
exportedFilesCleaner = exportedFilesCleaner,
)
/** Erwartet, dass der Suspend-Block eine Exception vom Typ [T] wirft, und liefert sie zurück. */
+8 -1
View File
@@ -1,10 +1,17 @@
<?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
Architektur) benötigt keine READ_MEDIA_IMAGES-Berechtigung, und Fotos/Signaturen/
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
android:name=".WunddokuApplication"
android:allowBackup="false"
@@ -2,14 +2,25 @@ package de.medapp.wunddoku
import android.app.Application
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() {
lateinit var container: AppContainer
private set
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate() {
super.onCreate()
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() }
}
}
@@ -29,6 +29,7 @@ import de.medapp.wunddoku.domain.model.Signature
import de.medapp.wunddoku.media.ImageDownsampler
import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.pdf.DocumentationPdfExporter
import de.medapp.wunddoku.pdf.ExportedFilesCleaner
import de.medapp.wunddoku.pdf.DocumentationToPdfExportModelMapper
import de.medapp.wunddoku.pdf.PdfExportError
import de.medapp.wunddoku.pdf.PdfExportResult
@@ -69,6 +70,7 @@ class DocumentationRepository(
private val pdfExporter: DocumentationPdfExporter,
private val pdfToImagesConverter: PdfToImagesConverter,
private val db: AppDatabase,
private val exportedFilesCleaner: ExportedFilesCleaner = ExportedFilesCleaner.None,
) {
fun observeAll(): Flow<List<DocumentationSummary>> =
@@ -434,6 +436,9 @@ class DocumentationRepository(
// geladen werden.
runCatching { photoStorage.deleteAll(documentationId) }
runCatching { signatureStorage.deleteAll(documentationId) }
// Datenschutz (Löschkonzept): Auch abgeleitete Kopien der Patientendaten - exportierte
// PDFs und deren Seitenbilder - müssen mit der Dokumentation verschwinden.
runCatching { exportedFilesCleaner.deleteExportsFor(documentationId) }
}
/**
@@ -10,6 +10,8 @@ import de.medapp.wunddoku.media.PhotoStorage
import de.medapp.wunddoku.pdf.AndroidPdfDocumentExporter
import de.medapp.wunddoku.pdf.AndroidPdfToImagesConverter
import de.medapp.wunddoku.pdf.DocumentationPdfExporter
import de.medapp.wunddoku.pdf.ExportedFilesCleaner
import de.medapp.wunddoku.pdf.InternalExportedFilesCleaner
import de.medapp.wunddoku.pdf.PdfToImagesConverter
import de.medapp.wunddoku.signature.InternalSignatureStorage
import de.medapp.wunddoku.signature.SignatureStorage
@@ -42,6 +44,11 @@ class AppContainer(context: Context) {
}
private val pdfToImagesConverter: PdfToImagesConverter by lazy { AndroidPdfToImagesConverter(context) }
/** Löscht Exporte gelöschter Dokumentationen sowie veraltete Exporte/Kameraaufnahmen. */
val exportedFilesCleaner: ExportedFilesCleaner by lazy {
InternalExportedFilesCleaner(filesDir = context.filesDir, cacheDir = context.cacheDir)
}
val patientRepository: PatientRepository by lazy {
PatientRepository(patientDao = database.patientDao())
}
@@ -67,6 +74,7 @@ class AppContainer(context: Context) {
pdfExporter = pdfExporter,
pdfToImagesConverter = pdfToImagesConverter,
db = database,
exportedFilesCleaner = exportedFilesCleaner,
)
}
}
@@ -28,11 +28,11 @@ import de.medapp.wunddoku.domain.model.InputSpec
* Abschnitt 2.3b.
*
* [CATALOG_VERSION] wird beim Erstellen einer neuen Dokumentation als rein informatives
* Metadatum in Documentation.catalogVersion übernommen. Auf 5 erhöht (Decubitus als einfache Checkbox, Umbau "Empfohlene Wundversorgung").
* Metadatum in Documentation.catalogVersion übernommen. Auf 6 erhöht (Wundart/Wundgrund geteilt, Wundrandschutz neu geordnet, Granudacyn und Freitext entkoppelt).
*/
object DocumentationCatalog {
const val CATALOG_VERSION: Int = 5
const val CATALOG_VERSION: Int = 6
/**
* Label der automatisch an jeden Block angehängten Freitext-Option. Zentral hier definiert,
@@ -65,30 +65,31 @@ object DocumentationCatalog {
CheckItemDefinition(id = "wundart_ulcus_cruris_arteriosum", label = "Ulcus Cruris art.", order = 6),
CheckItemDefinition(id = "wundart_ulcus_cruris_venosum_arteriosum", label = "Ulcus Cruris ven.-art.", order = 7),
CheckItemDefinition(id = "wundart_diabetisches_fusssyndrom", label = "Diab. Fuß Syndrom", order = 8),
CheckItemDefinition(id = "wundart_post_operative_wundheilungsstoerung", label = "Post-operative Wundheilungsstörung / Dehiszenz", order = 9),
CheckItemDefinition(id = "wundart_schuerfwunde", label = "Schürfwunde", order = 10),
CheckItemDefinition(id = "wundart_intertrigo", label = "Intertrigo / Wunde nach Intertrigo", order = 11),
CheckItemDefinition(id = "wundart_iad", label = "IAD (Inkontinenz-assoziierte Dermatitis) / Wunde nach IAD", order = 12),
CheckItemDefinition(id = "wundart_amputationswunde", label = "Amputationswunde", order = 13),
CheckItemDefinition(id = "wundart_spalthautentnahmestelle", label = "Spalthautentnahmestelle", order = 14),
CheckItemDefinition(id = "wundart_post_operative_wundheilungsstoerung", label = "Post-operative Wundheilungsstörung", order = 9),
CheckItemDefinition(id = "wundart_dehiszenz", label = "Dehiszenz", order = 10),
CheckItemDefinition(id = "wundart_schuerfwunde", label = "Schürfwunde", order = 11),
CheckItemDefinition(id = "wundart_intertrigo", label = "Intertrigo / Wunde nach Intertrigo", order = 12),
CheckItemDefinition(id = "wundart_iad", label = "IAD (Inkontinenz-assoziierte Dermatitis) / Wunde nach IAD", order = 13),
CheckItemDefinition(id = "wundart_amputationswunde", label = "Amputationswunde", order = 14),
CheckItemDefinition(id = "wundart_spalthautentnahmestelle", label = "Spalthautentnahmestelle", order = 15),
CheckItemDefinition(
id = "wundart_transplantatverlust",
label = "Wunde nach Transplantatverlust",
order = 15,
order = 16,
inputSpec = InputSpec(
pdfTemplate = "Wunde nach Transplantatverlust, Spalthautdeckung ca. {0}%",
fields = listOf(InputFieldDef.Number(label = "Spalthautdeckung ca. (%)", maxLength = 3)),
),
),
CheckItemDefinition(id = "wundart_sekundaer_heilende_wunde", label = "Sekundär heilende Wunde", order = 16),
CheckItemDefinition(id = "wundart_tumorwunde", label = "Tumorwunde", order = 17),
CheckItemDefinition(id = "wundart_wunde_nach_abszess", label = "Wunde nach Abszess", order = 18),
CheckItemDefinition(id = "wundart_verbrennung_verbruehung", label = "Verbrennung / Verbrühung", order = 19),
CheckItemDefinition(id = "wundart_platzwunde", label = "Platzwunde", order = 20),
CheckItemDefinition(id = "wundart_schnittwunde", label = "Schnittwunde", order = 21),
CheckItemDefinition(id = "wundart_bisswunde", label = "Bisswunde", order = 22),
CheckItemDefinition(id = "wundart_chemisch_veraetzung", label = "Chemisch hervorgerufene Wunde / Verätzung", order = 23),
CheckItemDefinition(id = "wundart_strahlenschaden", label = "Strahlenschaden", order = 24),
CheckItemDefinition(id = "wundart_sekundaer_heilende_wunde", label = "Sekundär heilende Wunde", order = 17),
CheckItemDefinition(id = "wundart_tumorwunde", label = "Tumorwunde", order = 18),
CheckItemDefinition(id = "wundart_wunde_nach_abszess", label = "Wunde nach Abszess", order = 19),
CheckItemDefinition(id = "wundart_verbrennung_verbruehung", label = "Verbrennung / Verbrühung", order = 20),
CheckItemDefinition(id = "wundart_platzwunde", label = "Platzwunde", order = 21),
CheckItemDefinition(id = "wundart_schnittwunde", label = "Schnittwunde", order = 22),
CheckItemDefinition(id = "wundart_bisswunde", label = "Bisswunde", order = 23),
CheckItemDefinition(id = "wundart_chemisch_veraetzung", label = "Chemisch hervorgerufene Wunde / Verätzung", order = 24),
CheckItemDefinition(id = "wundart_strahlenschaden", label = "Strahlenschaden", order = 25),
),
),
BlockDefinition(
@@ -165,24 +166,25 @@ object DocumentationCatalog {
items = listOf(
CheckItemDefinition(id = "wundgrund_wie_ausgestanzt", label = "Wie ausgestanzt", order = 1),
CheckItemDefinition(id = "wundgrund_kraterfoermig", label = "Kraterförmig", order = 2),
CheckItemDefinition(id = "wundgrund_unregelmaessig_landkartenartig", label = "Unregelmäßig (landkartenartig/amorph)", order = 3),
CheckItemDefinition(id = "wundgrund_linear_spaltfoermig", label = "Linear/spaltförmig", order = 4),
CheckItemDefinition(id = "wundgrund_rund", label = "Rund", order = 5),
CheckItemDefinition(id = "wundgrund_oval_elliptisch", label = "Oval/elliptisch", order = 6),
CheckItemDefinition(id = "wundgrund_quadrangulaer", label = "Quadrangulär", order = 7),
CheckItemDefinition(id = "wundgrund_eben", label = "Eben", order = 8),
CheckItemDefinition(id = "wundgrund_auf_niveau", label = "Auf Niveau", order = 9),
CheckItemDefinition(id = "wundgrund_ueber_niveau", label = "Über Niveau", order = 10),
CheckItemDefinition(id = "wundgrund_glaenzend", label = "Glänzend", order = 11),
CheckItemDefinition(id = "wundgrund_grob", label = "Grob", order = 12),
CheckItemDefinition(id = "wundgrund_fein", label = "Fein", order = 13),
CheckItemDefinition(id = "wundgrund_glatt", label = "Glatt", order = 14),
CheckItemDefinition(id = "wundgrund_rot", label = "Rot", order = 15),
CheckItemDefinition(id = "wundgrund_blass", label = "Blass", order = 16),
CheckItemDefinition(id = "wundgrund_knochen_sehnen", label = "Knochen/Sehnen", order = 17),
CheckItemDefinition(id = "wundgrund_haemorrhagie", label = "Hämorrhagie", order = 18),
CheckItemDefinition(id = "wundgrund_nicht_kaum_einsehbar", label = "Nicht/kaum einsehbar", order = 19),
CheckItemDefinition(id = "wundgrund_erscheinung_erscheinung", label = "Erscheinung", order = 20, isFreeText = true),
CheckItemDefinition(id = "wundgrund_unregelmaessig", label = "Unregelmäßig", order = 3),
CheckItemDefinition(id = "wundgrund_landkartenartig_amorph", label = "Landkartenartig/amorph", order = 4),
CheckItemDefinition(id = "wundgrund_linear_spaltfoermig", label = "Linear/spaltförmig", order = 5),
CheckItemDefinition(id = "wundgrund_rund", label = "Rund", order = 6),
CheckItemDefinition(id = "wundgrund_oval_elliptisch", label = "Oval/elliptisch", order = 7),
CheckItemDefinition(id = "wundgrund_quadrangulaer", label = "Quadrangulär", order = 8),
CheckItemDefinition(id = "wundgrund_eben", label = "Eben", order = 9),
CheckItemDefinition(id = "wundgrund_auf_niveau", label = "Auf Niveau", order = 10),
CheckItemDefinition(id = "wundgrund_ueber_niveau", label = "Über Niveau", order = 11),
CheckItemDefinition(id = "wundgrund_glaenzend", label = "Glänzend", order = 12),
CheckItemDefinition(id = "wundgrund_grob", label = "Grob", order = 13),
CheckItemDefinition(id = "wundgrund_fein", label = "Fein", order = 14),
CheckItemDefinition(id = "wundgrund_glatt", label = "Glatt", order = 15),
CheckItemDefinition(id = "wundgrund_rot", label = "Rot", order = 16),
CheckItemDefinition(id = "wundgrund_blass", label = "Blass", order = 17),
CheckItemDefinition(id = "wundgrund_knochen_sehnen", label = "Knochen/Sehnen", order = 18),
CheckItemDefinition(id = "wundgrund_haemorrhagie", label = "Hämorrhagie", order = 19),
CheckItemDefinition(id = "wundgrund_nicht_kaum_einsehbar", label = "Nicht/kaum einsehbar", order = 20),
CheckItemDefinition(id = "wundgrund_erscheinung_erscheinung", label = "Erscheinung", order = 21, isFreeText = true),
),
),
BlockDefinition(
@@ -498,61 +500,61 @@ object DocumentationCatalog {
CheckItemDefinition(id = "empfohlene_wundversorgung_sterile_kompresse", label = "Sterile Kompresse", order = 2),
CheckItemDefinition(id = "empfohlene_wundversorgung_nacl", label = "NaCl 0,9%", order = 3),
CheckItemDefinition(id = "empfohlene_wundversorgung_octenisept", label = "Octenisept", order = 4),
// Granudacyn: Checkbox + direkt folgendes Freitextfeld, das nur bei angehakter
// Checkbox aktiv ist (= vorhandenes isFreeText-Verhalten, s. FreeTextCheckItemRow).
CheckItemDefinition(id = "empfohlene_wundversorgung_granudacyn", label = "Granudacyn", order = 5, isFreeText = true),
// Granudacyn: einfache Checkbox. Der Freitext ist ein eigener, separat
// anzuhakender Punkt direkt darunter (Bezeichnung "Freitext" wird in PDF/JPG
// und, sobald angehakt, auch in der App nicht angezeigt).
CheckItemDefinition(id = "empfohlene_wundversorgung_granudacyn", label = "Granudacyn", order = 5),
CheckItemDefinition(id = "empfohlene_wundversorgung_granudacyn_freitext", label = FREE_TEXT_LABEL, order = 6, isFreeText = true),
CheckItemDefinition(
id = "empfohlene_wundversorgung_getraenkte_kompresse",
label = "Getränkte Kompresse",
order = 6,
order = 7,
inputSpec = InputSpec(
pdfTemplate = "Getränkte Kompresse für ca. {0} Min. auflegen",
fields = listOf(InputFieldDef.Choice(label = "Minuten", options = MINUTE_1_TO_15_OPTIONS)),
),
),
CheckItemDefinition(id = "empfohlene_wundversorgung_tupfend", label = "Tupfend", order = 7),
CheckItemDefinition(id = "empfohlene_wundversorgung_wischend", label = "Wischend", order = 8),
CheckItemDefinition(id = "empfohlene_wundversorgung_nachspuelen_nacl", label = "Nachspülen mit NaCl 0,9%", order = 9),
CheckItemDefinition(id = "empfohlene_wundversorgung_umgebung_trocken_tupfen", label = "Umgebung trocken tupfen", order = 10),
CheckItemDefinition(id = "empfohlene_wundversorgung_primaer_abdecken_austamponieren", label = "Primär Wunde abdecken/austamponieren", order = 11),
CheckItemDefinition(id = "empfohlene_wundversorgung_ueber_wundrand_hinaus", label = "Über Wundrand hinaus", order = 12),
CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit", label = "Nicht über Wundrand mit", order = 13),
CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit_freitext", label = FREE_TEXT_LABEL, order = 14, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_wundrandschutz", label = "Wundrandschutz:", order = 15, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_ja", label = "Ja", order = 16),
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_nein", label = "Nein", order = 17),
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz_freitext", label = FREE_TEXT_LABEL, order = 18, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_tupfend", label = "Tupfend", order = 8),
CheckItemDefinition(id = "empfohlene_wundversorgung_wischend", label = "Wischend", order = 9),
CheckItemDefinition(id = "empfohlene_wundversorgung_nachspuelen_nacl", label = "Nachspülen mit NaCl 0,9%", order = 10),
CheckItemDefinition(id = "empfohlene_wundversorgung_umgebung_trocken_tupfen", label = "Umgebung trocken tupfen", order = 11),
CheckItemDefinition(id = "empfohlene_wundversorgung_primaer_abdecken_austamponieren", label = "Primär Wunde abdecken/austamponieren", order = 12),
CheckItemDefinition(id = "empfohlene_wundversorgung_ueber_wundrand_hinaus", label = "Über Wundrand hinaus", order = 13),
CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit", label = "Nicht über Wundrand mit", order = 14),
CheckItemDefinition(id = "empfohlene_wundversorgung_nicht_ueber_wundrand_mit_freitext", label = FREE_TEXT_LABEL, order = 15, isFreeText = true),
// Wundrandschutz: EINE Checkbox mit eigenem Freitextfeld (kein Ja/Nein mehr).
CheckItemDefinition(id = "empfohlene_wundversorgung_wundrandschutz", label = "Wundrandschutz", order = 16, isFreeText = true),
// "Wie oft pro Woche": eigene Checkbox; die Auswahl (1-7x) ist erst nach dem
// Anhaken aktiv (InputSpec.requiresOwnCheckbox).
CheckItemDefinition(
id = "empfohlene_wundversorgung_wundrandschutz_tage_pro_woche",
label = "Wie oft pro Woche",
order = 19,
order = 17,
inputSpec = InputSpec(
pdfTemplate = "{0}x pro Woche",
fields = listOf(InputFieldDef.Choice(label = "Tage pro Woche", options = WEEKDAY_COUNT_OPTIONS)),
requiresOwnCheckbox = true,
),
),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_sekundaere_wundauflage", label = "Sekundäre Wundauflage:", order = 20, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_sekundaer_sterile_kompresse", label = "Sterile Kompresse", order = 21),
CheckItemDefinition(id = "empfohlene_wundversorgung_saugkompresse", label = "Saugkompresse", order = 22),
CheckItemDefinition(id = "empfohlene_wundversorgung_sonstige_wundauflage", label = "Sonstiges (Wundauflage)", order = 23, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_fixieren_mit", label = "Fixieren mit:", order = 24, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_mullbinde", label = "Mullbinde", order = 25),
CheckItemDefinition(id = "empfohlene_wundversorgung_klebevlies", label = "Klebevlies", order = 26),
CheckItemDefinition(id = "empfohlene_wundversorgung_folienverband", label = "Folienverband", order = 27),
CheckItemDefinition(id = "empfohlene_wundversorgung_schlauchverband", label = "Schlauchverband", order = 28),
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde", label = "Kohäsive Fixierbinde", order = 29),
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde_freitext", label = FREE_TEXT_LABEL, order = 30, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system", label = "Kompressionsverband / System", order = 31),
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system_freitext", label = FREE_TEXT_LABEL, order = 32, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_wechselintervall", label = "Wechselintervall", order = 33, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_bedarf", label = "+ bei Bedarf", order = 34, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_sekundaere_wundauflage", label = "Sekundäre Wundauflage:", order = 18, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_sekundaer_sterile_kompresse", label = "Sterile Kompresse", order = 19),
CheckItemDefinition(id = "empfohlene_wundversorgung_saugkompresse", label = "Saugkompresse", order = 20),
CheckItemDefinition(id = "empfohlene_wundversorgung_sonstige_wundauflage", label = "Sonstiges (Wundauflage)", order = 21, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_fixieren_mit", label = "Fixieren mit:", order = 22, isStaticHint = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_mullbinde", label = "Mullbinde", order = 23),
CheckItemDefinition(id = "empfohlene_wundversorgung_klebevlies", label = "Klebevlies", order = 24),
CheckItemDefinition(id = "empfohlene_wundversorgung_folienverband", label = "Folienverband", order = 25),
CheckItemDefinition(id = "empfohlene_wundversorgung_schlauchverband", label = "Schlauchverband", order = 26),
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde", label = "Kohäsive Fixierbinde", order = 27),
CheckItemDefinition(id = "empfohlene_wundversorgung_kohaesive_fixierbinde_freitext", label = FREE_TEXT_LABEL, order = 28, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system", label = "Kompressionsverband / System", order = 29),
CheckItemDefinition(id = "empfohlene_wundversorgung_kompressionsverband_system_freitext", label = FREE_TEXT_LABEL, order = 30, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_wechselintervall", label = "Wechselintervall", order = 31, isFreeText = true),
CheckItemDefinition(id = "empfohlene_wundversorgung_hinweis_bedarf", label = "+ bei Bedarf", order = 32, isStaticHint = true),
CheckItemDefinition(
id = "empfohlene_wundversorgung_hinweis_abklaerung",
label = "Generell: bei akuter Verschlechterung sofort ärztliche Abklärung",
order = 35,
order = 33,
isStaticHint = true,
),
),
@@ -571,9 +573,8 @@ object DocumentationCatalog {
CheckItemDefinition(id = "ziel_abheilung", label = "Abheilung", order = 7),
CheckItemDefinition(id = "ziel_geruchsreduzierung", label = "Geruchsreduzierung", order = 8),
CheckItemDefinition(id = "ziel_einfache_durchfuehrung", label = "Einfache Durchführung", order = 9),
CheckItemDefinition(id = "ziel_wundrandschutz", label = "Wundrandschutz", order = 10),
CheckItemDefinition(id = "ziel_aufbruch_vorhandenes_material", label = "Aufbruch des vorhandenen Materials", order = 11),
CheckItemDefinition(id = "ziel_selbstversorgung_foerdern", label = "Selbstversorgung der Wunde durch Patient/Angehörige unterstützen/fördern", order = 12),
CheckItemDefinition(id = "ziel_aufbruch_vorhandenes_material", label = "Aufbruch des vorhandenen Materials", order = 10),
CheckItemDefinition(id = "ziel_selbstversorgung_foerdern", label = "Selbstversorgung der Wunde durch Patient/Angehörige unterstützen/fördern", order = 11),
),
),
BlockDefinition(
@@ -117,13 +117,13 @@ class InternalPhotoStorage(private val context: Context) : PhotoStorage {
}
private fun documentationDirPath(documentationId: String): File =
File(photosRootDir, documentationId)
SafePaths.resolveChild(photosRootDir, documentationId)
private fun ensureDocumentationDir(documentationId: String): File =
documentationDirPath(documentationId).apply { mkdirs() }
override fun resolve(documentationId: String, fileName: String): File =
File(documentationDirPath(documentationId), fileName)
SafePaths.resolveChild(documentationDirPath(documentationId), fileName)
override suspend fun importFrom(documentationId: String, sourceUri: Uri): String =
withContext(Dispatchers.IO) {
@@ -0,0 +1,32 @@
package de.medapp.wunddoku.media
import java.io.File
/**
* Schutz vor Path-Traversal (`../`) beim Auflösen von Datei-/Ordnernamen im privaten App-Speicher.
*
* Datei- und Ordnernamen stammen normalerweise aus der eigenen Datenbank (UUIDs). Als
* Defense-in-Depth wird trotzdem sichergestellt, dass ein aufgelöster Pfad nie aus seinem
* Wurzelordner ausbrechen kann - etwa bei einer manipulierten Datenbank oder einem Programmfehler.
*/
object SafePaths {
/**
* Liefert `File(parent, name)`, wenn [name] ein einzelner, harmloser Pfadbestandteil ist.
*
* @throws IllegalArgumentException bei leerem Namen, Pfadtrennzeichen, "." / ".." oder wenn
* der kanonische Pfad nicht unterhalb von [parent] liegt.
*/
fun resolveChild(parent: File, name: String): File {
require(name.isNotBlank()) { "Name darf nicht leer sein." }
require(!name.contains('/') && !name.contains('\\') && !name.contains('\u0000')) {
"Name darf kein Pfadtrennzeichen enthalten."
}
require(name != "." && name != "..") { "Ungültiger Name." }
val child = File(parent, name)
val parentPath = parent.canonicalFile.path
val childPath = child.canonicalFile.path
require(childPath.startsWith(parentPath + File.separator)) { "Pfad liegt außerhalb des erlaubten Ordners." }
return child
}
}
@@ -2,10 +2,13 @@ package de.medapp.wunddoku.pdf
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Typeface
import android.graphics.pdf.PdfDocument
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import de.medapp.wunddoku.domain.error.FailureCategory
import de.medapp.wunddoku.domain.model.InputSpecCodec
@@ -66,6 +69,8 @@ class AndroidPdfDocumentExporter(
private const val ITEM_TEXT_SIZE = 11f
private const val FREE_TEXT_TEXT_SIZE = 10.5f
private const val FREE_TEXT_INDENT = 20f
private const val RUNNING_HEADER_TEXT_SIZE = 9.5f
private const val RUNNING_HEADER_SPACING_AFTER = 10f
// PDF-003: Signaturbereich. Fachlich handschriftliche Unterschriften, keine
// kryptographische PDF-Signatur (s. Aufgabenstellung Abschnitt 5).
@@ -160,6 +165,9 @@ class AndroidPdfDocumentExporter(
marginRight = MARGIN,
marginTop = MARGIN,
marginBottom = MARGIN,
pageHeaderDrawer = PageHeaderDrawer { canvas, pageNumber, left, top, width ->
drawRunningHeader(canvas, pageNumber, left, top, width, model, paints)
},
)
drawHeader(cursor, model, paints)
@@ -243,6 +251,32 @@ class AndroidPdfDocumentExporter(
textSize = FREE_TEXT_TEXT_SIZE
color = Color.DKGRAY
}
// Zwischenüberschriften innerhalb eines Blocks: kursiv + unterstrichen.
val itemSubheading = TextPaint().apply {
isAntiAlias = true
textSize = ITEM_TEXT_SIZE
typeface = Typeface.create(Typeface.DEFAULT, Typeface.ITALIC)
isUnderlineText = true
color = Color.BLACK
}
// Wichtiger Hinweis: fett + unterstrichen.
val itemWarning = TextPaint().apply {
isAntiAlias = true
textSize = ITEM_TEXT_SIZE
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
isUnderlineText = true
color = Color.BLACK
}
// Seitenkopf ab Seite 2 (Patient + Erstelldatum).
val runningHeader = TextPaint().apply {
isAntiAlias = true
textSize = RUNNING_HEADER_TEXT_SIZE
color = Color.DKGRAY
}
val runningHeaderLine = Paint().apply {
color = Color.LTGRAY
strokeWidth = 1f
}
// PDF-003: Signaturbereich.
val signatureLabel = TextPaint().apply {
@@ -286,6 +320,37 @@ class AndroidPdfDocumentExporter(
cursor.drawDivider(spacingAfter = 12f)
}
/**
* Seitenkopf für alle Seiten AB Seite 2 (Änderungswunsch: auf jeder Seite müssen Name des
* Patienten und Erstellungsdatum stehen): Seite 1 trägt bereits den großen Dokumentkopf
* ([drawHeader]). Gilt auch für Unterschriften- und Fotoseiten. Verwendet ausschließlich
* Snapshot-Daten aus dem [PdfExportModel]. Liefert die belegte Höhe zurück.
*/
private fun drawRunningHeader(
canvas: Canvas,
pageNumber: Int,
left: Float,
top: Float,
width: Float,
model: PdfExportModel,
paints: ExportPaints,
): Float {
if (pageNumber <= 1) return 0f
val text = "Patient: ${model.patientFullName}, geb. ${model.patientBirthDate.format(HEADER_DATE_FORMATTER)}" +
" · ${model.patientDisplayAddress} · Erstellt am: ${model.createdAt.format(HEADER_DATE_FORMATTER)}"
val layout = StaticLayout.Builder.obtain(text, 0, text.length, paints.runningHeader, width.toInt().coerceAtLeast(1))
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
.setIncludePad(false)
.build()
canvas.save()
canvas.translate(left, top)
layout.draw(canvas)
canvas.restore()
val lineY = top + layout.height + 4f
canvas.drawLine(left, lineY, left + width, lineY, paints.runningHeaderLine)
return layout.height + 4f + 1f + RUNNING_HEADER_SPACING_AFTER
}
/** Überschriften erscheinen im PDF immer mit abschließendem Doppelpunkt (kein doppelter
* Doppelpunkt, falls der Titel bereits mit einem endet). */
private fun headingText(title: String): String {
@@ -294,6 +359,8 @@ class AndroidPdfDocumentExporter(
}
private fun drawBlock(cursor: PdfPageCursor, block: PdfExportBlock, paints: ExportPaints) {
// Änderungswunsch: "Empfohlene Wundversorgung" beginnt immer auf einer eigenen Seite.
if (block.startsOnNewPage) cursor.startNewPageUnlessEmpty()
cursor.drawText(headingText(block.title), paints.blockTitle, spacingAfter = 6f)
for (item in block.items) {
@@ -330,14 +397,37 @@ class AndroidPdfDocumentExporter(
return
}
// Generische "Freitext"-Einträge (Änderungswunsch): Bezeichnung wird NICHT gedruckt, nur
// der eingegebene Text (eingerückt unter dem vorhergehenden Eintrag). Ein angehakter,
// aber leerer Freitext erzeugt keine Ausgabe.
if (!item.showLabel) {
if (item.freeTextValue.isNotBlank()) {
cursor.drawText(
text = item.freeTextValue,
paint = paints.item,
indent = FREE_TEXT_INDENT,
spacingAfter = 6f,
)
}
return
}
// Die Freitext-Option bekommt einen abschließenden Doppelpunkt, sofern tatsächlich Text
// folgt (z. B. "Granudacyn" ohne Text bleibt ohne hängenden Doppelpunkt), normale
// Optionen nie.
val labelText = if (item.isFreeText && item.freeTextValue.isNotBlank()) "${item.label}:" else item.label
val baseLabel = if (item.isFreeText && item.freeTextValue.isNotBlank()) "${item.label}:" else item.label
// Änderungswunsch: Zwischenüberschriften kursiv + unterstrichen, wichtiger Hinweis fett +
// unterstrichen mit Ausrufezeichen davor und dahinter.
val (labelText, labelPaint) = when (item.style) {
PdfItemStyle.SUBHEADING -> baseLabel to paints.itemSubheading
PdfItemStyle.WARNING -> "! $baseLabel !" to paints.itemWarning
PdfItemStyle.NORMAL -> baseLabel to paints.item
}
cursor.drawText(
text = labelText,
paint = paints.item,
paint = labelPaint,
indent = ITEM_INDENT,
spacingAfter = 3f,
)
@@ -1,6 +1,9 @@
package de.medapp.wunddoku.pdf
import de.medapp.wunddoku.domain.catalog.DocumentationCatalog
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.Documentation
import de.medapp.wunddoku.domain.model.DocumentationBlock
import de.medapp.wunddoku.domain.usecase.BlockEditedPolicy
import java.time.ZoneId
@@ -27,22 +30,24 @@ object DocumentationToPdfExportModelMapper {
.sortedBy { it.order }
.map { block ->
PdfExportBlock(
definitionId = block.definitionId,
title = block.title,
// Änderungswunsch: nur angehakte Optionen erscheinen im PDF - nicht
// angehakte Checkboxen werden komplett weggelassen (nicht nur unausgefüllt
// dargestellt). Ein leeres Ergebnis ist hier ausgeschlossen: BlockEditedPolicy
startsOnNewPage = block.definitionId in BLOCKS_ON_OWN_PAGE,
// Nur angehakte Optionen erscheinen im PDF. Hinweistexte (isStaticHint) sind
// technisch immer angehakt und werden gruppenabhängig gefiltert, s.
// [exportedItems]. Ein leeres Ergebnis ist ausgeschlossen: BlockEditedPolicy
// (Filter oben) lässt nur Blöcke mit mindestens einer angehakten Option durch.
items = block.items
.filter { it.checked }
.sortedBy { it.order }
.map { item ->
PdfExportItem(
label = item.label,
isFreeText = item.isFreeText,
freeTextValue = item.freeTextValue,
inputSpec = item.inputSpec,
)
},
items = exportedItems(block).map { item ->
PdfExportItem(
label = item.label,
isFreeText = item.isFreeText,
freeTextValue = item.freeTextValue,
inputSpec = item.inputSpec,
// Generische "Freitext"-Bezeichnung nie drucken, nur den Text.
showLabel = !(item.isFreeText && item.label == DocumentationCatalog.FREE_TEXT_LABEL),
style = styleFor(item),
)
},
)
}
@@ -80,4 +85,67 @@ object DocumentationToPdfExportModelMapper {
photos = photos,
)
}
/** Blöcke, die im PDF/JPG immer auf einer neuen Seite beginnen. */
private val BLOCKS_ON_OWN_PAGE: Set<String> = setOf("empfohlene_wundversorgung")
/** Zwischenüberschriften (kursiv + unterstrichen), anhand der stabilen Definitions-IDs. */
private val SUBHEADING_DEFINITION_IDS: Set<String> = setOf(
"empfohlene_wundversorgung_hinweis_reinigen_mit",
"empfohlene_wundversorgung_primaer_abdecken_austamponieren",
"empfohlene_wundversorgung_wundrandschutz",
"empfohlene_wundversorgung_hinweis_sekundaere_wundauflage",
"empfohlene_wundversorgung_hinweis_fixieren_mit",
"empfohlene_wundversorgung_wechselintervall",
)
/** Wichtige Hinweise (fett + unterstrichen, mit Ausrufezeichen). */
private val WARNING_DEFINITION_IDS: Set<String> = setOf(
"empfohlene_wundversorgung_hinweis_abklaerung",
)
private fun styleFor(item: CheckItem): PdfItemStyle = when (item.definitionId) {
in WARNING_DEFINITION_IDS -> PdfItemStyle.WARNING
in SUBHEADING_DEFINITION_IDS -> PdfItemStyle.SUBHEADING
else -> PdfItemStyle.NORMAL
}
/**
* Bestimmt die zu exportierenden Einträge eines Blocks (sortiert nach order):
* - normale Einträge: nur wenn angehakt
* - Hinweistexte (isStaticHint, technisch immer angehakt): Ein Hinweis, dem im Block
* eigene Einträge folgen (bis zum nächsten Hinweis), ist eine Gruppenüberschrift und
* wird nur gedruckt, wenn mindestens einer dieser Einträge angehakt ist (z. B. "Zu
* versorgendes Areal" bei Größe). Hinweise ohne folgende Einträge ("+ bei Bedarf",
* "Generell: ...") bleiben immer erhalten. Der automatisch angehängte generische
* Freitext eines Blocks gehört zu keiner Gruppe. Trägt der erste angehakte Eintrag der
* Gruppe dieselbe Bezeichnung wie der Hinweis, entfällt der Hinweis (keine Doppelung).
*
* Ausschließlich Snapshot-Daten, kein Zugriff auf den aktuellen Katalog.
*/
private fun exportedItems(block: DocumentationBlock): List<CheckItem> {
val sorted = block.items.sortedBy { it.order }
val genericFreeTextId = "${block.definitionId}_freetext"
val result = mutableListOf<CheckItem>()
for ((index, item) in sorted.withIndex()) {
if (!item.checked) continue
if (!item.isStaticHint) {
result.add(item)
continue
}
val groupItems = sorted
.drop(index + 1)
.takeWhile { !it.isStaticHint }
.filter { it.definitionId != genericFreeTextId }
val keep = when {
groupItems.isEmpty() -> true
else -> {
val firstChecked = groupItems.firstOrNull { it.checked }
firstChecked != null && !firstChecked.label.trim().equals(item.label.trim(), ignoreCase = true)
}
}
if (keep) result.add(item)
}
return result
}
}
@@ -0,0 +1,80 @@
package de.medapp.wunddoku.pdf
import de.medapp.wunddoku.media.SafePaths
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
/**
* Löscht abgeleitete, personenbezogene Dateien, die NICHT zur Dokumentation selbst gehören:
* exportierte PDFs (`filesDir/pdf/<documentationId>/`), daraus erzeugte Seitenbilder
* (`filesDir/pdf_images/<documentationId>/`) und liegengebliebene Kameraaufnahmen
* (`cacheDir/camera/`).
*
* Datenschutz: PDFs enthalten Name, Geburtsdatum, Anschrift und Wundbefund. Sie dürfen nach dem
* Löschen einer Dokumentation nicht im Speicher zurückbleiben (Löschkonzept) und werden darüber
* hinaus nach einer Aufbewahrungsfrist automatisch entfernt - sie lassen sich jederzeit aus den
* Daten der Dokumentation neu erzeugen.
*/
interface ExportedFilesCleaner {
/** Entfernt alle PDF- und Bildexporte der Dokumentation [documentationId]. Wirft nie. */
suspend fun deleteExportsFor(documentationId: String)
/** Entfernt veraltete Exporte und liegengebliebene Kameraaufnahmen. Wirft nie. */
suspend fun deleteStaleFiles()
/** Tut nichts (Standard für Tests und Umgebungen ohne Dateisystem). */
object None : ExportedFilesCleaner {
override suspend fun deleteExportsFor(documentationId: String) = Unit
override suspend fun deleteStaleFiles() = Unit
}
}
class InternalExportedFilesCleaner(
filesDir: File,
cacheDir: File,
private val exportMaxAgeMillis: Long = DEFAULT_EXPORT_MAX_AGE_MILLIS,
private val captureMaxAgeMillis: Long = DEFAULT_CAPTURE_MAX_AGE_MILLIS,
private val now: () -> Long = System::currentTimeMillis,
) : ExportedFilesCleaner {
private val pdfRoot = File(filesDir, "pdf")
private val imagesRoot = File(filesDir, "pdf_images")
private val captureDir = File(cacheDir, "camera")
override suspend fun deleteExportsFor(documentationId: String) = withContext(Dispatchers.IO) {
listOf(pdfRoot, imagesRoot).forEach { root ->
runCatching { SafePaths.resolveChild(root, documentationId).deleteRecursively() }
}
Unit
}
override suspend fun deleteStaleFiles() = withContext(Dispatchers.IO) {
val currentTime = now()
runCatching { deleteOlderThan(pdfRoot, currentTime - exportMaxAgeMillis) }
runCatching { deleteOlderThan(imagesRoot, currentTime - exportMaxAgeMillis) }
// Kameraaufnahmen nur nach längerer Frist löschen: Wurde die App während der Aufnahme
// beendet, wird die Datei nach dem Neustart noch für den Import gebraucht.
runCatching { deleteOlderThan(captureDir, currentTime - captureMaxAgeMillis) }
Unit
}
/** Löscht Dateien (rekursiv) mit `lastModified < cutoff`; leere Unterordner werden entfernt. */
private fun deleteOlderThan(dir: File, cutoff: Long) {
val children = dir.listFiles() ?: return
for (child in children) {
if (child.isDirectory) {
deleteOlderThan(child, cutoff)
if (child.listFiles().isNullOrEmpty()) child.delete()
} else if (child.lastModified() < cutoff) {
child.delete()
}
}
}
companion object {
const val DEFAULT_EXPORT_MAX_AGE_MILLIS: Long = 7L * 24 * 60 * 60 * 1000
const val DEFAULT_CAPTURE_MAX_AGE_MILLIS: Long = 24L * 60 * 60 * 1000
}
}
@@ -27,7 +27,12 @@ data class PdfExportModel(
)
data class PdfExportBlock(
/** Stabile Definitions-ID des Blocks (z. B. "empfohlene_wundversorgung"). */
val definitionId: String = "",
val title: String,
/** `true`: der Block beginnt im PDF/JPG zwingend auf einer neuen Seite (Änderungswunsch:
* "Empfohlene Wundversorgung" auf eigener Seite). */
val startsOnNewPage: Boolean = false,
/** Bereits gefiltert (NUR angehakte Optionen, s. Mapper - Änderungswunsch) und sortiert
* (itemOrder). Nicht angehakte Optionen erscheinen im PDF überhaupt nicht mehr. */
val items: List<PdfExportItem>,
@@ -45,8 +50,23 @@ data class PdfExportItem(
val isFreeText: Boolean,
val freeTextValue: String,
val inputSpec: String = "",
/** `false`: die Bezeichnung wird nicht gedruckt, nur der eingegebene Text (generische
* "Freitext"-Einträge, Änderungswunsch: Schriftzug "Freitext" im PDF/JPG nicht anzeigen). */
val showLabel: Boolean = true,
val style: PdfItemStyle = PdfItemStyle.NORMAL,
)
/** Darstellung der Bezeichnung eines Eintrags im PDF (rein layoutbezogen). */
enum class PdfItemStyle {
NORMAL,
/** Zwischenüberschrift innerhalb eines Blocks: kursiv und unterstrichen. */
SUBHEADING,
/** Wichtiger Hinweis: fett, unterstrichen, mit Ausrufezeichen davor und dahinter. */
WARNING,
}
/**
* Minimale, für den Fotoanhang benötigte Referenz. Enthält bewusst nur [fileName] -
* `documentationId` steht bereits am [PdfExportModel] selbst zur Verfügung (nötig, um über
@@ -10,6 +10,15 @@ import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
/**
* Zeichnet den Seitenkopf einer (neuen) Seite und liefert die dafür belegte Höhe zurück
* (0f, wenn auf dieser Seite kein Seitenkopf gezeichnet wird, z. B. Seite 1 mit dem großen
* Dokumentkopf). [left]/[top] sind die obere linke Ecke des Inhaltsbereichs, [width] dessen Breite.
*/
internal fun interface PageHeaderDrawer {
fun draw(canvas: Canvas, pageNumber: Int, left: Float, top: Float, width: Float): Float
}
/**
* Verwaltet Seiten, Zeichenfläche und die aktuelle vertikale Schreibposition ("Cursor") beim
* Erzeugen eines [PdfDocument]. Kapselt die komplette Seitenumbruch-Logik an einer Stelle, damit
@@ -33,8 +42,13 @@ internal class PdfPageCursor(
private val marginRight: Float,
private val marginTop: Float,
private val marginBottom: Float,
/** Optionaler Seitenkopf, der auf JEDER neu begonnenen Seite gezeichnet wird (Änderungswunsch:
* Patientenname und Erstellungsdatum auf jeder Seite). */
private val pageHeaderDrawer: PageHeaderDrawer? = null,
) {
private var pageNumber = 0
/** Y-Position, ab der auf der aktuellen Seite Inhalt beginnt (nach einem evtl. Seitenkopf). */
private var pageContentTop: Float = marginTop
private var currentPage: PdfDocument.Page? = null
private var canvas: Canvas? = null
private var cursorY: Float = marginTop
@@ -74,7 +88,15 @@ internal class PdfPageCursor(
val page = pdfDocument.startPage(info)
currentPage = page
canvas = page.canvas
cursorY = marginTop
val headerHeight = pageHeaderDrawer?.draw(
canvas = page.canvas,
pageNumber = pageNumber,
left = marginLeft,
top = marginTop,
width = pageWidth - marginLeft - marginRight,
) ?: 0f
pageContentTop = marginTop + headerHeight
cursorY = pageContentTop
}
private fun finishCurrentPageIfOpen() {
@@ -98,6 +120,15 @@ internal class PdfPageCursor(
startNewPage()
}
/**
* Beginnt eine neue Seite, außer die aktuelle Seite ist noch völlig leer (Cursor steht am
* Inhaltsanfang) - so entstehen keine Leerseiten, wenn ein Block, der immer auf einer
* eigenen Seite beginnt, ohnehin ganz oben stünde.
*/
fun startNewPageUnlessEmpty() {
if (currentPage == null || cursorY > pageContentTop) startNewPage()
}
/**
* Zeichnet [bitmap] proportional eingepasst (kein Zuschneiden, keine Verzerrung) horizontal
* über die volle Inhaltsbreite und vertikal zentriert im aktuell verbleibenden Platz der
@@ -1,5 +1,6 @@
package de.medapp.wunddoku.signature
import de.medapp.wunddoku.media.SafePaths
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
@@ -92,13 +93,13 @@ class InternalSignatureStorage(
}
private fun documentationDirPath(documentationId: String): File =
File(rootDir, documentationId)
SafePaths.resolveChild(rootDir, documentationId)
private fun ensureDocumentationDir(documentationId: String): File =
documentationDirPath(documentationId).apply { mkdirs() }
override fun resolve(documentationId: String, fileName: String): File =
File(documentationDirPath(documentationId), fileName)
SafePaths.resolveChild(documentationDirPath(documentationId), fileName)
override suspend fun save(documentationId: String, bitmap: Bitmap): String =
withContext(Dispatchers.IO) {
@@ -85,6 +85,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import de.medapp.wunddoku.R
import de.medapp.wunddoku.domain.catalog.DocumentationCatalog
import de.medapp.wunddoku.domain.model.CheckItem
import de.medapp.wunddoku.domain.model.InputFieldDef
import de.medapp.wunddoku.domain.model.InputSpecCodec
@@ -907,19 +908,25 @@ private fun FreeTextCheckItemRow(
onCheckedChanged: (checkItemStateId: String, checked: Boolean) -> Unit,
onFreeTextChanged: (checkItemStateId: String, text: String) -> Unit,
) {
// Änderungswunsch: Ist ein generischer "Freitext"-Eintrag angehakt, wird der Schriftzug
// "Freitext" nicht mehr angezeigt (nur Häkchenkästchen + Eingabefeld). Fachlich benannte
// Freitext-Einträge (z. B. "Wundrandschutz", "Wechselintervall") behalten ihre Bezeichnung.
val hideName = item.checked && item.label == DocumentationCatalog.FREE_TEXT_LABEL
val fieldLabel: (@Composable () -> Unit)? =
if (hideName) null else ({ Text(stringResource(R.string.documentation_edit_free_text_label)) })
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = item.checked,
onCheckedChange = { checked -> onCheckedChanged(item.checkItemStateId, checked) },
)
Text(item.label)
if (!hideName) Text(item.label)
}
OutlinedTextField(
value = item.freeTextValue,
onValueChange = { text -> onFreeTextChanged(item.checkItemStateId, text) },
enabled = item.checked,
label = { Text(stringResource(R.string.documentation_edit_free_text_label)) },
label = fieldLabel,
modifier = Modifier.fillMaxWidth().padding(start = 40.dp),
minLines = 2,
)
@@ -331,11 +331,12 @@ class DocumentationEditViewModel(
failureCount++
if (FailureCategory.of(e) == FailureCategory.OUT_OF_MEMORY) outOfMemoryCount++
if (FailureCategory.of(e) == FailureCategory.OUT_OF_STORAGE) outOfStorageCount++
// Bewusst OHNE Uri/Dateipfad geloggt (Datenschutz-Vorgabe: keine Bild-Uris/
// Dateipfade in Logs) - die Exception-Nachricht selbst genügt zur Diagnose.
// Datenschutz: Es wird bewusst NUR der Exception-Typ geloggt. Die
// Exception-Nachricht (e.message) enthält bei Dateifehlern häufig den
// Dateipfad bzw. die Bild-Uri und darf deshalb nie in Logs landen.
android.util.Log.w(
"DocumentationEdit",
"Fotoimport fehlgeschlagen: ${e::class.simpleName}: ${e.message}",
"Fotoimport fehlgeschlagen: ${e::class.simpleName}",
)
}
}
+4 -3
View File
@@ -1,15 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Gibt ausschließlich Unterordner des privaten internen App-Speichers (context.filesDir)
für die Freigabe über den FileProvider frei. Es gibt bewusst keinen <external-path>-
für die Freigabe über den FileProvider frei - und zwar NUR das, was die App tatsächlich
nach außen gibt (PDF-Export, Seitenbilder, Kameraaufnahme). Die Ordner photos/ und
signatures/ (Originalfotos, Unterschriften) sind bewusst NICHT freigegeben (Prinzip der
minimalen Freigabe; Datenschutz-Review QA-006). Es gibt bewusst keinen <external-path>-
oder <external-files-path>-Eintrag: Fotos, Signaturen und PDFs liegen grundsätzlich
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">
<files-path name="pdf_exports" path="pdf/" />
<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
Dokumentation, danach sofort gelöscht. -->
<cache-path name="camera_captures" path="camera/" />
@@ -0,0 +1,78 @@
package de.medapp.wunddoku.domain.catalog
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Strukturprüfungen für den statischen Katalog sowie die fachlichen Änderungswünsche
* (Wundart/Wundgrund geteilt, Wundrandschutz, Granudacyn).
*/
class DocumentationCatalogTest {
private fun items(blockId: String) = DocumentationCatalog.findBlock(blockId)!!.items
@Test
fun alle_checkitem_ids_sind_katalogweit_eindeutig() {
val ids = DocumentationCatalog.blocks.flatMap { b -> b.items.map { it.id } }
assertEquals(ids.size, ids.toSet().size)
}
@Test
fun order_ist_je_block_eindeutig_und_aufsteigend() {
for (block in DocumentationCatalog.blocks) {
val orders = block.items.map { it.order }
assertEquals("Block ${block.id}", orders.sorted(), orders)
assertEquals("Block ${block.id}", orders.size, orders.toSet().size)
}
}
@Test
fun wundart_postoperativ_und_dehiszenz_sind_getrennte_punkte() {
val wundart = items("wundart")
assertEquals("Post-operative Wundheilungsstörung", wundart.single { it.id == "wundart_post_operative_wundheilungsstoerung" }.label)
assertEquals("Dehiszenz", wundart.single { it.id == "wundart_dehiszenz" }.label)
assertTrue(wundart.none { it.label.contains("/ Dehiszenz") })
}
@Test
fun wundgrund_unregelmaessig_und_landkartenartig_sind_getrennte_punkte() {
val wundgrund = items("wundgrund_erscheinung")
assertEquals("Unregelmäßig", wundgrund.single { it.id == "wundgrund_unregelmaessig" }.label)
assertEquals("Landkartenartig/amorph", wundgrund.single { it.id == "wundgrund_landkartenartig_amorph" }.label)
assertNull(wundgrund.firstOrNull { it.id == "wundgrund_unregelmaessig_landkartenartig" })
}
@Test
fun wundrandschutz_ist_aus_ziel_entfernt() {
assertTrue(items("ziel").none { it.label.contains("Wundrandschutz") })
}
@Test
fun wundrandschutz_ist_eine_einzelne_freitext_box_ohne_ja_nein() {
val ev = items("empfohlene_wundversorgung")
val wrs = ev.single { it.id == "empfohlene_wundversorgung_wundrandschutz" }
assertEquals("Wundrandschutz", wrs.label)
assertTrue(wrs.isFreeText)
assertNull(ev.firstOrNull { it.id == "empfohlene_wundversorgung_hinweis_wundrandschutz" })
assertNull(ev.firstOrNull { it.id == "empfohlene_wundversorgung_wundrandschutz_ja" })
assertNull(ev.firstOrNull { it.id == "empfohlene_wundversorgung_wundrandschutz_nein" })
// "Wie oft pro Woche" bleibt als eigene Checkbox erhalten.
assertNotNull(ev.firstOrNull { it.id == "empfohlene_wundversorgung_wundrandschutz_tage_pro_woche" })
}
@Test
fun granudacyn_ist_einfache_checkbox_und_freitext_ein_eigener_punkt_direkt_darunter() {
val ev = items("empfohlene_wundversorgung")
val granudacyn = ev.single { it.id == "empfohlene_wundversorgung_granudacyn" }
val freitext = ev.single { it.id == "empfohlene_wundversorgung_granudacyn_freitext" }
assertFalse(granudacyn.isFreeText)
assertTrue(freitext.isFreeText)
assertEquals(DocumentationCatalog.FREE_TEXT_LABEL, freitext.label)
assertEquals(granudacyn.order + 1, freitext.order)
}
}
@@ -0,0 +1,81 @@
package de.medapp.wunddoku.media
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
/** Datenschutz/Sicherheit: kein Ausbrechen aus dem privaten Ordner per Path-Traversal. */
class SafePathsTest {
@get:Rule
val temp = TemporaryFolder()
private fun assertRejected(name: String) {
val root = temp.newFolder()
try {
SafePaths.resolveChild(root, name)
fail("'$name' hätte abgelehnt werden müssen")
} catch (expected: IllegalArgumentException) {
// erwartet
}
}
@Test
fun normale_namen_werden_unterhalb_des_wurzelordners_aufgeloest() {
val root = temp.newFolder()
val result = SafePaths.resolveChild(root, "3f2a-uuid.jpg")
assertEquals(File(root, "3f2a-uuid.jpg"), result)
assertTrue(result.canonicalPath.startsWith(root.canonicalPath + File.separator))
}
@Test
fun uebliche_namen_mit_punkten_und_bindestrichen_sind_erlaubt() {
val root = temp.newFolder()
SafePaths.resolveChild(root, "2026-08-19_Max.Mustermann.v2.pdf")
SafePaths.resolveChild(root, "..hidden") // beginnt mit Punkten, ist aber kein Traversal
}
@Test
fun path_traversal_wird_abgelehnt() {
assertRejected("..")
assertRejected("../geheim")
assertRejected("../../etc/passwd")
assertRejected("a/../../b")
}
@Test
fun pfadtrenner_und_leere_namen_werden_abgelehnt() {
assertRejected("")
assertRejected(" ")
assertRejected(".")
assertRejected("unter/ordner.jpg")
assertRejected("unter\\ordner.jpg")
assertRejected("/absolut.jpg")
assertRejected("nullbyte\u0000.jpg")
}
@Test
fun symbolischer_link_aus_dem_ordner_heraus_wird_abgelehnt() {
val root = temp.newFolder()
val outside = temp.newFolder()
val link = File(root, "link")
try {
java.nio.file.Files.createSymbolicLink(link.toPath(), outside.toPath())
} catch (e: Exception) {
return // Dateisystem ohne Symlink-Unterstützung: Test nicht anwendbar
}
try {
SafePaths.resolveChild(root, "link")
fail("Symlink aus dem Ordner heraus hätte abgelehnt werden müssen")
} catch (expected: IllegalArgumentException) {
// erwartet
}
}
}
@@ -172,10 +172,156 @@ class DocumentationToPdfExportModelMapperTest {
val items = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items
assertEquals(PdfExportItem("Freitext", isFreeText = true, freeTextValue = "Rötung", inputSpec = ""), items[0])
// Generischer "Freitext": Bezeichnung wird nicht gedruckt (showLabel = false).
assertEquals(
PdfExportItem("Freitext", isFreeText = true, freeTextValue = "Rötung", inputSpec = "", showLabel = false),
items[0],
)
assertEquals(PdfExportItem("Maße", isFreeText = false, freeTextValue = "3|4", inputSpec = "SPEC"), items[1])
}
// --- Änderungswünsche: Freitext, Hinweisgruppen, Stile, eigene Seite ------------------
@Test
fun benannter_freitext_behaelt_seine_bezeichnung() {
val doc = documentation(
listOf(
block("a", 1, items = listOf(
item("w", 1, checked = true, label = "Wundrandschutz", isFreeText = true, freeTextValue = "Cavilon"),
)),
)
)
assertTrue(DocumentationToPdfExportModelMapper.map(doc).blocks.single().items.single().showLabel)
}
@Test
fun gruppenhinweis_ohne_angehakten_eintrag_der_gruppe_entfaellt() {
// Größe: "Zu versorgendes Areal" darf nicht erscheinen, wenn nur die Maße ausgefüllt sind.
val doc = documentation(
listOf(
block("groesse", 1, items = listOf(
item("h_masse", 1, checked = true, label = "Maße", isStaticHint = true),
item("masse", 2, checked = true, label = "Maße (Länge / Breite / Tiefe)", freeTextValue = "3|4|1", inputSpec = "SPEC"),
item("h_areal", 3, checked = true, label = "Zu versorgendes Areal", isStaticHint = true),
item("areal", 4, checked = false, label = "Zu versorgendes Areal"),
item("areal_ft", 5, checked = false, label = "Freitext für zu versorgendes Areal", isFreeText = true),
// generischer Freitext des Blocks gehört zu keiner Gruppe
item("groesse_freetext", 6, checked = false, label = "Freitext", isFreeText = true),
)),
)
)
val labels = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items.map { it.label }
assertEquals(listOf("Maße", "Maße (Länge / Breite / Tiefe)"), labels)
}
@Test
fun generischer_freitext_laesst_gruppenhinweis_nicht_erscheinen() {
val doc = documentation(
listOf(
block("groesse", 1, items = listOf(
item("masse", 1, checked = true, label = "Maße", freeTextValue = "3|4|1", inputSpec = "SPEC"),
item("h_areal", 2, checked = true, label = "Zu versorgendes Areal", isStaticHint = true),
item("areal", 3, checked = false, label = "Areal"),
item("groesse_freetext", 4, checked = true, label = "Freitext", isFreeText = true, freeTextValue = "x"),
)),
)
)
val labels = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items.map { it.label }
assertEquals(listOf("Maße", "Freitext"), labels)
}
@Test
fun gruppenhinweis_mit_angehaktem_eintrag_bleibt_erhalten() {
val doc = documentation(
listOf(
block("a", 1, items = listOf(
item("h", 1, checked = true, label = "Fixieren mit:", isStaticHint = true),
item("m", 2, checked = true, label = "Mullbinde"),
item("k", 3, checked = false, label = "Klebevlies"),
)),
)
)
val labels = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items.map { it.label }
assertEquals(listOf("Fixieren mit:", "Mullbinde"), labels)
}
@Test
fun gruppenhinweis_mit_gleichnamigem_angehaktem_eintrag_wird_nicht_doppelt_gedruckt() {
val doc = documentation(
listOf(
block("a", 1, items = listOf(
item("h", 1, checked = true, label = "Zu versorgendes Areal", isStaticHint = true),
item("areal", 2, checked = true, label = "Zu versorgendes Areal"),
)),
)
)
val labels = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items.map { it.label }
assertEquals(listOf("Zu versorgendes Areal"), labels)
}
@Test
fun abschliessende_hinweise_ohne_folgende_eintraege_bleiben_immer_erhalten() {
val doc = documentation(
listOf(
block("empfohlene_wundversorgung", 1, items = listOf(
item("x", 1, checked = true, label = "Mullbinde"),
item("empfohlene_wundversorgung_hinweis_bedarf", 2, checked = true, label = "+ bei Bedarf", isStaticHint = true),
item("empfohlene_wundversorgung_hinweis_abklaerung", 3, checked = true, label = "Generell: ...", isStaticHint = true),
item("empfohlene_wundversorgung_freetext", 4, checked = false, label = "Freitext", isFreeText = true),
)),
)
)
val labels = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items.map { it.label }
assertEquals(listOf("Mullbinde", "+ bei Bedarf", "Generell: ..."), labels)
}
@Test
fun stile_werden_anhand_der_definitions_id_vergeben() {
val doc = documentation(
listOf(
block("empfohlene_wundversorgung", 1, items = listOf(
item("empfohlene_wundversorgung_hinweis_reinigen_mit", 1, checked = true, label = "Reinigen mit:", isStaticHint = true),
item("empfohlene_wundversorgung_nacl", 2, checked = true, label = "NaCl 0,9%"),
item("empfohlene_wundversorgung_primaer_abdecken_austamponieren", 3, checked = true, label = "Primär Wunde abdecken/austamponieren"),
item("empfohlene_wundversorgung_hinweis_abklaerung", 4, checked = true, label = "Generell: ...", isStaticHint = true),
)),
)
)
val styles = DocumentationToPdfExportModelMapper.map(doc).blocks.single().items.map { it.style }
assertEquals(
listOf(PdfItemStyle.SUBHEADING, PdfItemStyle.NORMAL, PdfItemStyle.SUBHEADING, PdfItemStyle.WARNING),
styles,
)
}
@Test
fun empfohlene_wundversorgung_beginnt_auf_eigener_seite_andere_bloecke_nicht() {
val doc = documentation(
listOf(
block("weitere_ueberlegung", 1, items = listOf(item("a", 1, checked = true))),
block("empfohlene_wundversorgung", 2, items = listOf(item("b", 1, checked = true))),
block("ziel", 3, items = listOf(item("c", 1, checked = true))),
)
)
val blocks = DocumentationToPdfExportModelMapper.map(doc).blocks
assertEquals(listOf(false, true, false), blocks.map { it.startsOnNewPage })
}
// --- Sortierung ----------------------------------------------------------------------
@Test
@@ -0,0 +1,157 @@
package de.medapp.wunddoku.pdf
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
/**
* Datenschutz-Löschkonzept: PDF-/Bildexporte (enthalten Patientendaten) und liegengebliebene
* Kameraaufnahmen dürfen nicht dauerhaft im Speicher bleiben.
*/
class ExportedFilesCleanerTest {
@get:Rule
val temp = TemporaryFolder()
private lateinit var filesDir: File
private lateinit var cacheDir: File
private val day = 24L * 60 * 60 * 1000
private val now = 1_800_000_000_000L // fester "Jetzt"-Zeitpunkt
@Before
fun setUp() {
filesDir = temp.newFolder("files")
cacheDir = temp.newFolder("cache")
}
private fun cleaner() = InternalExportedFilesCleaner(filesDir, cacheDir, now = { now })
private fun file(path: File, ageMillis: Long = 0): File {
path.parentFile!!.mkdirs()
path.writeText("x")
path.setLastModified(now - ageMillis)
return path
}
private fun pdf(docId: String, ageMillis: Long = 0) = file(File(filesDir, "pdf/$docId/bericht.pdf"), ageMillis)
private fun image(docId: String, ageMillis: Long = 0) = file(File(filesDir, "pdf_images/$docId/seite_01.jpg"), ageMillis)
private fun capture(name: String, ageMillis: Long) = file(File(cacheDir, "camera/$name"), ageMillis)
// --- Löschen mit der Dokumentation ---------------------------------------------------
@Test
fun deleteExportsFor_entfernt_pdf_und_bildexporte_der_dokumentation() = runBlocking {
val p = pdf("doc-1")
val i = image("doc-1")
cleaner().deleteExportsFor("doc-1")
assertFalse(p.exists())
assertFalse(i.exists())
assertFalse(File(filesDir, "pdf/doc-1").exists())
}
@Test
fun deleteExportsFor_laesst_andere_dokumentationen_unberuehrt() = runBlocking {
val other = pdf("doc-2")
cleaner().deleteExportsFor("doc-1")
assertTrue(other.exists())
}
@Test
fun deleteExportsFor_ohne_vorhandene_ordner_wirft_nicht() = runBlocking {
cleaner().deleteExportsFor("gibt-es-nicht")
}
@Test
fun deleteExportsFor_loescht_nie_ausserhalb_der_exportordner() = runBlocking {
val secret = file(File(filesDir, "secret/patientendaten.db"))
val photos = file(File(filesDir, "photos/doc-1/foto.jpg"))
File(filesDir, "pdf").mkdirs()
cleaner().deleteExportsFor("../secret")
cleaner().deleteExportsFor("..")
cleaner().deleteExportsFor("../photos/doc-1")
assertTrue(secret.exists())
assertTrue(photos.exists())
}
@Test
fun deleteExportsFor_beruehrt_fotos_und_signaturen_der_dokumentation_nicht() = runBlocking {
val photo = file(File(filesDir, "photos/doc-1/foto.jpg"))
val signature = file(File(filesDir, "signatures/doc-1/sig.png"))
pdf("doc-1")
cleaner().deleteExportsFor("doc-1")
assertTrue("Löschen der Exporte ist nicht Löschen der Dokumentation", photo.exists() && signature.exists())
}
// --- Aufbewahrungsfristen ------------------------------------------------------------
@Test
fun deleteStaleFiles_entfernt_exporte_aelter_als_7_tage_und_behaelt_frische() = runBlocking {
val old = pdf("doc-old", ageMillis = 8 * day)
val oldImage = image("doc-old", ageMillis = 8 * day)
val fresh = pdf("doc-fresh", ageMillis = 1 * day)
cleaner().deleteStaleFiles()
assertFalse(old.exists())
assertFalse(oldImage.exists())
assertTrue(fresh.exists())
}
@Test
fun deleteStaleFiles_entfernt_leere_unterordner() = runBlocking {
pdf("doc-old", ageMillis = 30 * day)
cleaner().deleteStaleFiles()
assertFalse(File(filesDir, "pdf/doc-old").exists())
}
@Test
fun deleteStaleFiles_entfernt_liegengebliebene_kameraaufnahmen_nach_24_stunden() = runBlocking {
val stale = capture("capture_old.jpg", ageMillis = day + 60_000)
val recent = capture("capture_new.jpg", ageMillis = 2 * 60 * 60 * 1000)
cleaner().deleteStaleFiles()
assertFalse("Wundfoto darf nicht dauerhaft im Cache liegen bleiben", stale.exists())
assertTrue("aktuelle Aufnahme wird eventuell noch importiert", recent.exists())
}
@Test
fun deleteStaleFiles_beruehrt_dokumentationsdaten_nie() = runBlocking {
val photo = file(File(filesDir, "photos/doc-1/foto.jpg"), ageMillis = 365 * day)
val signature = file(File(filesDir, "signatures/doc-1/sig.png"), ageMillis = 365 * day)
cleaner().deleteStaleFiles()
assertTrue("Dokumentationsfotos/-signaturen sind keine Exporte", photo.exists() && signature.exists())
}
@Test
fun deleteStaleFiles_ohne_vorhandene_ordner_wirft_nicht() = runBlocking {
cleaner().deleteStaleFiles()
}
@Test
fun none_cleaner_tut_nichts() = runBlocking {
val p = pdf("doc-1", ageMillis = 100 * day)
ExportedFilesCleaner.None.deleteExportsFor("doc-1")
ExportedFilesCleaner.None.deleteStaleFiles()
assertTrue(p.exists())
}
}
@@ -0,0 +1,219 @@
package de.medapp.wunddoku.privacy
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
/**
* Datenschutz-Regressionstests (QA-006) auf Quelltext- und Konfigurationsebene.
*
* Läuft als reiner JVM-Test ohne Gerät und liest die Projektdateien direkt. Er soll verhindern,
* dass später versehentlich etwas eingebaut wird, das die Datenschutz-Zusagen der App bricht
* (Netzwerkzugriff, sensible Logs, öffentlicher Speicher, Backup, zu weite Freigaben).
* Schlägt ein Test fehl, ist das ein Hinweis zum Prüfen - nicht zwingend ein Fehler: dann
* bewusst entscheiden und die Datenschutz-Dokumentation anpassen.
*/
class PrivacySourceScanTest {
private lateinit var mainDir: File
@Before
fun locateSources() {
mainDir = listOf(File("src/main"), File("app/src/main"))
.firstOrNull { File(it, "AndroidManifest.xml").exists() }
?: File("")
assumeTrue("Quellverzeichnis nicht gefunden (Arbeitsverzeichnis: ${File(".").absolutePath})", mainDir.path.isNotEmpty())
}
// --- Hilfsfunktionen -----------------------------------------------------------------
private fun kotlinSources(): List<Pair<String, String>> =
File(mainDir, "java").walkTopDown()
.filter { it.isFile && it.extension == "kt" }
.map { it.relativeTo(mainDir).path to stripComments(it.readText()) }
.toList()
/** Entfernt Kommentare, damit Dokumentationstext keine Fehlalarme auslöst. */
private fun stripComments(source: String): String =
source
.replace(Regex("/\\*[\\s\\S]*?\\*/"), "")
.replace(Regex("//[^\\n]*"), "")
private fun findings(pattern: Regex, sources: List<Pair<String, String>> = kotlinSources()): List<String> =
sources.flatMap { (path, text) ->
pattern.findAll(text).map { "$path: ${it.value}" }.toList()
}
private fun assertNone(description: String, pattern: Regex) {
val found = findings(pattern)
assertTrue("$description - gefunden: $found", found.isEmpty())
}
private fun parseXml(relativePath: String) =
DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(File(mainDir, relativePath))
// --- Logging -------------------------------------------------------------------------
@Test
fun keine_konsolenausgaben_oder_stacktrace_ausgaben() {
assertNone(
"println/printStackTrace/System.out/Timber können Patientendaten in Logs schreiben",
Regex("""\bprintln\s*\(|printStackTrace\s*\(|System\.(out|err)\b|\bTimber\b"""),
)
}
@Test
fun log_aufrufe_enthalten_keine_nachrichten_pfade_uris_oder_personendaten() {
val callStart = Regex("""\bLog\s*\.\s*(v|d|i|w|e|wtf)\s*\(""")
val forbidden = Regex(
"""(?i)\.message\b|localizedMessage|stackTraceToString|getStackTraceString|\buri\b|\bpath\b|absolutePath|fileName|patient|firstName|lastName|birthDate|signer|street|phone"""
)
val violations = mutableListOf<String>()
for ((path, text) in kotlinSources()) {
for (match in callStart.findAll(text)) {
val openIndex = match.range.last
var depth = 0
var end = openIndex
for (i in openIndex until text.length) {
when (text[i]) {
'(' -> depth++
')' -> { depth--; if (depth == 0) { end = i; break } }
}
}
val call = text.substring(match.range.first, end + 1)
if (forbidden.containsMatchIn(call)) violations += "$path: ${call.replace(Regex("\\s+"), " ")}"
}
}
assertTrue("Log-Aufrufe mit potenziell sensiblem Inhalt: $violations", violations.isEmpty())
}
// --- Speicherorte --------------------------------------------------------------------
@Test
fun kein_zugriff_auf_oeffentlichen_oder_externen_speicher() {
assertNone(
"Patientendaten dürfen nur im privaten internen Speicher liegen",
Regex(
"""getExternalFilesDir|getExternalFilesDirs|externalCacheDir|externalMediaDirs|getExternalStorage|Environment\.getExternal|MediaStore\.|WRITE_EXTERNAL_STORAGE|READ_EXTERNAL_STORAGE|MANAGE_EXTERNAL_STORAGE|MODE_WORLD_READABLE|MODE_WORLD_WRITEABLE|DIRECTORY_(PICTURES|DOWNLOADS|DOCUMENTS)"""
),
)
}
@Test
fun keine_nutzung_der_zwischenablage() {
assertNone(
"Die Zwischenablage ist für andere Apps lesbar",
Regex("""ClipboardManager|setPrimaryClip|ClipboardManager"""),
)
}
@Test
fun keine_personendaten_als_text_oder_betreff_in_share_intents() {
assertNone(
"Share-Intents dürfen nur die PDF/Bilder (EXTRA_STREAM), keinen Text mit Patientendaten tragen",
Regex("""EXTRA_TEXT|EXTRA_SUBJECT|EXTRA_EMAIL|EXTRA_CC|EXTRA_BCC|EXTRA_HTML_TEXT"""),
)
}
// --- Netzwerk ------------------------------------------------------------------------
@Test
fun kein_netzwerkcode_im_quelltext() {
assertNone(
"Die App arbeitet offline - kein Netzwerkzugriff",
Regex("""HttpURLConnection|HttpsURLConnection|java\.net\.URL\b|URLConnection|\bSocket\s*\(|okhttp|retrofit|\bktor\b|WebView|firebase|crashlytics|analytics""", RegexOption.IGNORE_CASE),
)
}
@Test
fun keine_netzwerk_bibliotheken_in_den_abhaengigkeiten() {
val gradle = listOf(File("app/build.gradle.kts"), File("build.gradle.kts")).firstOrNull { it.exists() }
assumeTrue("build.gradle.kts nicht gefunden", gradle != null)
val dependencies = gradle!!.readLines()
.filter { it.trim().startsWith("implementation") || it.trim().startsWith("api(") }
val forbidden = Regex("""okhttp|retrofit|\bktor\b|volley|firebase|crashlytics|analytics|sentry|coil|glide|picasso|play-services""", RegexOption.IGNORE_CASE)
val found = dependencies.filter { forbidden.containsMatchIn(it) }
assertTrue("Abhängigkeiten mit Netzwerk-/Tracking-Bezug: $found", found.isEmpty())
}
// --- Manifest ------------------------------------------------------------------------
private fun manifest(): String = File(mainDir, "AndroidManifest.xml").readText()
@Test
fun manifest_fordert_keine_berechtigungen_an() {
val permissions = Regex("""<uses-permission[^>]*>""").findAll(manifest()).map { it.value }.toList()
val requested = permissions.filterNot { it.contains("tools:node=\"remove\"") }
assertTrue("Unnötige Berechtigungen im Manifest: $requested", requested.isEmpty())
}
@Test
fun manifest_entfernt_die_internet_berechtigung_explizit() {
val internet = Regex("""<uses-permission[^>]*android\.permission\.INTERNET[^>]*>""").find(manifest())
assertTrue("INTERNET muss per tools:node=\"remove\" ausgeschlossen sein", internet != null && internet.value.contains("tools:node=\"remove\""))
}
@Test
fun manifest_deaktiviert_backup_und_verlangt_keine_cleartext_oder_debug_freigaben() {
val text = manifest()
assertTrue("allowBackup muss false sein", text.contains("""android:allowBackup="false""""))
assertTrue("dataExtractionRules müssen gesetzt sein", text.contains("android:dataExtractionRules="))
assertTrue(!text.contains("""android:usesCleartextTraffic="true""""))
assertTrue(!text.contains("android:debuggable"))
assertTrue(!text.contains("""android:allowBackup="true""""))
}
@Test
fun nur_die_start_activity_ist_exportiert_und_der_fileprovider_nicht() {
val text = manifest()
assertEquals(
"Genau eine Komponente (MainActivity) darf exportiert sein",
1,
Regex("""android:exported="true"""").findAll(text).count(),
)
val provider = Regex("""<provider[\s\S]*?</provider>""").find(text)!!.value
assertTrue(provider.contains("""android:exported="false""""))
assertTrue(provider.contains("""android:grantUriPermissions="true""""))
}
// --- Backup-Regeln -------------------------------------------------------------------
@Test
fun data_extraction_rules_schliessen_alle_daten_aus_cloud_backup_und_geraetetransfer_aus() {
val doc = parseXml("res/xml/data_extraction_rules.xml")
for (section in listOf("cloud-backup", "device-transfer")) {
val nodes = doc.getElementsByTagName(section)
assertEquals("<$section> muss vorhanden sein", 1, nodes.length)
val element = nodes.item(0) as org.w3c.dom.Element
assertEquals("<$section> darf kein <include> enthalten", 0, element.getElementsByTagName("include").length)
val excluded = (0 until element.getElementsByTagName("exclude").length)
.map { (element.getElementsByTagName("exclude").item(it) as org.w3c.dom.Element).getAttribute("domain") }
.toSet()
assertTrue("<$section> muss database, file, sharedpref ausschließen, war $excluded", excluded.containsAll(setOf("database", "file", "sharedpref")))
}
}
// --- FileProvider-Freigaben ----------------------------------------------------------
@Test
fun fileprovider_gibt_nur_export_und_kameraordner_frei() {
val doc = parseXml("res/xml/file_paths.xml")
val paths = doc.getElementsByTagName("paths").item(0).childNodes
val entries = (0 until paths.length).map { paths.item(it) }
.filterIsInstance<org.w3c.dom.Element>()
.map { it.tagName to it.getAttribute("path") }
val allowed = setOf("files-path" to "pdf/", "files-path" to "pdf_images/", "cache-path" to "camera/")
val unexpected = entries.filterNot { it in allowed }
assertTrue(
"Zu weite FileProvider-Freigaben (nur $allowed erlaubt): $unexpected",
unexpected.isEmpty(),
)
}
}
+212
View File
@@ -0,0 +1,212 @@
# VulneraDoc – Datenschutz- und Datensicherheits-Dokumentation
Stand: Datenschutz-Review QA-006 (Projektstand `dev-QA004` + Maßnahmen aus diesem Review)
> **Hinweis:** Dieses Dokument beschreibt die *technische* Umsetzung des Datenschutzes in der App
> (Datenflüsse, Speicherorte, Schutzmaßnahmen). Es ist keine Rechtsberatung. Die rechtliche
> Bewertung (Rechtsgrundlage, Verzeichnis der Verarbeitungstätigkeiten, ggf. Datenschutz-
> Folgenabschätzung, Auftragsverarbeitung, Einwilligungen) muss durch die verantwortliche Stelle
> bzw. deren Datenschutzbeauftragte erfolgen. Die Abschnitte 9 und 10 liefern dafür die
> technischen Eingangsdaten.
---
## 1. Zweck und Einordnung
VulneraDoc ist eine **Offline-Android-App zur Wunddokumentation**. Pflegekräfte erfassen Patienten,
dokumentieren Wunden (Checklisten, Freitext, Fotos, Unterschriften) und erzeugen daraus ein PDF,
das sie öffnen oder teilen können.
Verarbeitet werden **Gesundheitsdaten** (Wundbefunde, Wundfotos) und weitere personenbezogene Daten.
Gesundheitsdaten zählen nach Art. 9 DSGVO zu den besonderen Kategorien personenbezogener Daten und
verlangen ein erhöhtes Schutzniveau.
**Grundprinzip der App:** Die Daten verlassen das Gerät **ausschließlich auf ausdrückliche
Handlung des Nutzers** (PDF/Bilder öffnen oder teilen). Es gibt keinen Server, keine Cloud, kein
Konto, keine Analyse- oder Absturzberichts-Dienste.
---
## 2. Verarbeitete Daten
| Datenart | Felder / Inhalt | Speicherort (App-intern) |
|---|---|---|
| Patientenstammdaten | Vorname, Nachname, Geburtsdatum, Straße, PLZ, Ort, bis zu 3 Telefonnummern | Datenbank `wunddoku.db` |
| Dokumentation | Erstelldatum, Checklisten-Zustände, Freitexte (Wundbefund, Maßnahmen) | Datenbank |
| Wundfotos | Fotos aus Kamera bzw. Photo Picker | `files/photos/<Dokumentations-ID>/` |
| Unterschriften | Name des Unterzeichners, Unterschrift als PNG | Datenbank + `files/signatures/<Dokumentations-ID>/` |
| Wundversorger-Register | Name und Unterschrift der Pflegekraft (für Wiederverwendung) | Datenbank + `files/wound_carer_signatures/` |
| PDF-Export | Vollständiger Bericht inkl. Stammdaten, Befund, Fotos, Unterschriften | `files/pdf/<Dokumentations-ID>/` |
| Bildexport | Seiten des PDFs als JPEG (zum Teilen als Bilder) | `files/pdf_images/<Dokumentations-ID>/` |
| Kameraaufnahme (temporär) | Foto bis zum Import in die Dokumentation | `cache/camera/` |
Alle Speicherorte liegen im **privaten internen App-Speicher**. Es wird kein öffentlicher
Speicher (Galerie, Downloads, SD-Karte) beschrieben.
**Hinweis Dateiname:** Das PDF heißt `Erstelldatum_Vorname_Nachname_Geburtsdatum.pdf` und enthält
damit personenbezogene Daten. Der Name wird beim Teilen/Öffnen an die gewählte Ziel-App übergeben
(siehe Abschnitt 6, Restrisiko R4).
---
## 3. Berechtigungen
Die App fordert **keine einzige Berechtigung** an.
| Funktion | Technik | Berechtigung nötig? |
|---|---|---|
| Fotos auswählen | System-Photo-Picker | Nein |
| Foto aufnehmen | System-Kamera-App über `ACTION_IMAGE_CAPTURE` (die App greift nicht selbst auf die Kamera zu) | Nein (`CAMERA` nicht deklariert) |
| PDF speichern | Privater App-Speicher | Nein |
| PDF öffnen/teilen | `FileProvider` + Android-Sharesheet | Nein |
| Netzwerk | nicht vorhanden | `INTERNET` ist im Manifest **ausdrücklich entfernt** (`tools:node="remove"`) |
Durchsetzung: Das Manifest entfernt `INTERNET` und `ACCESS_NETWORK_STATE` beim Zusammenführen mit
Bibliotheks-Manifesten. Der Test `PrivacyConfigurationTest` prüft das fertig zusammengeführte
Manifest auf dem Gerät.
---
## 4. Datenflüsse nach außen
Daten verlassen das Gerät nur in diesen Fällen – jeweils durch eine bewusste Aktion des Nutzers:
1. **PDF öffnen** – Übergabe der PDF per `content://`-URI mit *Lese*recht an die gewählte PDF-App.
2. **PDF teilen / Bilder teilen** – Übergabe per Android-Sharesheet mit *Lese*recht, nur
`EXTRA_STREAM` (die Datei). Es wird **kein** Text, Betreff oder Empfänger mit Patientendaten
vorbelegt.
3. **Fotoimport** – Fotos werden *in* die App kopiert (Photo Picker / Kamera); die Originale
verbleiben dort, wo sie der Nutzer gespeichert hat. Beim Import wird das Foto neu als JPEG
kodiert; Metadaten des Originals (z. B. GPS-Position) werden dadurch **nicht** übernommen.
Nicht vorhanden: Netzwerkzugriff, Cloud-Backup, Geräte-zu-Geräte-Transfer, Analyse/Tracking,
Absturzberichte, Werbung, Zwischenablage, Weitergabe an Dritte durch die App selbst.
**Verantwortung nach dem Teilen:** Sobald eine PDF an eine andere App übergeben wurde, liegt der
weitere Umgang außerhalb der Kontrolle von VulneraDoc (z. B. E-Mail-Programm, Messenger,
Cloud-Speicher). Das ist organisatorisch zu regeln (Abschnitt 9).
---
## 5. Technische und organisatorische Schutzmaßnahmen in der App
### 5.1 Speicherung
- Ausschließlich privater interner Speicher (`filesDir`, `cacheDir`); kein `getExternalFilesDir`,
keine `MediaStore`-Nutzung, keine `MODE_WORLD_*`-Dateien.
- Dateien sind für andere Apps nicht lesbar (Android-App-Sandbox, Dateirechte).
- **Pfadsicherheit:** Datei-/Ordnernamen werden über `SafePaths` aufgelöst (kein `../`, keine
Pfadtrenner, keine Symlink-Ausbrüche).
- **Atomares Schreiben der PDF:** Erst temporäre Datei, dann Umbenennen – kein halbfertiges PDF,
eine vorhandene PDF geht bei einem Fehler nicht verloren.
### 5.2 Freigabe über FileProvider (Prinzip der minimalen Freigabe)
Nur diese Ordner können nach außen gegeben werden:
`files/pdf/`, `files/pdf_images/` (Exporte) und `cache/camera/` (Kameraaufnahme).
**Nicht** freigegeben: Originalfotos (`photos/`), Unterschriften (`signatures/`), Datenbank,
Wundversorger-Register. Der `FileProvider` ist nicht exportiert (`exported="false"`), Rechte werden
nur pro Vorgang als Leserecht vergeben.
### 5.3 Backup
- `android:allowBackup="false"` – kein Cloud-/Auto-Backup.
- `data_extraction_rules.xml` schließt für `cloud-backup` **und** `device-transfer` die Bereiche
`database`, `file` und `sharedpref` aus; es gibt kein `<include>`.
- Folge: Beim Gerätewechsel oder nach Deinstallation sind die Daten **nicht** automatisch
wiederherstellbar. Das ist gewollt (Datenschutz), muss aber organisatorisch bekannt sein
(z. B. PDFs als Archiv in der Einrichtungsdokumentation ablegen).
### 5.4 Oberfläche
- `FLAG_SECURE` im Hauptfenster: keine Screenshots, keine Bildschirmaufnahme, keine Vorschau im
App-Wechsler.
### 5.5 Protokollierung (Logs)
- Es gibt keine Konsolenausgaben und keinen `printStackTrace`-Aufruf.
- Es existiert genau ein Log-Aufruf (Fehler beim Fotoimport). Er schreibt **nur den Typ** der
Exception, nie die Fehlermeldung (diese kann Dateipfade oder URIs enthalten), nie Namen oder
Patientendaten.
- Fehlermeldungen in der Oberfläche sind allgemein formuliert und enthalten keine Pfade oder
Exception-Texte.
### 5.6 Löschkonzept
| Auslöser | Was wird gelöscht |
|---|---|
| Dokumentation löschen | Datenbankzeilen (inkl. Checklisten, Foto- und Signatur-Einträge), Fotos, Signaturen **und** zugehörige PDF- und Bildexporte |
| Einzelnes Foto / einzelne Signatur löschen | Datenbankzeile und Datei |
| Patient löschen | Nur möglich, wenn keine Dokumentation mehr existiert |
| Automatisch beim App-Start | PDF-/Bildexporte älter als **7 Tage**; liegengebliebene Kameraaufnahmen älter als **24 Stunden** |
| Deinstallation der App | Alle App-Daten (Android entfernt den privaten Speicher) |
Die automatisch gelöschten Exporte lassen sich jederzeit aus der Dokumentation neu erzeugen.
Die Fristen stehen als Konstanten in `InternalExportedFilesCleaner` und können angepasst werden.
### 5.7 Fehlerbehandlung als Datenschutz-/Integritätsmaßnahme
Fehlerfälle (Speicher voll, defekte Bilder, PDF-Fehler) führen nicht zu Datenverlust oder zu
verwaisten Dateien (siehe Tests aus QA-004): fehlgeschlagene Importe hinterlassen keine
Halbdateien, Datenbank und Dateien bleiben konsistent, Autosave-Fehler werden dem Nutzer gemeldet.
---
## 6. Bekannte Restrisiken und Empfehlungen
| Nr. | Restrisiko | Bewertung | Empfehlung |
|---|---|---|---|
| R1 | **Datenbank und Dateien sind nicht zusätzlich verschlüsselt** (nur Android-Sandbox + Gerätespeicher-Verschlüsselung). Auf gerooteten Geräten oder bei kompromittiertem Gerät sind Daten lesbar. | mittel | Gerät mit Bildschirmsperre und aktivierter Speicherverschlüsselung betreiben (Standard seit Android 10). Bei erhöhtem Schutzbedarf Datenbankverschlüsselung (z. B. SQLCipher mit Schlüssel im Android Keystore) einplanen. |
| R2 | **Keine App-eigene Sperre** (PIN/Biometrie). Wer das entsperrte Gerät hat, sieht die Daten. | mittel | Gerätesperre verbindlich vorschreiben; optional App-Sperre mit `BiometricPrompt` einführen. |
| R3 | **Tastatur-Lernfunktion/Autofill:** Eingaben (Namen, Befund-Freitexte) können von der Bildschirmtastatur oder einem Autofill-Dienst mitgelernt werden. | gering–mittel | Geräte-Richtlinie: nur vertrauenswürdige Tastatur, Personalisierung/Cloud-Funktionen der Tastatur deaktivieren. |
| R4 | **Dateiname des PDFs enthält Name und Geburtsdatum** und ist in Ziel-Apps (Mailanhang, Messenger) sichtbar. | gering–mittel | Fachliche Entscheidung: neutralen Dateinamen (z. B. nur Dokumentations-ID/Datum) anbieten. |
| R5 | **Weitergabe über andere Apps** liegt nach dem Teilen außerhalb der App-Kontrolle. | organisatorisch | Zulässige Übertragungswege festlegen (z. B. nur dienstliches Postfach/Messenger); Mitarbeiter schulen. |
| R6 | **Release-Build ohne Code-Verkleinerung/Obfuskation** (`isMinifyEnabled = false`). | gering | Für die Veröffentlichung R8 aktivieren (bessere Härtung gegen Reverse Engineering; kein Ersatz für Datenschutzmaßnahmen). |
| R7 | **Kein automatischer Abgleich/Backup**: Geräteverlust oder Defekt = Datenverlust. | Verfügbarkeit | Organisatorisches Archiv (z. B. PDFs in die Patientenakte der Einrichtung). |
| R8 | **Kamera-App des Herstellers** speichert Aufnahmen evtl. zusätzlich in der Galerie/Cloud (bei `ACTION_IMAGE_CAPTURE` mit Ziel-URI normalerweise nicht, herstellerabhängig). | gering | Auf Testgeräten prüfen, ob Aufnahmen zusätzlich in der Galerie erscheinen. |
---
## 7. Automatisierte Datenschutz-Tests
| Test | Art | Prüft |
|---|---|---|
| `PrivacySourceScanTest` | JVM | Keine Netzwerk-/Tracking-Bibliotheken, keine `println`/`printStackTrace`, Log-Aufrufe ohne Nachricht/Pfad/Personendaten, kein externer Speicher, keine Zwischenablage, keine Texte in Share-Intents, Manifest ohne Berechtigungen, `allowBackup=false`, nur eine exportierte Komponente, Backup-Regeln, FileProvider-Freigaben |
| `SafePathsTest` | JVM | Schutz vor Path-Traversal |
| `ExportedFilesCleanerTest` | JVM | Löschen der Exporte, Aufbewahrungsfristen, kein Löschen außerhalb der Exportordner |
| `PrivacyConfigurationTest` | Gerät/Emulator | Zusammengeführtes Manifest (Berechtigungen, exportierte Komponenten), Backup, FileProvider-Freigaben, private Dateirechte, `FLAG_SECURE` |
| `DocumentationDeletionPrivacyTest` | Gerät/Emulator | Löschen einer Dokumentation entfernt auch die Exporte |
Diese Tests dienen als Regressionsschutz: Sie schlagen an, wenn künftig z. B. eine Berechtigung,
eine Netzwerkbibliothek oder eine zu weite Freigabe hinzukommt.
---
## 8. Checkliste für Releases
- [ ] Alle Tests grün (insbesondere die Datenschutz-Tests aus Abschnitt 7)
- [ ] Merged Manifest geprüft (Android Studio → `AndroidManifest.xml` → Tab *Merged Manifest*): keine neuen Berechtigungen/exportierten Komponenten
- [ ] Neue Abhängigkeiten auf Netzwerk-/Tracking-Funktionen geprüft
- [ ] Neue Log-Ausgaben enthalten keine Patientendaten, Pfade, URIs oder Exception-Nachrichten
- [ ] Neue Speicherorte liegen im privaten Speicher; neue Freigaben sind in `file_paths.xml` minimal
- [ ] Neue Datenfelder sind in Abschnitt 2 dieses Dokuments ergänzt und im Löschkonzept berücksichtigt
- [ ] Release-Build signiert, nicht debuggable
---
## 9. Organisatorische Hinweise (für die verantwortliche Stelle)
Diese Punkte kann die App nicht technisch lösen:
- **Rechtsgrundlage** der Verarbeitung (Behandlungsvertrag / Dokumentationspflicht, Art. 9 Abs. 2
lit. h DSGVO) und **Verzeichnis der Verarbeitungstätigkeiten** durch die verantwortliche Stelle
- Prüfung, ob eine **Datenschutz-Folgenabschätzung** (Art. 35 DSGVO) erforderlich ist
(Gesundheitsdaten, ggf. in größerem Umfang)
- **Geräterichtlinie:** Bildschirmsperre, Geräteverschlüsselung, Updates, ggf. MDM, Regelung
privater vs. dienstlicher Geräte
- **Übertragungswege** für das Teilen von PDFs, Aufbewahrungs- und Löschfristen im Archiv
- **Betroffenenrechte** (Auskunft, Berichtigung, Löschung): Löschung ist je Dokumentation in der
App möglich; Auskunft über den PDF-Export
- Schulung der Anwender, Meldeweg bei Geräteverlust (Meldepflicht bei Datenpannen, Art. 33 DSGVO)
## 10. Technische Eckdaten für Datenschutzdokumente
- Verarbeitung ausschließlich lokal auf dem Gerät; keine Empfänger durch die App
- Keine Auftragsverarbeiter durch die App (kein Server, keine Cloud, keine Analyse-/Crash-Dienste)
- Keine Datenübermittlung in Drittländer durch die App
- Keine Cookies, kein Tracking, keine Werbe-ID
- Speicherdauer: bis zur Löschung durch den Nutzer; Exporte automatisch nach 7 Tagen
- Zugriffsschutz: Android-App-Sandbox, Gerätesperre; `FLAG_SECURE`
+50
View File
@@ -0,0 +1,50 @@
# QA-006 – Datenschutz-Review: Befunde und Maßnahmen
Geprüft wurde der Projektstand `dev-QA004` (Manifest, Backup-Regeln, FileProvider, Quelltext,
Abhängigkeiten, Speicher-, Lösch- und Exportabläufe). Die App wurde **nicht** auf einem Gerät
dynamisch untersucht (kein Netzwerk-Mitschnitt, kein Test der Hersteller-Kamera-App).
## Bereits gut umgesetzt (keine Änderung nötig)
| Prüfpunkt | Ergebnis |
|---|---|
| Berechtigungen | Keine deklariert; Photo Picker und System-Kamera-App statt eigener Kamera-/Speicherzugriffe |
| Netzwerk | Kein Netzwerkcode, keine Netzwerk-/Tracking-Bibliotheken |
| Backup | `allowBackup=false` und `data_extraction_rules` (Cloud-Backup + Geräte-Transfer) schließen alles aus |
| Speicherort | Nur privater interner Speicher, kein öffentlicher/externer Speicher |
| Bildschirm | `FLAG_SECURE` (keine Screenshots, keine App-Wechsler-Vorschau) |
| Komponenten | Nur `MainActivity` exportiert; `FileProvider` nicht exportiert |
| Share-Intents | Nur Datei (`EXTRA_STREAM`), kein Text/Betreff mit Patientendaten |
| Datenbank | Keine destruktive Migration (kein stiller Datenverlust bei Updates) |
| Metadaten in Fotos | Beim Import neu kodiert → GPS/EXIF des Originals wird nicht übernommen |
| Fehlermeldungen in der UI | Ohne Pfade/Exception-Texte |
## Befunde und umgesetzte Maßnahmen
| Nr. | Befund | Schwere | Maßnahme | Datei(en) |
|---|---|---|---|---|
| B1 | **Log beim Fotoimport schrieb `e.message`.** Bei Dateifehlern enthält die Meldung häufig Pfad bzw. Bild-URI – entgegen dem Kommentar im Code. | mittel | Es wird nur noch der Exception-Typ geloggt. | `DocumentationEditViewModel.kt` |
| B2 | **PDF- und Bildexporte blieben nach dem Löschen einer Dokumentation im Speicher** (enthalten Name, Geburtsdatum, Anschrift, Befund, Fotos). | hoch | Löschen der Dokumentation entfernt jetzt auch `pdf/<id>/` und `pdf_images/<id>/`. | `ExportedFilesCleaner.kt` (neu), `DocumentationRepository.kt`, `AppContainer.kt` |
| B3 | **Exporte hatten keine Aufbewahrungsfrist.** Alte PDFs lagen unbegrenzt im App-Speicher. | mittel | Beim App-Start werden Exporte älter als 7 Tage gelöscht (jederzeit neu erzeugbar). | `ExportedFilesCleaner.kt`, `WunddokuApplication.kt` |
| B4 | **Kameraaufnahmen konnten im Cache liegen bleiben**, wenn die App zwischen Aufnahme und Import beendet wurde. | gering–mittel | Aufnahmen älter als 24 h werden beim App-Start gelöscht (Frist bewusst lang, damit eine noch ausstehende Aufnahme nach einem Neustart importierbar bleibt). | `ExportedFilesCleaner.kt` |
| B5 | **FileProvider gab `photos/` und `signatures/` frei**, obwohl nie darüber geteilt wird. | gering (Prinzip der minimalen Freigabe) | Freigaben auf `pdf/`, `pdf_images/`, `cache/camera/` reduziert. | `file_paths.xml` |
| B6 | **`INTERNET` konnte über eine künftige Bibliothek unbemerkt ins Manifest gelangen.** | gering (Vorsorge) | `INTERNET` und `ACCESS_NETWORK_STATE` werden beim Manifest-Merge explizit entfernt. | `AndroidManifest.xml` |
| B7 | **Pfadauflösung ohne Traversal-Schutz** (`File(dir, name)`). Namen stammen aus der eigenen DB (UUIDs), daher kein akutes Risiko. | gering (Defense-in-Depth) | `SafePaths.resolveChild` in Foto-/Signatur-Speicher und Cleaner. | `SafePaths.kt` (neu), `PhotoStorage.kt`, `SignatureStorage.kt` |
## Offene Punkte (bewusst NICHT geändert – Produktentscheidung nötig)
| Nr. | Thema | Empfehlung |
|---|---|---|
| O1 | Datenbank/Dateien nicht zusätzlich verschlüsselt (Restrisiko R1) | Bei erhöhtem Schutzbedarf SQLCipher + Android Keystore; sonst Geräteverschlüsselung + Sperre vorschreiben |
| O2 | Keine App-Sperre (PIN/Biometrie) (R2) | `BiometricPrompt` beim Start/Wiederöffnen erwägen |
| O3 | PDF-Dateiname enthält Name + Geburtsdatum (R4) | Neutralen Dateinamen als Option; würde `PdfFileNameGenerator` und dessen Tests ändern |
| O4 | Tastatur-Lernfunktion/Autofill (R3) | Geräterichtlinie; in Compose nur eingeschränkt steuerbar |
| O5 | `isMinifyEnabled = false` (R6) | R8 vor Veröffentlichung aktivieren und Release-Build testen |
| O6 | Dynamische Prüfung auf echten Geräten (Netzwerkmitschnitt, Hersteller-Kamera, Galerie-Kopien) | Vor Produktivbetrieb einmal durchführen |
## Wichtig zu den Änderungen B2/B3
B2 und B3 ändern das Verhalten: Exportierte PDFs verschwinden nach 7 Tagen und beim Löschen der
Dokumentation. Wenn die App PDFs länger vorhalten soll, `DEFAULT_EXPORT_MAX_AGE_MILLIS` in
`InternalExportedFilesCleaner` erhöhen. Bereits **geteilte** Kopien in anderen Apps sind davon
natürlich nicht betroffen.