PDF generation: complete the new system
Use the chosen filename, fix errors on sharing, save on a separate thread
This commit is contained in:
8
.idea/deploymentTargetSelector.xml
generated
8
.idea/deploymentTargetSelector.xml
generated
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 = { _ -> {} }
|
||||
)
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user