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 = {}
)
}
}