From 8c05a513fe5f6d070ccef8c7cea081f28dfece0b Mon Sep 17 00:00:00 2001 From: NOrtmann1 Date: Tue, 25 Aug 2026 09:50:31 +0200 Subject: [PATCH] PDF-005 --- .../java/de/medapp/wunddoku/pdf/PdfOpener.kt | 89 +++++++++++++++++++ .../edit/DocumentationEditScreen.kt | 33 +++++-- .../edit/DocumentationEditViewModel.kt | 33 +++++-- 3 files changed, 140 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/de/medapp/wunddoku/pdf/PdfOpener.kt diff --git a/app/src/main/java/de/medapp/wunddoku/pdf/PdfOpener.kt b/app/src/main/java/de/medapp/wunddoku/pdf/PdfOpener.kt new file mode 100644 index 0000000..ba4a9f5 --- /dev/null +++ b/app/src/main/java/de/medapp/wunddoku/pdf/PdfOpener.kt @@ -0,0 +1,89 @@ +package de.medapp.wunddoku.pdf + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import androidx.core.content.FileProvider +import java.io.File + +/** + * Öffnet eine bereits erzeugte PDF-Datei im auf dem Gerät verfügbaren Standard-PDF-Reader + * (PDF-005). Bewusst getrennt von [DocumentationPdfExporter]: PDF-Erzeugung (reine + * Datei-/Domain-Logik) und PDF-Anzeige (Android-Intent/Activity-Start) bleiben unterschiedliche + * Verantwortlichkeiten - der Exporter startet niemals eine Activity. + * + * VulneraDoc implementiert ausdrücklich KEINEN eigenen PDF-Viewer; welche konkrete App den + * `ACTION_VIEW`-Intent verarbeitet, entscheidet ausschließlich Android/der Nutzer. + */ +interface PdfOpener { + fun open(file: File): PdfOpenResult +} + +sealed interface PdfOpenResult { + data object Success : PdfOpenResult + + sealed interface Failure : PdfOpenResult { + /** Die referenzierte Datei existiert nicht mehr (z. B. zwischenzeitlich extern entfernt). */ + data object FileNotFound : Failure + + /** FileProvider konnte für die Datei keine content://-Uri erzeugen. */ + data class UriResolutionFailed(val message: String) : Failure + + /** Kein installierter Reader kann application/pdf verarbeiten. */ + data object NoViewerAvailable : Failure + + data class Unexpected(val message: String) : Failure + } +} + +/** Fachliche, für die Anzeige (z. B. Snackbar) geeignete Übersetzung eines [PdfOpenResult.Failure]. */ +fun PdfOpenResult.Failure.toUserMessage(): String = when (this) { + PdfOpenResult.Failure.FileNotFound -> + "Die PDF-Datei wurde nicht gefunden. Bitte die Dokumentation erneut exportieren." + is PdfOpenResult.Failure.UriResolutionFailed -> + "Die PDF-Datei konnte nicht zur Anzeige freigegeben werden." + PdfOpenResult.Failure.NoViewerAvailable -> + "Auf diesem Gerät ist keine App zum Anzeigen von PDF-Dateien installiert." + is PdfOpenResult.Failure.Unexpected -> + "Die PDF-Datei konnte nicht geöffnet werden." +} + +class AndroidPdfOpener(private val context: Context) : PdfOpener { + + override fun open(file: File): PdfOpenResult { + if (!file.exists()) { + return PdfOpenResult.Failure.FileNotFound + } + + val authority = "${context.packageName}.fileprovider" + val uri = try { + FileProvider.getUriForFile(context, authority, file) + } catch (e: IllegalArgumentException) { + // Wird von FileProvider geworfen, wenn die Datei außerhalb der in file_paths.xml + // freigegebenen Verzeichnisse liegt oder die Authority nicht passt. + return PdfOpenResult.Failure.UriResolutionFailed(e.message ?: "Uri konnte nicht erzeugt werden.") + } + + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/pdf") + // Ausschließlich Lese-, KEINE Schreibberechtigung für den externen Reader. + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + return try { + context.startActivity(intent) + PdfOpenResult.Success + } catch (e: ActivityNotFoundException) { + // Bewusst KEIN vorheriges intent.resolveActivity(...) - das würde auf Android 11+ + // durch Package-Visibility-Einschränkungen ohne zusätzliche -Deklaration + // im Manifest fälschlich "kein Reader" melden können, auch wenn tatsächlich einer + // installiert ist. Der eigentliche startActivity()-Aufruf unterliegt dieser + // Einschränkung nicht - ActivityNotFoundException ist hier die zuverlässige Quelle. + PdfOpenResult.Failure.NoViewerAvailable + } catch (e: SecurityException) { + PdfOpenResult.Failure.Unexpected(e.message ?: "Zugriff auf die PDF-Datei wurde verweigert.") + } catch (e: Exception) { + PdfOpenResult.Failure.Unexpected(e.message ?: "Unbekannter Fehler beim Öffnen der PDF-Datei.") + } + } +} diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt index 51bcef4..2253187 100644 --- a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt +++ b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -56,6 +57,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle @@ -67,7 +69,9 @@ import de.medapp.wunddoku.R import de.medapp.wunddoku.domain.model.CheckItem import de.medapp.wunddoku.domain.model.DocumentationBlock import de.medapp.wunddoku.domain.model.Photo -import kotlinx.coroutines.launch +import de.medapp.wunddoku.pdf.AndroidPdfOpener +import de.medapp.wunddoku.pdf.PdfOpenResult +import de.medapp.wunddoku.pdf.toUserMessage /** * Bearbeitungsansicht für eine Wunddokumentation. Zeigt einen Block nach dem anderen mit @@ -113,15 +117,30 @@ fun DocumentationEditScreen( contract = ActivityResultContracts.PickMultipleVisualMedia(), ) { uris: List -> viewModel.onPhotosPicked(uris) } - // Für die kurze Erfolgs-/Fehlermeldung nach einem PDF-Exportversuch (rein zum manuellen - // Testen von PDF-001/002 - Öffnen/Teilen der Datei folgt erst in PDF-005/006). + // Für die Erfolgs-/Fehlermeldung nach einem PDF-Exportversuch (PDF-001/002) inkl. optionaler + // "ANZEIGEN"-Aktion (PDF-005). PdfOpener wird bewusst hier in der Compose-Schicht instanziiert + // (mit dem Activity-Context aus LocalContext, analog zum Photo-Picker-Launcher oben) - das + // Starten einer externen Activity ist eine UI-/Plattform-Aktion, keine Repository-Aufgabe. + val context = LocalContext.current + val pdfOpener = remember(context) { AndroidPdfOpener(context) } val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(uiState.pdfExportMessage) { - val message = uiState.pdfExportMessage - if (message != null) { - snackbarHostState.showSnackbar(message) - viewModel.dismissPdfExportMessage() + val message = uiState.pdfExportMessage ?: return@LaunchedEffect + val exportedFile = uiState.lastExportedPdfFile + // "ANZEIGEN" wird ausschließlich angeboten, wenn tatsächlich eine erfolgreich erzeugte + // Datei vorliegt - bei einem fehlgeschlagenen Export ist exportedFile null und die + // Snackbar zeigt keine Aktion an. + val result = snackbarHostState.showSnackbar( + message = message, + actionLabel = if (exportedFile != null) "ANZEIGEN" else null, + ) + if (result == SnackbarResult.ActionPerformed && exportedFile != null) { + when (val openResult = pdfOpener.open(exportedFile)) { + PdfOpenResult.Success -> Unit + is PdfOpenResult.Failure -> snackbarHostState.showSnackbar(openResult.toUserMessage()) + } } + viewModel.dismissPdfExportMessage() } Scaffold( diff --git a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt index 0758ea0..18b97ab 100644 --- a/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt +++ b/app/src/main/java/de/medapp/wunddoku/ui/documentation/edit/DocumentationEditViewModel.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.io.File data class DocumentationEditUiState( val documentation: Documentation? = null, @@ -39,6 +40,14 @@ data class DocumentationEditUiState( /** Kurze, fachlich übersetzte Erfolgs-/Fehlermeldung nach einem Exportversuch - einmalig als * Snackbar angezeigt (s. Screen), danach über [DocumentationEditViewModel.dismissPdfExportMessage] geleert. */ val pdfExportMessage: String? = null, + /** + * Die zuletzt erfolgreich erzeugte PDF-Datei dieser Dokumentation (PDF-005: "ANZEIGEN" in + * der Snackbar). `null` sowohl vor dem ersten Export als auch nach einem fehlgeschlagenen + * Export - "ANZEIGEN" wird nur angeboten, wenn hier tatsächlich eine Datei vorliegt. Wird + * bei jedem neuen Exportversuch zunächst zurückgesetzt, damit niemals eine veraltete Datei + * referenziert werden kann, während ein neuer Export läuft. + */ + val lastExportedPdfFile: File? = null, ) { val blockCount: Int get() = documentation?.blocks?.size ?: 0 } @@ -237,16 +246,24 @@ class DocumentationEditViewModel( */ fun exportPdf() { viewModelScope.launch { - _uiState.update { it.copy(pdfExportInProgress = true, pdfExportMessage = null) } + _uiState.update { + it.copy(pdfExportInProgress = true, pdfExportMessage = null, lastExportedPdfFile = null) + } val result = repository.exportPdf(documentationId) _uiState.update { - it.copy( - pdfExportInProgress = false, - pdfExportMessage = when (result) { - is PdfExportResult.Success -> "PDF erzeugt: ${result.file.name}" - is PdfExportResult.Failure -> "PDF-Erzeugung fehlgeschlagen: ${result.error.toUserMessage()}" - }, - ) + when (result) { + is PdfExportResult.Success -> it.copy( + pdfExportInProgress = false, + pdfExportMessage = "PDF erfolgreich erstellt", + lastExportedPdfFile = result.file, + ) + + is PdfExportResult.Failure -> it.copy( + pdfExportInProgress = false, + pdfExportMessage = "PDF-Erzeugung fehlgeschlagen: ${result.error.toUserMessage()}", + lastExportedPdfFile = null, + ) + } } } }