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>
<SelectionState runConfigName="MainActivity"> <SelectionState runConfigName="MainActivity">
<option name="selectionMode" value="DROPDOWN" /> <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> </SelectionState>
</selectionStates> </selectionStates>
</component> </component>

View File

@@ -15,8 +15,8 @@
package org.mydomain.myscan package org.mydomain.myscan
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.media.MediaScannerConnection import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.Environment import android.os.Environment
import android.util.Log import android.util.Log
@@ -29,6 +29,11 @@ import androidx.compose.runtime.getValue
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.ui.theme.MyScanTheme
import org.mydomain.myscan.view.CameraScreen import org.mydomain.myscan.view.CameraScreen
import org.mydomain.myscan.view.DocumentScreen import org.mydomain.myscan.view.DocumentScreen
@@ -62,12 +67,13 @@ class MainActivity : ComponentActivity() {
initialPage = screen.initialPage, initialPage = screen.initialPage,
imageLoader = { id -> viewModel.getBitmap(id) }, imageLoader = { id -> viewModel.getBitmap(id) },
toCameraScreen = { viewModel.navigateTo(Screen.Camera) }, toCameraScreen = { viewModel.navigateTo(Screen.Camera) },
// TODO Save and share files with the filename chosen by the user
pdfActions = PdfGenerationActions( pdfActions = PdfGenerationActions(
generatePdf = viewModel::generatePdf, startGeneration = viewModel::startPdfGeneration,
onShare = { uri -> sharePdf(uri) }, cancelGeneration = viewModel::cancelPdfGeneration,
onSave = { uri -> savePdf(uri) }, setFilename = viewModel::setFilename,
onOpen = { uri -> savePdf(uri) /* TODO Open */} generatedPdfFlow = viewModel.generatedPdf,
sharePdf = { sharePdf(viewModel.getFinalPdf()) },
savePdf = { savePdf(viewModel.getFinalPdf()) },
), ),
onStartNew = { onStartNew = {
viewModel.startNewDocument() viewModel.startNewDocument()
@@ -80,32 +86,63 @@ class MainActivity : ComponentActivity() {
} }
} }
private fun sharePdf(fileUri: Uri) { private fun sharePdf(generatedPdf: GeneratedPdf?) {
val fileUri = FileProvider.getUriForFile( if (generatedPdf == null)
this, return
"${applicationContext.packageName}.fileprovider", val file = generatedPdf.uri.toFile()
fileUri.toFile() val authority = "${applicationContext.packageName}.fileprovider"
) val fileUri = FileProvider.getUriForFile(this, authority, file)
val shareIntent = Intent(Intent.ACTION_SEND).apply { val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "application/pdf" type = "application/pdf"
putExtra(Intent.EXTRA_STREAM, fileUri) putExtra(Intent.EXTRA_STREAM, fileUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) 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) { private fun savePdf(generatedPdf: GeneratedPdf?) {
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) if (generatedPdf == null)
if (!downloadsDir.exists()) { return
downloadsDir.mkdirs() 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 = 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(
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()
}
}
} }
val generatedFile = fileUri.toFile()
val targetFile = File(downloadsDir, generatedFile.name)
generatedFile.copyTo(targetFile)
MediaScannerConnection.scanFile(
this, arrayOf(targetFile.absolutePath), arrayOf("application/pdf"), null
)
Toast.makeText(this, "Saved PDF in Downloads", Toast.LENGTH_SHORT).show()
} }
private fun initLibraries() { private fun initLibraries() {

View File

@@ -19,12 +19,14 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras import androidx.lifecycle.viewmodel.CreationExtras
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -35,7 +37,6 @@ import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.OutputStream
class MainViewModel( class MainViewModel(
private val imageSegmentationService: ImageSegmentationService, private val imageSegmentationService: ImageSegmentationService,
@@ -194,20 +195,52 @@ class MainViewModel(
return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) } return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
} }
fun createPdf(outputStream: OutputStream) { private suspend fun generatePdf(): GeneratedPdf = withContext(Dispatchers.IO) {
val jpegs = imageRepository.imageIds().asSequence() val imageIds = imageRepository.imageIds()
val file = File(pdfDir, "${System.currentTimeMillis()}.pdf")
val jpegs = imageIds.asSequence()
.map { id -> imageRepository.getContent(id) } .map { id -> imageRepository.getContent(id) }
.filterNotNull() .filterNotNull()
writePdfFromJpegs(jpegs, outputStream) writePdfFromJpegs(jpegs, FileOutputStream(file))
}
suspend fun generatePdf(): GeneratedPdf = withContext(Dispatchers.IO) {
val pageCount = imageRepository.imageIds().size
val file = File(pdfDir,"${System.currentTimeMillis()}.pdf")
createPdf(FileOutputStream(file))
val sizeBytes = file.length() val sizeBytes = file.length()
val uri = file.toUri() 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( data class PdfGenerationActions(
val generatePdf: suspend () -> GeneratedPdf?, val startGeneration: () -> Unit,
val onShare: (Uri) -> Unit, val cancelGeneration: () -> Unit,
val onSave: (Uri) -> Unit, val setFilename: (String) -> Unit,
val onOpen: (Uri) -> 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.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.MutableStateFlow
import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable import net.engawapg.lib.zoomable.zoomable
import org.mydomain.myscan.PdfGenerationActions import org.mydomain.myscan.PdfGenerationActions
@@ -289,7 +290,10 @@ fun DocumentScreenPreview() {
} }
}, },
toCameraScreen = {}, toCameraScreen = {},
pdfActions = PdfGenerationActions({ null }, {}, {}, {}), pdfActions = PdfGenerationActions(
{}, {}, {},
MutableStateFlow(null),
{}, {}),
onStartNew = {}, onStartNew = {},
onDeleteImage = { _ -> {} } onDeleteImage = { _ -> {} }
) )

View File

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