From 7b2e60ee14bccc8072eb639461321175e70d80bb Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Fri, 4 Jul 2025 20:34:34 +0200 Subject: [PATCH] PDF generation: complete the new system Use the chosen filename, fix errors on sharing, save on a separate thread --- .idea/deploymentTargetSelector.xml | 8 ++ .../java/org/mydomain/myscan/MainActivity.kt | 85 +++++++++++++------ .../java/org/mydomain/myscan/MainViewModel.kt | 65 ++++++++++---- .../mydomain/myscan/view/DocumentScreen.kt | 6 +- .../myscan/view/PdfGenerationDialog.kt | 56 ++++-------- 5 files changed, 140 insertions(+), 80 deletions(-) diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 652219b..ac44af9 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -15,6 +15,14 @@ diff --git a/app/src/main/java/org/mydomain/myscan/MainActivity.kt b/app/src/main/java/org/mydomain/myscan/MainActivity.kt index f997c73..56f1d2c 100644 --- a/app/src/main/java/org/mydomain/myscan/MainActivity.kt +++ b/app/src/main/java/org/mydomain/myscan/MainActivity.kt @@ -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) - if (!downloadsDir.exists()) { - downloadsDir.mkdirs() + 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 = 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() { diff --git a/app/src/main/java/org/mydomain/myscan/MainViewModel.kt b/app/src/main/java/org/mydomain/myscan/MainViewModel.kt index 07598fd..d192bbb 100644 --- a/app/src/main/java/org/mydomain/myscan/MainViewModel.kt +++ b/app/src/main/java/org/mydomain/myscan/MainViewModel.kt @@ -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(null) + val generatedPdf: StateFlow = _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, + val sharePdf: () -> Unit, + val savePdf: () -> Unit ) diff --git a/app/src/main/java/org/mydomain/myscan/view/DocumentScreen.kt b/app/src/main/java/org/mydomain/myscan/view/DocumentScreen.kt index c9d69d8..07cffde 100644 --- a/app/src/main/java/org/mydomain/myscan/view/DocumentScreen.kt +++ b/app/src/main/java/org/mydomain/myscan/view/DocumentScreen.kt @@ -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 = { _ -> {} } ) diff --git a/app/src/main/java/org/mydomain/myscan/view/PdfGenerationDialog.kt b/app/src/main/java/org/mydomain/myscan/view/PdfGenerationDialog.kt index 586c3ac..9579f8d 100644 --- a/app/src/main/java/org/mydomain/myscan/view/PdfGenerationDialog.kt +++ b/app/src/main/java/org/mydomain/myscan/view/PdfGenerationDialog.kt @@ -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(null) } - - val coroutineScope = rememberCoroutineScope() - var job by remember { mutableStateOf(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 = {} ) } }