Refactoring: PdfFileManager

This commit is contained in:
Pierre-Yves Nicolas
2025-07-10 15:03:37 +02:00
parent 50a123d155
commit d29d0544fb
8 changed files with 202 additions and 146 deletions

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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() }
}

View File

@@ -53,10 +53,10 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
initLibraries() initLibraries()
lifecycleScope.launch(Dispatchers.IO) {
cleanUpOldFiles(File(cacheDir, "pdfs"), 1000 * 3600)
}
val viewModel: MainViewModel by viewModels { MainViewModel.getFactory(this) } val viewModel: MainViewModel by viewModels { MainViewModel.getFactory(this) }
lifecycleScope.launch(Dispatchers.IO) {
viewModel.cleanUpOldPdfs(1000 * 3600)
}
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
val currentScreen by viewModel.currentScreen.collectAsStateWithLifecycle() val currentScreen by viewModel.currentScreen.collectAsStateWithLifecycle()
@@ -114,7 +114,7 @@ class MainActivity : ComponentActivity() {
private fun sharePdf(generatedPdf: GeneratedPdf?) { private fun sharePdf(generatedPdf: GeneratedPdf?) {
if (generatedPdf == null) if (generatedPdf == null)
return return
val file = generatedPdf.uri.toFile() val file = generatedPdf.file
val authority = "${applicationContext.packageName}.fileprovider" val authority = "${applicationContext.packageName}.fileprovider"
val fileUri = FileProvider.getUriForFile(this, authority, file) val fileUri = FileProvider.getUriForFile(this, authority, file)
val shareIntent = Intent(Intent.ACTION_SEND).apply { val shareIntent = Intent(Intent.ACTION_SEND).apply {
@@ -139,16 +139,7 @@ class MainActivity : ComponentActivity() {
val context = this val context = this
appScope.launch { appScope.launch {
try { try {
val downloadsDir = val targetFile = viewModel.saveFile(generatedPdf.file)
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())
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
MediaScannerConnection.scanFile( MediaScannerConnection.scanFile(

View File

@@ -18,6 +18,7 @@ import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.os.Environment
import android.util.Log import android.util.Log
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import androidx.core.net.toFile import androidx.core.net.toFile
@@ -41,12 +42,11 @@ import kotlinx.coroutines.withContext
import org.mydomain.myscan.ui.PdfGenerationUiState import org.mydomain.myscan.ui.PdfGenerationUiState
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.io.FileOutputStream
class MainViewModel( class MainViewModel(
private val imageSegmentationService: ImageSegmentationService, private val imageSegmentationService: ImageSegmentationService,
private val imageRepository: ImageRepository, private val imageRepository: ImageRepository,
private val pdfDir: File, private val pdfFileManager: PdfFileManager,
): ViewModel() { ): ViewModel() {
companion object { companion object {
@@ -56,7 +56,10 @@ class MainViewModel(
return MainViewModel( return MainViewModel(
ImageSegmentationService(context), ImageSegmentationService(context),
ImageRepository(context.filesDir), ImageRepository(context.filesDir),
File(context.cacheDir, "pdfs"), PdfFileManager(
File(context.cacheDir, "pdfs"),
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
AndroidPdfWriter()),
) as T ) as T
} }
} }
@@ -207,19 +210,12 @@ class MainViewModel(
private suspend fun generatePdf(): GeneratedPdf = withContext(Dispatchers.IO) { private suspend fun generatePdf(): GeneratedPdf = withContext(Dispatchers.IO) {
val imageIds = imageRepository.imageIds() val imageIds = imageRepository.imageIds()
pdfDir.mkdirs()
val file = File(pdfDir, "${System.currentTimeMillis()}.pdf")
val jpegs = imageIds.asSequence() val jpegs = imageIds.asSequence()
.map { id -> imageRepository.getContent(id) } .map { id -> imageRepository.getContent(id) }
.filterNotNull() .filterNotNull()
writePdfFromJpegs(jpegs, FileOutputStream(file)) return@withContext pdfFileManager.generatePdf(jpegs)
val sizeBytes = file.length()
val uri = file.toUri()
return@withContext GeneratedPdf(uri, sizeBytes, imageIds.size)
} }
private val _generatedPdf = MutableStateFlow<GeneratedPdf?>(null)
private val _pdfUiState = MutableStateFlow(PdfGenerationUiState()) private val _pdfUiState = MutableStateFlow(PdfGenerationUiState())
val pdfUiState: StateFlow<PdfGenerationUiState> = _pdfUiState.asStateFlow() val pdfUiState: StateFlow<PdfGenerationUiState> = _pdfUiState.asStateFlow()
@@ -264,7 +260,7 @@ class MainViewModel(
fun getFinalPdf(): GeneratedPdf? { fun getFinalPdf(): GeneratedPdf? {
val tempPdf = _pdfUiState.value.generatedPdf ?: return null val tempPdf = _pdfUiState.value.generatedPdf ?: return null
val tempFile = tempPdf.uri.toFile() val tempFile = tempPdf.file
val newFile = File(tempFile.parentFile, desiredFilename) val newFile = File(tempFile.parentFile, desiredFilename)
if (tempFile.absolutePath != newFile.absolutePath) { if (tempFile.absolutePath != newFile.absolutePath) {
if (newFile.exists()) newFile.delete() if (newFile.exists()) newFile.delete()
@@ -272,20 +268,30 @@ class MainViewModel(
if (!success) return null if (!success) return null
_pdfUiState.update { _pdfUiState.update {
it.copy(generatedPdf = GeneratedPdf( it.copy(generatedPdf = GeneratedPdf(
uri = newFile.toUri(), tempPdf.sizeInBytes, tempPdf.pageCount) newFile, tempPdf.sizeInBytes, tempPdf.pageCount)
) )
} }
} }
return _pdfUiState.value.generatedPdf return _pdfUiState.value.generatedPdf
} }
fun saveFile(pdfFile: File): File {
val copiedFile = pdfFileManager.copyToExternalDir(pdfFile)
markFileSaved(pdfFile.toUri())
return copiedFile
}
fun markFileSaved(uri: Uri) { fun markFileSaved(uri: Uri) {
_pdfUiState.update { it.copy(savedFileUri = uri) } _pdfUiState.update { it.copy(savedFileUri = uri) }
} }
fun cleanUpOldPdfs(thresholdInMillis: Int) {
pdfFileManager.cleanUpOldFiles(thresholdInMillis)
}
} }
data class GeneratedPdf( data class GeneratedPdf(
val uri: Uri, val file: File,
val sizeInBytes: Long, val sizeInBytes: Long,
val pageCount: Int, val pageCount: Int,
) )

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
package org.mydomain.myscan
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStream
fun interface PdfWriter {
fun writePdfFromJpegs(jpegs: Sequence<ByteArray>, outputStream: OutputStream): Int
}
class PdfFileManager(
private val pdfDir: File,
private val externalDir: File,
private val pdfWriter: PdfWriter
) {
fun generatePdf(jpegs: Sequence<ByteArray>): 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() }
}
}

View File

@@ -14,8 +14,6 @@
*/ */
package org.mydomain.myscan 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.PDDocument
import com.tom_roush.pdfbox.pdmodel.PDPage import com.tom_roush.pdfbox.pdmodel.PDPage
import com.tom_roush.pdfbox.pdmodel.PDPageContentStream 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.common.PDRectangle
import com.tom_roush.pdfbox.pdmodel.graphics.image.JPEGFactory import com.tom_roush.pdfbox.pdmodel.graphics.image.JPEGFactory
import java.io.OutputStream import java.io.OutputStream
import kotlin.math.max
fun writePdfFromJpegs(jpegs: Sequence<ByteArray>, outputStream: OutputStream) { class AndroidPdfWriter : PdfWriter {
PDDocument().use { document -> override fun writePdfFromJpegs(jpegs: Sequence<ByteArray>, outputStream: OutputStream): Int {
for (jpegBytes in jpegs) { val doc = PDDocument()
val image = JPEGFactory.createFromByteArray(document, jpegBytes) doc.use { document ->
val page = PDPage(PDRectangle(image.width.toFloat(), image.height.toFloat())) for (jpegBytes in jpegs) {
document.addPage(page) val image = JPEGFactory.createFromByteArray(document, jpegBytes)
val contentStream = PDPageContentStream(document, page, AppendMode.OVERWRITE, false) val page = PDPage(PDRectangle(image.width.toFloat(), image.height.toFloat()))
contentStream.drawImage(image, 0f, 0f) document.addPage(page)
contentStream.close() 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
} }
} }

View File

@@ -58,6 +58,7 @@ import org.mydomain.myscan.GeneratedPdf
import org.mydomain.myscan.PdfGenerationActions import org.mydomain.myscan.PdfGenerationActions
import org.mydomain.myscan.ui.PdfGenerationUiState import org.mydomain.myscan.ui.PdfGenerationUiState
import org.mydomain.myscan.ui.theme.MyScanTheme import org.mydomain.myscan.ui.theme.MyScanTheme
import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -267,7 +268,7 @@ fun PreviewPdfGenerationDialogDuringGeneration() {
fun PreviewPdfGenerationDialogAfterGeneration() { fun PreviewPdfGenerationDialogAfterGeneration() {
PreviewToCustomize( PreviewToCustomize(
uiState = PdfGenerationUiState( 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) @Preview(showBackground = true)
@Composable @Composable
fun PreviewPdfGenerationDialogAfterSave() { fun PreviewPdfGenerationDialogAfterSave() {
val file = File("fake.pdf")
PreviewToCustomize( PreviewToCustomize(
uiState = PdfGenerationUiState( uiState = PdfGenerationUiState(
generatedPdf = GeneratedPdf("file://fake.pdf".toUri(), 442897L, 3), generatedPdf = GeneratedPdf(file, 442897L, 3),
savedFileUri = "file:///fake".toUri() savedFileUri = file.toUri()
) )
) )
} }

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<ByteArray>, 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")
}
}