PDF generation: complete the new system

Use the chosen filename, fix errors on sharing, save on a separate thread
This commit is contained in:
Pierre-Yves Nicolas
2025-07-04 20:34:34 +02:00
parent 80bc1affb8
commit 7b2e60ee14
5 changed files with 140 additions and 80 deletions

View File

@@ -15,6 +15,14 @@
</SelectionState>
<SelectionState runConfigName="MainActivity">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-07-03T06:58:49.513127900Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=99021FFAZ009KN" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>

View File

@@ -15,8 +15,8 @@
package org.mydomain.myscan
import android.content.Intent
import android.content.pm.PackageManager
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Bundle
import android.os.Environment
import android.util.Log
@@ -29,6 +29,11 @@ import androidx.compose.runtime.getValue
import androidx.core.content.FileProvider
import androidx.core.net.toFile
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import org.mydomain.myscan.ui.theme.MyScanTheme
import org.mydomain.myscan.view.CameraScreen
import org.mydomain.myscan.view.DocumentScreen
@@ -62,12 +67,13 @@ class MainActivity : ComponentActivity() {
initialPage = screen.initialPage,
imageLoader = { id -> viewModel.getBitmap(id) },
toCameraScreen = { viewModel.navigateTo(Screen.Camera) },
// TODO Save and share files with the filename chosen by the user
pdfActions = PdfGenerationActions(
generatePdf = viewModel::generatePdf,
onShare = { uri -> sharePdf(uri) },
onSave = { uri -> savePdf(uri) },
onOpen = { uri -> savePdf(uri) /* TODO Open */}
startGeneration = viewModel::startPdfGeneration,
cancelGeneration = viewModel::cancelPdfGeneration,
setFilename = viewModel::setFilename,
generatedPdfFlow = viewModel.generatedPdf,
sharePdf = { sharePdf(viewModel.getFinalPdf()) },
savePdf = { savePdf(viewModel.getFinalPdf()) },
),
onStartNew = {
viewModel.startNewDocument()
@@ -80,32 +86,63 @@ class MainActivity : ComponentActivity() {
}
}
private fun sharePdf(fileUri: Uri) {
val fileUri = FileProvider.getUriForFile(
this,
"${applicationContext.packageName}.fileprovider",
fileUri.toFile()
)
private fun sharePdf(generatedPdf: GeneratedPdf?) {
if (generatedPdf == null)
return
val file = generatedPdf.uri.toFile()
val authority = "${applicationContext.packageName}.fileprovider"
val fileUri = FileProvider.getUriForFile(this, authority, file)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "application/pdf"
putExtra(Intent.EXTRA_STREAM, fileUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(Intent.createChooser(shareIntent, "Share PDF"))
val chooser = Intent.createChooser(shareIntent, "Share PDF")
val resInfoList = packageManager.queryIntentActivities(chooser, PackageManager.MATCH_DEFAULT_ONLY)
for (resInfo in resInfoList) {
val packageName = resInfo.activityInfo.packageName
grantUriPermission(packageName, fileUri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(chooser)
}
private fun savePdf(fileUri: Uri) {
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
private fun savePdf(generatedPdf: GeneratedPdf?) {
if (generatedPdf == null)
return
val appScope = CoroutineScope(Dispatchers.IO)
val context = this
appScope.launch {
try {
val downloadsDir =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
if (!downloadsDir.exists()) {
downloadsDir.mkdirs()
}
val generatedFile = fileUri.toFile()
val generatedFile = generatedPdf.uri.toFile()
val targetFile = File(downloadsDir, generatedFile.name)
// TODO Handle case where the target file already exists (choose a unique name)
generatedFile.copyTo(targetFile)
withContext(Dispatchers.Main) {
// TODO Display a link to the file
Toast.makeText(context, "Saved PDF in Downloads", Toast.LENGTH_SHORT).show()
}
suspendCancellableCoroutine { continuation ->
MediaScannerConnection.scanFile(
this, arrayOf(targetFile.absolutePath), arrayOf("application/pdf"), null
)
Toast.makeText(this, "Saved PDF in Downloads", Toast.LENGTH_SHORT).show()
context,
arrayOf(targetFile.absolutePath),
arrayOf("application/pdf")
) { _, _ -> continuation.resume(Unit) {} }
}
} catch (e: Exception) {
Log.e("MyScan", "Failed to save PDF", e)
withContext(Dispatchers.Main) {
Toast.makeText(context, "Failed to save PDF", Toast.LENGTH_SHORT).show()
}
}
}
}
private fun initLibraries() {

View File

@@ -19,12 +19,14 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.camera.core.ImageProxy
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -35,7 +37,6 @@ import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStream
class MainViewModel(
private val imageSegmentationService: ImageSegmentationService,
@@ -194,20 +195,52 @@ class MainViewModel(
return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
}
fun createPdf(outputStream: OutputStream) {
val jpegs = imageRepository.imageIds().asSequence()
private suspend fun generatePdf(): GeneratedPdf = withContext(Dispatchers.IO) {
val imageIds = imageRepository.imageIds()
val file = File(pdfDir, "${System.currentTimeMillis()}.pdf")
val jpegs = imageIds.asSequence()
.map { id -> imageRepository.getContent(id) }
.filterNotNull()
writePdfFromJpegs(jpegs, outputStream)
}
suspend fun generatePdf(): GeneratedPdf = withContext(Dispatchers.IO) {
val pageCount = imageRepository.imageIds().size
val file = File(pdfDir,"${System.currentTimeMillis()}.pdf")
createPdf(FileOutputStream(file))
writePdfFromJpegs(jpegs, FileOutputStream(file))
val sizeBytes = file.length()
val uri = file.toUri()
return@withContext GeneratedPdf(uri, sizeBytes, pageCount)
return@withContext GeneratedPdf(uri, sizeBytes, imageIds.size)
}
private val _generatedPdf = MutableStateFlow<GeneratedPdf?>(null)
val generatedPdf: StateFlow<GeneratedPdf?> = _generatedPdf
private var generationJob: Job? = null
private var desiredFilename: String = ""
fun setFilename(name: String) {
desiredFilename = name
}
fun startPdfGeneration() {
if (_generatedPdf.value != null) return
generationJob = viewModelScope.launch {
val result = generatePdf()
_generatedPdf.value = result
}
}
fun cancelPdfGeneration() {
generationJob?.cancel()
_generatedPdf.value = null
}
fun getFinalPdf(): GeneratedPdf? {
val temp = _generatedPdf.value ?: return null
val tempFile = temp.uri.toFile()
val newFile = File(tempFile.parentFile, desiredFilename)
if (tempFile.absolutePath != newFile.absolutePath) {
if (newFile.exists()) newFile.delete()
val success = tempFile.renameTo(newFile)
if (!success) return null
_generatedPdf.value = GeneratedPdf(uri = newFile.toUri(), temp.sizeInBytes, temp.pageCount)
}
return _generatedPdf.value
}
}
@@ -218,8 +251,10 @@ data class GeneratedPdf(
)
data class PdfGenerationActions(
val generatePdf: suspend () -> GeneratedPdf?,
val onShare: (Uri) -> Unit,
val onSave: (Uri) -> Unit,
val onOpen: (Uri) -> Unit
val startGeneration: () -> Unit,
val cancelGeneration: () -> Unit,
val setFilename: (String) -> Unit,
val generatedPdfFlow: StateFlow<GeneratedPdf?>,
val sharePdf: () -> Unit,
val savePdf: () -> Unit
)

View File

@@ -63,6 +63,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.MutableStateFlow
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
import org.mydomain.myscan.PdfGenerationActions
@@ -289,7 +290,10 @@ fun DocumentScreenPreview() {
}
},
toCameraScreen = {},
pdfActions = PdfGenerationActions({ null }, {}, {}, {}),
pdfActions = PdfGenerationActions(
{}, {}, {},
MutableStateFlow(null),
{}, {}),
onStartNew = {},
onDeleteImage = { _ -> {} }
)

View File

@@ -30,10 +30,10 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -41,9 +41,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.mydomain.myscan.GeneratedPdf
import org.mydomain.myscan.PdfGenerationActions
import org.mydomain.myscan.ui.theme.MyScanTheme
@@ -57,52 +54,37 @@ fun PdfGenerationDialogWrapper(
pdfActions: PdfGenerationActions,
) {
var filename by remember { mutableStateOf(defaultFilename()) }
var isGenerating by remember { mutableStateOf(true) }
var pdf by remember { mutableStateOf<GeneratedPdf?>(null) }
val coroutineScope = rememberCoroutineScope()
var job by remember { mutableStateOf<Job?>(null) }
val generatedPdf by pdfActions.generatedPdfFlow.collectAsState()
LaunchedEffect(Unit) {
job = coroutineScope.launch {
try {
val result = pdfActions.generatePdf()
pdf = result
isGenerating = false
} catch (_: CancellationException) {
// Cancelled
} catch (_: Exception) {
// Error
isGenerating = false
}
}
pdfActions.setFilename(filename)
pdfActions.startGeneration()
}
PdfGenerationDialog(
filename = filename,
onFilenameChange = { filename = it },
isGenerating = isGenerating,
pdf = pdf,
onFilenameChange = {
filename = it
pdfActions.setFilename(it)
},
pdf = generatedPdf,
onDismiss = {
job?.cancel()
pdfActions.cancelGeneration()
onDismiss()
},
onShare = { pdf?.uri?.let(pdfActions.onShare) },
onSave = { pdf?.uri?.let(pdfActions.onSave) },
onOpen = { pdf?.uri?.let(pdfActions.onOpen) }
onShare = { pdfActions.sharePdf() },
onSave = { pdfActions.savePdf() },
)
}
// TODO Handle error in PDF generation
@Composable
fun PdfGenerationDialog(
filename: String,
onFilenameChange: (String) -> Unit,
isGenerating: Boolean,
pdf: GeneratedPdf?,
onDismiss: () -> Unit,
onShare: () -> Unit,
onSave: () -> Unit,
onOpen: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
@@ -115,7 +97,7 @@ fun PdfGenerationDialog(
label = { Text("Filename") },
modifier = Modifier.fillMaxWidth()
)
if (isGenerating) {
if (pdf == null) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
@@ -124,19 +106,17 @@ fun PdfGenerationDialog(
Spacer(Modifier.width(8.dp))
Text("Generating PDF…")
}
} else if (pdf != null) {
} else {
val context = LocalContext.current
Text("${pdf.pageCount} pages ${formatFileSize(pdf.sizeInBytes, context)}")
}
}
},
// TODO 4 buttons (counting dismissButton): that's too many
confirmButton = {
if (!isGenerating && pdf != null) {
if (pdf != null) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = onShare) { Text("Share") }
TextButton(onClick = onSave) { Text("Save") }
TextButton(onClick = onOpen) { Text("Open") }
}
}
},
@@ -162,13 +142,11 @@ fun PreviewPdfGenerationDialogDuringGeneration() {
MyScanTheme {
PdfGenerationDialog(
filename = "scan_20250702_174042.pdf",
isGenerating = true,
pdf = null,
onFilenameChange = {},
onDismiss = {},
onShare = {},
onSave = {},
onOpen = {}
)
}
}
@@ -179,13 +157,11 @@ fun PreviewPdfGenerationDialogAfterGeneration() {
MyScanTheme {
PdfGenerationDialog(
filename = "scan_20250702_174042.pdf",
isGenerating = false,
pdf = GeneratedPdf("file://fake.pdf".toUri(), 42897L, 3),
onFilenameChange = {},
onDismiss = {},
onShare = {},
onSave = {},
onOpen = {}
)
}
}