Refactoring: PdfFileManager
This commit is contained in:
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
70
app/src/main/java/org/mydomain/myscan/PdfFileManager.kt
Normal file
70
app/src/main/java/org/mydomain/myscan/PdfFileManager.kt
Normal 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() }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user