PDF/MEDIA #5
@@ -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 <queries>-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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-7
@@ -40,6 +40,7 @@ import androidx.compose.material3.OutlinedTextField
|
|||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
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.Color
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.Lifecycle
|
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.CheckItem
|
||||||
import de.medapp.wunddoku.domain.model.DocumentationBlock
|
import de.medapp.wunddoku.domain.model.DocumentationBlock
|
||||||
import de.medapp.wunddoku.domain.model.Photo
|
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
|
* Bearbeitungsansicht für eine Wunddokumentation. Zeigt einen Block nach dem anderen mit
|
||||||
@@ -113,16 +117,31 @@ fun DocumentationEditScreen(
|
|||||||
contract = ActivityResultContracts.PickMultipleVisualMedia(),
|
contract = ActivityResultContracts.PickMultipleVisualMedia(),
|
||||||
) { uris: List<Uri> -> viewModel.onPhotosPicked(uris) }
|
) { uris: List<Uri> -> viewModel.onPhotosPicked(uris) }
|
||||||
|
|
||||||
// Für die kurze Erfolgs-/Fehlermeldung nach einem PDF-Exportversuch (rein zum manuellen
|
// Für die Erfolgs-/Fehlermeldung nach einem PDF-Exportversuch (PDF-001/002) inkl. optionaler
|
||||||
// Testen von PDF-001/002 - Öffnen/Teilen der Datei folgt erst in PDF-005/006).
|
// "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() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
LaunchedEffect(uiState.pdfExportMessage) {
|
LaunchedEffect(uiState.pdfExportMessage) {
|
||||||
val message = uiState.pdfExportMessage
|
val message = uiState.pdfExportMessage ?: return@LaunchedEffect
|
||||||
if (message != null) {
|
val exportedFile = uiState.lastExportedPdfFile
|
||||||
snackbarHostState.showSnackbar(message)
|
// "ANZEIGEN" wird ausschließlich angeboten, wenn tatsächlich eine erfolgreich erzeugte
|
||||||
viewModel.dismissPdfExportMessage()
|
// 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(
|
Scaffold(
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
|||||||
+23
-6
@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
data class DocumentationEditUiState(
|
data class DocumentationEditUiState(
|
||||||
val documentation: Documentation? = null,
|
val documentation: Documentation? = null,
|
||||||
@@ -39,6 +40,14 @@ data class DocumentationEditUiState(
|
|||||||
/** Kurze, fachlich übersetzte Erfolgs-/Fehlermeldung nach einem Exportversuch - einmalig als
|
/** Kurze, fachlich übersetzte Erfolgs-/Fehlermeldung nach einem Exportversuch - einmalig als
|
||||||
* Snackbar angezeigt (s. Screen), danach über [DocumentationEditViewModel.dismissPdfExportMessage] geleert. */
|
* Snackbar angezeigt (s. Screen), danach über [DocumentationEditViewModel.dismissPdfExportMessage] geleert. */
|
||||||
val pdfExportMessage: String? = null,
|
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
|
val blockCount: Int get() = documentation?.blocks?.size ?: 0
|
||||||
}
|
}
|
||||||
@@ -237,16 +246,24 @@ class DocumentationEditViewModel(
|
|||||||
*/
|
*/
|
||||||
fun exportPdf() {
|
fun exportPdf() {
|
||||||
viewModelScope.launch {
|
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)
|
val result = repository.exportPdf(documentationId)
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
when (result) {
|
||||||
|
is PdfExportResult.Success -> it.copy(
|
||||||
pdfExportInProgress = false,
|
pdfExportInProgress = false,
|
||||||
pdfExportMessage = when (result) {
|
pdfExportMessage = "PDF erfolgreich erstellt",
|
||||||
is PdfExportResult.Success -> "PDF erzeugt: ${result.file.name}"
|
lastExportedPdfFile = result.file,
|
||||||
is PdfExportResult.Failure -> "PDF-Erzeugung fehlgeschlagen: ${result.error.toUserMessage()}"
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is PdfExportResult.Failure -> it.copy(
|
||||||
|
pdfExportInProgress = false,
|
||||||
|
pdfExportMessage = "PDF-Erzeugung fehlgeschlagen: ${result.error.toUserMessage()}",
|
||||||
|
lastExportedPdfFile = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user