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?) {
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(

View File

@@ -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<GeneratedPdf?>(null)
private val _pdfUiState = MutableStateFlow(PdfGenerationUiState())
val pdfUiState: StateFlow<PdfGenerationUiState> = _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,
)

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
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<ByteArray>, 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<ByteArray>, 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
}
}

View File

@@ -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()
)
)
}