diff --git a/app/src/main/java/org/mydomain/myscan/MainActivity.kt b/app/src/main/java/org/mydomain/myscan/MainActivity.kt index 4f5dbd0..f997c73 100644 --- a/app/src/main/java/org/mydomain/myscan/MainActivity.kt +++ b/app/src/main/java/org/mydomain/myscan/MainActivity.kt @@ -14,9 +14,9 @@ */ package org.mydomain.myscan -import android.content.Context import android.content.Intent import android.media.MediaScannerConnection +import android.net.Uri import android.os.Bundle import android.os.Environment import android.util.Log @@ -25,18 +25,15 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels -import androidx.compose.foundation.layout.Column import androidx.compose.runtime.getValue -import androidx.compose.ui.platform.LocalContext import androidx.core.content.FileProvider +import androidx.core.net.toFile import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.mydomain.myscan.ui.theme.MyScanTheme import org.mydomain.myscan.view.CameraScreen import org.mydomain.myscan.view.DocumentScreen import org.opencv.android.OpenCVLoader import java.io.File -import java.io.FileOutputStream -import java.io.IOException class MainActivity : ComponentActivity() { @@ -49,7 +46,6 @@ class MainActivity : ComponentActivity() { val currentScreen by viewModel.currentScreen.collectAsStateWithLifecycle() val liveAnalysisState by viewModel.liveAnalysisState.collectAsStateWithLifecycle() val pageIds by viewModel.pageIds.collectAsStateWithLifecycle() - val context = LocalContext.current MyScanTheme { when (val screen = currentScreen) { is Screen.Camera -> { @@ -66,8 +62,13 @@ class MainActivity : ComponentActivity() { initialPage = screen.initialPage, imageLoader = { id -> viewModel.getBitmap(id) }, toCameraScreen = { viewModel.navigateTo(Screen.Camera) }, - onSavePressed = savePdf(viewModel, context), - onSharePressed = sharePdf(viewModel, context), + // 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 */} + ), onStartNew = { viewModel.startNewDocument() viewModel.navigateTo(Screen.Camera) }, @@ -79,57 +80,32 @@ class MainActivity : ComponentActivity() { } } - private fun sharePdf( - viewModel: MainViewModel, - context: Context - ): () -> Unit = { - val outputDir = File(cacheDir, "pdfs").apply { mkdirs() } - val outputFile = File(outputDir, "scan_${System.currentTimeMillis()}.pdf") - var success = true - try { - val fileOutputStream = FileOutputStream(outputFile) - viewModel.createPdf(fileOutputStream) - } catch (_: IOException) { - Toast.makeText(context, "Failed to share PDF", Toast.LENGTH_SHORT).show() - success = false - } - if (success) { - val uri = FileProvider.getUriForFile( - context, - "${applicationContext.packageName}.fileprovider", - outputFile - ) - val shareIntent = Intent(Intent.ACTION_SEND).apply { - type = "application/pdf" - putExtra(Intent.EXTRA_STREAM, uri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - startActivity(Intent.createChooser(shareIntent, "Share PDF")) + private fun sharePdf(fileUri: Uri) { + val fileUri = FileProvider.getUriForFile( + this, + "${applicationContext.packageName}.fileprovider", + fileUri.toFile() + ) + 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")) } - private fun savePdf( - viewModel: MainViewModel, - context: Context - ): () -> Unit = { - try { - val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) - if (!downloadsDir.exists()) downloadsDir.mkdirs() - val file = File(downloadsDir, "scan_${System.currentTimeMillis()}.pdf") - val outputStream = FileOutputStream(file) - viewModel.createPdf(outputStream) - outputStream.flush() - outputStream.close() - - MediaScannerConnection.scanFile( - context, arrayOf(file.absolutePath), arrayOf("application/pdf"), null - ) - - Toast.makeText(context, "Saved PDF in Downloads", Toast.LENGTH_SHORT).show() - } catch (e: Exception) { - Log.e("MyScan", "Failed to save PDF", e) - Toast.makeText(context, "Failed to save PDF", Toast.LENGTH_SHORT).show() + private fun savePdf(fileUri: Uri) { + val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + if (!downloadsDir.exists()) { + downloadsDir.mkdirs() } + 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 ea1e2a7..07598fd 100644 --- a/app/src/main/java/org/mydomain/myscan/MainViewModel.kt +++ b/app/src/main/java/org/mydomain/myscan/MainViewModel.kt @@ -17,7 +17,9 @@ package org.mydomain.myscan import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.net.Uri import androidx.camera.core.ImageProxy +import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -31,18 +33,25 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch 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, private val imageRepository: ImageRepository, + private val pdfDir: File, ): ViewModel() { companion object { fun getFactory(context: Context) = object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class, extras: CreationExtras): T { - return MainViewModel(ImageSegmentationService(context), ImageRepository(context.filesDir)) as T + return MainViewModel( + ImageSegmentationService(context), + ImageRepository(context.filesDir), + File(context.cacheDir, "pdfs"), + ) as T } } } @@ -191,4 +200,26 @@ class MainViewModel( .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)) + val sizeBytes = file.length() + val uri = file.toUri() + return@withContext GeneratedPdf(uri, sizeBytes, pageCount) + } } + +data class GeneratedPdf( + val uri: Uri, + val sizeInBytes: Long, + val pageCount: Int, +) + +data class PdfGenerationActions( + val generatePdf: suspend () -> GeneratedPdf?, + val onShare: (Uri) -> Unit, + val onSave: (Uri) -> Unit, + val onOpen: (Uri) -> 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 3ffe935..c9d69d8 100644 --- a/app/src/main/java/org/mydomain/myscan/view/DocumentScreen.kt +++ b/app/src/main/java/org/mydomain/myscan/view/DocumentScreen.kt @@ -30,9 +30,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.PictureAsPdf import androidx.compose.material.icons.filled.RestartAlt -import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.AlertDialog import androidx.compose.material3.BottomAppBar @@ -66,6 +65,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import net.engawapg.lib.zoomable.rememberZoomState import net.engawapg.lib.zoomable.zoomable +import org.mydomain.myscan.PdfGenerationActions import org.mydomain.myscan.ui.theme.MyScanTheme @OptIn(ExperimentalMaterial3Api::class) @@ -75,13 +75,13 @@ fun DocumentScreen( initialPage: Int, imageLoader: (String) -> Bitmap?, toCameraScreen: () -> Unit, - onSavePressed: () -> Unit, - onSharePressed: () -> Unit, + pdfActions: PdfGenerationActions, onStartNew: () -> Unit, onDeleteImage: (String) -> Unit, ) { // TODO Check how often images are loaded - var showDialog = rememberSaveable { mutableStateOf(false) } + val showNewDocDialog = rememberSaveable { mutableStateOf(false) } + val showPdfDialog = rememberSaveable { mutableStateOf(false) } val currentPageIndex = rememberSaveable { mutableIntStateOf(initialPage) } if (currentPageIndex.intValue >= pageIds.size) { currentPageIndex.intValue = pageIds.size - 1 @@ -111,23 +111,17 @@ fun DocumentScreen( BottomAppBar( containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, actions = { - Button(onClick = onSharePressed) { - Icon(Icons.Default.Share, contentDescription = "Share") + Button(onClick = { showPdfDialog.value = true }) { + Icon(Icons.Default.PictureAsPdf, contentDescription = "Generate PDF") Spacer(Modifier.width(8.dp)) - Text("Share") - } - Spacer(modifier = Modifier.size(8.dp)) - Button(onClick = onSavePressed) { - Icon(Icons.Default.Download, contentDescription = "Save") - Spacer(Modifier.width(8.dp)) - Text("Save") + Text("Generate PDF") } }, floatingActionButton = { MyIconButton( icon = Icons.Default.RestartAlt, contentDescription = "Restart", - onClick = { showDialog.value = true }, + onClick = { showNewDocDialog.value = true }, modifier = Modifier.padding(vertical = 8.dp) ) } @@ -136,8 +130,14 @@ fun DocumentScreen( } ) { padding -> DocumentPreview(pageIds, imageLoader, currentPageIndex, onDeleteImage, padding) - if (showDialog.value) { - NewDocumentDialog(onConfirm = onStartNew, showDialog) + if (showNewDocDialog.value) { + NewDocumentDialog(onConfirm = onStartNew, showNewDocDialog) + } + if (showPdfDialog.value) { + PdfGenerationDialogWrapper( + onDismiss = { showPdfDialog.value = false }, + pdfActions = pdfActions, + ) } } } @@ -289,8 +289,7 @@ fun DocumentScreenPreview() { } }, toCameraScreen = {}, - onSavePressed = {}, - onSharePressed = {}, + pdfActions = PdfGenerationActions({ 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 new file mode 100644 index 0000000..586c3ac --- /dev/null +++ b/app/src/main/java/org/mydomain/myscan/view/PdfGenerationDialog.kt @@ -0,0 +1,191 @@ +/* + * Copyright 2025 Pierre-Yves Nicolas + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ +package org.mydomain.myscan.view + +import android.content.Context +import android.text.format.Formatter +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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 +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 +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +fun PdfGenerationDialogWrapper( + onDismiss: () -> Unit, + 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) } + + LaunchedEffect(Unit) { + job = coroutineScope.launch { + try { + val result = pdfActions.generatePdf() + pdf = result + isGenerating = false + } catch (_: CancellationException) { + // Cancelled + } catch (_: Exception) { + // Error + isGenerating = false + } + } + } + + PdfGenerationDialog( + filename = filename, + onFilenameChange = { filename = it }, + isGenerating = isGenerating, + pdf = pdf, + onDismiss = { + job?.cancel() + onDismiss() + }, + onShare = { pdf?.uri?.let(pdfActions.onShare) }, + onSave = { pdf?.uri?.let(pdfActions.onSave) }, + onOpen = { pdf?.uri?.let(pdfActions.onOpen) } + ) +} + +@Composable +fun PdfGenerationDialog( + filename: String, + onFilenameChange: (String) -> Unit, + isGenerating: Boolean, + pdf: GeneratedPdf?, + onDismiss: () -> Unit, + onShare: () -> Unit, + onSave: () -> Unit, + onOpen: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Generate PDF") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = filename, + onValueChange = onFilenameChange, + label = { Text("Filename") }, + modifier = Modifier.fillMaxWidth() + ) + if (isGenerating) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + Spacer(Modifier.width(8.dp)) + Text("Generating PDF…") + } + } else if (pdf != null) { + 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) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = onShare) { Text("Share") } + TextButton(onClick = onSave) { Text("Save") } + TextButton(onClick = onOpen) { Text("Open") } + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Close") } + } + ) +} + +fun defaultFilename(): String { + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + return "scan_$timestamp.pdf" +} + +fun formatFileSize(sizeInBytes: Long?, context: Context): String { + return if (sizeInBytes == null) "Unknown size" + else Formatter.formatShortFileSize(context, sizeInBytes) +} + +@Preview +@Composable +fun PreviewPdfGenerationDialogDuringGeneration() { + MyScanTheme { + PdfGenerationDialog( + filename = "scan_20250702_174042.pdf", + isGenerating = true, + pdf = null, + onFilenameChange = {}, + onDismiss = {}, + onShare = {}, + onSave = {}, + onOpen = {} + ) + } +} + +@Preview +@Composable +fun PreviewPdfGenerationDialogAfterGeneration() { + MyScanTheme { + PdfGenerationDialog( + filename = "scan_20250702_174042.pdf", + isGenerating = false, + pdf = GeneratedPdf("file://fake.pdf".toUri(), 42897L, 3), + onFilenameChange = {}, + onDismiss = {}, + onShare = {}, + onSave = {}, + onOpen = {} + ) + } +}