diff --git a/app/src/main/java/org/mydomain/myscan/FileUtils.kt b/app/src/main/java/org/mydomain/myscan/FileUtils.kt deleted file mode 100644 index 6e5902f..0000000 --- a/app/src/main/java/org/mydomain/myscan/FileUtils.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * 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 - -import java.io.File - -fun getAvailableFilename(desiredFile: File): File { - var file = desiredFile - val dir = desiredFile.parentFile - val desiredName = desiredFile.name - val nameWithoutExtension = desiredName.removeSuffix(".pdf") - var counter = 1 - while (file.exists()) { - file = File(dir, "${nameWithoutExtension}_$counter.pdf") - counter++ - } - return file -} - -fun cleanUpOldFiles(dir: File, thresholdInMillis: Int) { - val now = System.currentTimeMillis() - dir.listFiles { file -> now - file.lastModified() > thresholdInMillis } - ?.forEach { file -> file.delete() } -} - diff --git a/app/src/main/java/org/mydomain/myscan/MainActivity.kt b/app/src/main/java/org/mydomain/myscan/MainActivity.kt index a13dbce..d82b867 100644 --- a/app/src/main/java/org/mydomain/myscan/MainActivity.kt +++ b/app/src/main/java/org/mydomain/myscan/MainActivity.kt @@ -53,10 +53,10 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) initLibraries() - lifecycleScope.launch(Dispatchers.IO) { - cleanUpOldFiles(File(cacheDir, "pdfs"), 1000 * 3600) - } val viewModel: MainViewModel by viewModels { MainViewModel.getFactory(this) } + lifecycleScope.launch(Dispatchers.IO) { + viewModel.cleanUpOldPdfs(1000 * 3600) + } enableEdgeToEdge() setContent { val currentScreen by viewModel.currentScreen.collectAsStateWithLifecycle() @@ -114,7 +114,7 @@ class MainActivity : ComponentActivity() { private fun sharePdf(generatedPdf: GeneratedPdf?) { if (generatedPdf == null) return - val file = generatedPdf.uri.toFile() + val file = generatedPdf.file val authority = "${applicationContext.packageName}.fileprovider" val fileUri = FileProvider.getUriForFile(this, authority, file) val shareIntent = Intent(Intent.ACTION_SEND).apply { @@ -139,16 +139,7 @@ class MainActivity : ComponentActivity() { val context = this appScope.launch { try { - val downloadsDir = - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) - if (!downloadsDir.exists()) { - downloadsDir.mkdirs() - } - val generatedFile = generatedPdf.uri.toFile() - val desiredFile = File(downloadsDir, generatedFile.name) - val targetFile = getAvailableFilename(desiredFile) - generatedFile.copyTo(targetFile) - viewModel.markFileSaved(targetFile.toUri()) + val targetFile = viewModel.saveFile(generatedPdf.file) suspendCancellableCoroutine { continuation -> MediaScannerConnection.scanFile( diff --git a/app/src/main/java/org/mydomain/myscan/MainViewModel.kt b/app/src/main/java/org/mydomain/myscan/MainViewModel.kt index 20b3bbb..2e2ee04 100644 --- a/app/src/main/java/org/mydomain/myscan/MainViewModel.kt +++ b/app/src/main/java/org/mydomain/myscan/MainViewModel.kt @@ -18,6 +18,7 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri +import android.os.Environment import android.util.Log import androidx.camera.core.ImageProxy import androidx.core.net.toFile @@ -41,12 +42,11 @@ import kotlinx.coroutines.withContext import org.mydomain.myscan.ui.PdfGenerationUiState import java.io.ByteArrayOutputStream import java.io.File -import java.io.FileOutputStream class MainViewModel( private val imageSegmentationService: ImageSegmentationService, private val imageRepository: ImageRepository, - private val pdfDir: File, + private val pdfFileManager: PdfFileManager, ): ViewModel() { companion object { @@ -56,7 +56,10 @@ class MainViewModel( return MainViewModel( ImageSegmentationService(context), ImageRepository(context.filesDir), - File(context.cacheDir, "pdfs"), + PdfFileManager( + File(context.cacheDir, "pdfs"), + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + AndroidPdfWriter()), ) as T } } @@ -207,19 +210,12 @@ class MainViewModel( private suspend fun generatePdf(): GeneratedPdf = withContext(Dispatchers.IO) { val imageIds = imageRepository.imageIds() - pdfDir.mkdirs() - val file = File(pdfDir, "${System.currentTimeMillis()}.pdf") val jpegs = imageIds.asSequence() .map { id -> imageRepository.getContent(id) } .filterNotNull() - writePdfFromJpegs(jpegs, FileOutputStream(file)) - val sizeBytes = file.length() - val uri = file.toUri() - return@withContext GeneratedPdf(uri, sizeBytes, imageIds.size) + return@withContext pdfFileManager.generatePdf(jpegs) } - private val _generatedPdf = MutableStateFlow(null) - private val _pdfUiState = MutableStateFlow(PdfGenerationUiState()) val pdfUiState: StateFlow = _pdfUiState.asStateFlow() @@ -264,7 +260,7 @@ class MainViewModel( fun getFinalPdf(): GeneratedPdf? { val tempPdf = _pdfUiState.value.generatedPdf ?: return null - val tempFile = tempPdf.uri.toFile() + val tempFile = tempPdf.file val newFile = File(tempFile.parentFile, desiredFilename) if (tempFile.absolutePath != newFile.absolutePath) { if (newFile.exists()) newFile.delete() @@ -272,20 +268,30 @@ class MainViewModel( if (!success) return null _pdfUiState.update { it.copy(generatedPdf = GeneratedPdf( - uri = newFile.toUri(), tempPdf.sizeInBytes, tempPdf.pageCount) + newFile, tempPdf.sizeInBytes, tempPdf.pageCount) ) } } return _pdfUiState.value.generatedPdf } + fun saveFile(pdfFile: File): File { + val copiedFile = pdfFileManager.copyToExternalDir(pdfFile) + markFileSaved(pdfFile.toUri()) + return copiedFile + } + fun markFileSaved(uri: Uri) { _pdfUiState.update { it.copy(savedFileUri = uri) } } + + fun cleanUpOldPdfs(thresholdInMillis: Int) { + pdfFileManager.cleanUpOldFiles(thresholdInMillis) + } } data class GeneratedPdf( - val uri: Uri, + val file: File, val sizeInBytes: Long, val pageCount: Int, ) diff --git a/app/src/main/java/org/mydomain/myscan/PdfFileManager.kt b/app/src/main/java/org/mydomain/myscan/PdfFileManager.kt new file mode 100644 index 0000000..5793637 --- /dev/null +++ b/app/src/main/java/org/mydomain/myscan/PdfFileManager.kt @@ -0,0 +1,70 @@ +/* + * 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 + +import java.io.File +import java.io.FileOutputStream +import java.io.OutputStream + +fun interface PdfWriter { + fun writePdfFromJpegs(jpegs: Sequence, outputStream: OutputStream): Int +} + +class PdfFileManager( + private val pdfDir: File, + private val externalDir: File, + private val pdfWriter: PdfWriter +) { + fun generatePdf(jpegs: Sequence): GeneratedPdf { + pdfDir.mkdirs() + require(pdfDir.exists() && pdfDir.isDirectory) { "Invalid pdfDir: $pdfDir" } + val file = File(pdfDir, "${System.currentTimeMillis()}.pdf") + val pageCount = FileOutputStream(file).use { + pdfWriter.writePdfFromJpegs(jpegs, it) + } + val sizeBytes = file.length() + return GeneratedPdf(file, sizeBytes, pageCount) + } + + fun copyToExternalDir(original: File): File { + if (!externalDir.exists()) { + externalDir.mkdirs() + } + require(externalDir.exists() && externalDir.isDirectory) { "Invalid externalDir: $pdfDir" } + val desiredFile = File(externalDir, original.name) + val targetFile = getAvailableFilename(desiredFile) + original.copyTo(targetFile) + return targetFile + } + + private fun getAvailableFilename(desiredFile: File): File { + var file = desiredFile + val dir = desiredFile.parentFile + val nameWithoutExtension = desiredFile.nameWithoutExtension + val extension = desiredFile.extension + var counter = 1 + while (file.exists()) { + file = File(dir, "${nameWithoutExtension}($counter).$extension") + counter++ + } + return file + } + + fun cleanUpOldFiles(thresholdInMillis: Int) { + val now = System.currentTimeMillis() + pdfDir.listFiles { file -> now - file.lastModified() > thresholdInMillis } + ?.forEach { file -> file.delete() } + } +} diff --git a/app/src/main/java/org/mydomain/myscan/PdfGeneration.kt b/app/src/main/java/org/mydomain/myscan/PdfGeneration.kt index 6ff6ccf..a379eb4 100644 --- a/app/src/main/java/org/mydomain/myscan/PdfGeneration.kt +++ b/app/src/main/java/org/mydomain/myscan/PdfGeneration.kt @@ -14,8 +14,6 @@ */ package org.mydomain.myscan -import android.graphics.Bitmap -import androidx.core.graphics.scale import com.tom_roush.pdfbox.pdmodel.PDDocument import com.tom_roush.pdfbox.pdmodel.PDPage import com.tom_roush.pdfbox.pdmodel.PDPageContentStream @@ -23,18 +21,22 @@ import com.tom_roush.pdfbox.pdmodel.PDPageContentStream.AppendMode import com.tom_roush.pdfbox.pdmodel.common.PDRectangle import com.tom_roush.pdfbox.pdmodel.graphics.image.JPEGFactory import java.io.OutputStream -import kotlin.math.max -fun writePdfFromJpegs(jpegs: Sequence, outputStream: OutputStream) { - PDDocument().use { document -> - for (jpegBytes in jpegs) { - val image = JPEGFactory.createFromByteArray(document, jpegBytes) - val page = PDPage(PDRectangle(image.width.toFloat(), image.height.toFloat())) - document.addPage(page) - val contentStream = PDPageContentStream(document, page, AppendMode.OVERWRITE, false) - contentStream.drawImage(image, 0f, 0f) - contentStream.close() +class AndroidPdfWriter : PdfWriter { + override fun writePdfFromJpegs(jpegs: Sequence, outputStream: OutputStream): Int { + val doc = PDDocument() + doc.use { document -> + for (jpegBytes in jpegs) { + val image = JPEGFactory.createFromByteArray(document, jpegBytes) + val page = PDPage(PDRectangle(image.width.toFloat(), image.height.toFloat())) + document.addPage(page) + val contentStream = PDPageContentStream(document, page, AppendMode.OVERWRITE, false) + contentStream.drawImage(image, 0f, 0f) + contentStream.close() + } + // TODO So the whole document is in memory before this line... + document.save(outputStream) } - document.save(outputStream) + return doc.numberOfPages } } diff --git a/app/src/main/java/org/mydomain/myscan/view/PdfGenerationBottomSheet.kt b/app/src/main/java/org/mydomain/myscan/view/PdfGenerationBottomSheet.kt index e77112a..5618bbc 100644 --- a/app/src/main/java/org/mydomain/myscan/view/PdfGenerationBottomSheet.kt +++ b/app/src/main/java/org/mydomain/myscan/view/PdfGenerationBottomSheet.kt @@ -58,6 +58,7 @@ import org.mydomain.myscan.GeneratedPdf import org.mydomain.myscan.PdfGenerationActions import org.mydomain.myscan.ui.PdfGenerationUiState import org.mydomain.myscan.ui.theme.MyScanTheme +import java.io.File import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -267,7 +268,7 @@ fun PreviewPdfGenerationDialogDuringGeneration() { fun PreviewPdfGenerationDialogAfterGeneration() { PreviewToCustomize( uiState = PdfGenerationUiState( - generatedPdf = GeneratedPdf("file://fake.pdf".toUri(), 442897L, 1) + generatedPdf = GeneratedPdf(File("fake.pdf"), 442897L, 1) ) ) } @@ -275,10 +276,11 @@ fun PreviewPdfGenerationDialogAfterGeneration() { @Preview(showBackground = true) @Composable fun PreviewPdfGenerationDialogAfterSave() { + val file = File("fake.pdf") PreviewToCustomize( uiState = PdfGenerationUiState( - generatedPdf = GeneratedPdf("file://fake.pdf".toUri(), 442897L, 3), - savedFileUri = "file:///fake".toUri() + generatedPdf = GeneratedPdf(file, 442897L, 3), + savedFileUri = file.toUri() ) ) } diff --git a/app/src/test/java/org/mydomain/myscan/FileUtilsTest.kt b/app/src/test/java/org/mydomain/myscan/FileUtilsTest.kt deleted file mode 100644 index 809487b..0000000 --- a/app/src/test/java/org/mydomain/myscan/FileUtilsTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * 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 - -import org.assertj.core.api.Assertions.assertThat -import org.junit.Test -import java.io.File -import kotlin.io.path.createTempDirectory - -class FileUtilsTest { - - @Test - fun getAvailableName() { - val dir = createTempDirectory().toFile() - val f = File(dir, "f.pdf") - val f1 = File(dir, "f_1.pdf") - val f2 = File(dir, "f_2.pdf") - - assertThat(f).doesNotExist() - assertThat(f1).doesNotExist() - assertThat(getAvailableFilename(f)).isEqualTo(f) - - f.apply { writeText("dummy") } - assertThat(f).exists() - assertThat(getAvailableFilename(f)).isEqualTo(f1) - - f1.apply { writeText("dummy") } - assertThat(f1).exists() - assertThat(getAvailableFilename(f)).isEqualTo(f2) - } - - @Test - fun cleanUpOldFiles() { - val dir = createTempDirectory().toFile() - val subDir = File(dir,"subDir") - cleanUpOldFiles(subDir, 10) - assertThat(subDir).doesNotExist() - - subDir.mkdirs() - assertThat(subDir).exists() - val file1 = File(subDir, "file1") - file1.createNewFile() - val file2 = File(subDir, "file2") - file2.createNewFile() - - val now = System.currentTimeMillis() - file1.setLastModified(now - 10_000) - file2.setLastModified(now - 11_000) - cleanUpOldFiles(subDir, 10_500) - assertThat(file1).exists() - assertThat(file2).doesNotExist() - } -} diff --git a/app/src/test/java/org/mydomain/myscan/PdfFileManagerTest.kt b/app/src/test/java/org/mydomain/myscan/PdfFileManagerTest.kt new file mode 100644 index 0000000..882d851 --- /dev/null +++ b/app/src/test/java/org/mydomain/myscan/PdfFileManagerTest.kt @@ -0,0 +1,87 @@ +/* + * 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 + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import java.io.File +import java.io.OutputStream +import kotlin.io.path.createTempDirectory + +class PdfFileManagerTest { + + val pdfDir: File = createTempDirectory().toFile() + val externalDir: File = createTempDirectory().toFile() + val dummyPdfWriter = PdfWriter { _,_ -> 42 } + + @Test + fun copyToExternalDir() { + val original = File(pdfDir, "f.pdf") + original.writeText("original content") + val f = File(externalDir, "f.pdf") + assertThat(f).doesNotExist() + + val manager = PdfFileManager(pdfDir, externalDir, dummyPdfWriter) + assertThat(manager.copyToExternalDir(original)) + .isEqualTo(f) + .hasContent("original content") + + val f1 = File(externalDir, "f(1).pdf") + val f2 = File(externalDir, "f(2).pdf") + assertThat(f1).doesNotExist() + assertThat(manager.copyToExternalDir(original)).isEqualTo(f1) + assertThat(manager.copyToExternalDir(original)).isEqualTo(f2) + } + + @Test + fun cleanUpOldFiles() { + val subDir = File(pdfDir,"subDir") + val manager = PdfFileManager(subDir, externalDir, dummyPdfWriter) + manager.cleanUpOldFiles(10) + assertThat(subDir).doesNotExist() + + subDir.mkdirs() + assertThat(subDir).exists() + val file1 = File(subDir, "file1") + file1.createNewFile() + val file2 = File(subDir, "file2") + file2.createNewFile() + + val now = System.currentTimeMillis() + file1.setLastModified(now - 10_000) + file2.setLastModified(now - 11_000) + manager.cleanUpOldFiles(10_500) + assertThat(file1).exists() + assertThat(file2).doesNotExist() + } + + @Test + fun generatePdf() { + val fakePdfWriter = object : PdfWriter { + override fun writePdfFromJpegs(jpegs: Sequence, outputStream: OutputStream): Int { + val list = jpegs.toList() + list.forEach { bytes -> outputStream.write(bytes) } + return list.size + } + } + val manager = PdfFileManager(pdfDir, externalDir, fakePdfWriter) + val jpegs = listOf(byteArrayOf(0x01, 0x02), byteArrayOf(0x11)).asSequence() + val pdf = manager.generatePdf(jpegs) + assertThat(pdf.pageCount).isEqualTo(2) + assertThat(pdf.sizeInBytes).isEqualTo(3) + assertThat(pdf.file.readBytes()).isEqualTo(byteArrayOf(0x01, 0x02, 0x11)) + assertThat(pdf.file.name).endsWith(".pdf") + } +}