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")
+ }
+}