diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e4a6e11..5ffc390 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -15,6 +15,7 @@
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
+ android:name=".FairScanApp"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.FairScan"
diff --git a/app/src/main/java/org/fairscan/app/FairScanApp.kt b/app/src/main/java/org/fairscan/app/FairScanApp.kt
new file mode 100644
index 0000000..c3471fe
--- /dev/null
+++ b/app/src/main/java/org/fairscan/app/FairScanApp.kt
@@ -0,0 +1,46 @@
+/*
+ * 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.fairscan.app
+
+import android.app.Application
+import android.content.Context
+import android.os.Environment
+import org.fairscan.app.data.ImageRepository
+import org.fairscan.app.data.PdfFileManager
+import org.fairscan.app.platform.AndroidPdfWriter
+import org.fairscan.app.platform.OpenCvTransformations
+import java.io.File
+
+class FairScanApp : Application() {
+ lateinit var appContainer: AppContainer
+
+ override fun onCreate() {
+ super.onCreate()
+ appContainer = AppContainer(this)
+ }
+}
+
+const val THUMBNAIL_SIZE_DP = 120
+
+class AppContainer(context: Context) {
+ private val density = context.resources.displayMetrics.density
+ private val thumbnailSizePx = (THUMBNAIL_SIZE_DP * density).toInt()
+ val imageRepository = ImageRepository(context.filesDir, OpenCvTransformations(), thumbnailSizePx)
+ val pdfFileManager = PdfFileManager(
+ File(context.cacheDir, "pdfs"),
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
+ AndroidPdfWriter()
+ )
+}
diff --git a/app/src/main/java/org/fairscan/app/MainActivity.kt b/app/src/main/java/org/fairscan/app/MainActivity.kt
index 4203725..45fdc66 100644
--- a/app/src/main/java/org/fairscan/app/MainActivity.kt
+++ b/app/src/main/java/org/fairscan/app/MainActivity.kt
@@ -51,15 +51,17 @@ import org.fairscan.app.data.GeneratedPdf
import org.fairscan.app.ui.Navigation
import org.fairscan.app.ui.Screen
import org.fairscan.app.ui.components.rememberCameraPermissionState
-import org.fairscan.app.ui.theme.FairScanTheme
import org.fairscan.app.ui.screens.AboutScreen
-import org.fairscan.app.ui.screens.camera.CameraScreen
import org.fairscan.app.ui.screens.DocumentScreen
-import org.fairscan.app.ui.screens.ExportScreenWrapper
import org.fairscan.app.ui.screens.HomeScreen
import org.fairscan.app.ui.screens.LibrariesScreen
import org.fairscan.app.ui.screens.camera.CameraEvent
+import org.fairscan.app.ui.screens.camera.CameraScreen
import org.fairscan.app.ui.screens.camera.CameraViewModel
+import org.fairscan.app.ui.screens.export.ExportScreenWrapper
+import org.fairscan.app.ui.screens.export.ExportViewModel
+import org.fairscan.app.ui.screens.export.PdfGenerationActions
+import org.fairscan.app.ui.theme.FairScanTheme
import org.opencv.android.OpenCVLoader
private const val PDF_MIME_TYPE = "application/pdf"
@@ -70,10 +72,11 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
initLibraries()
val viewModel: MainViewModel by viewModels { MainViewModel.getFactory(this) }
- lifecycleScope.launch(Dispatchers.IO) {
- viewModel.cleanUpOldPdfs(1000 * 3600)
- }
val cameraViewModel: CameraViewModel by viewModels { CameraViewModel.getFactory(this) }
+ val exportViewModel: ExportViewModel by viewModels { ExportViewModel.getFactory(this) }
+ lifecycleScope.launch(Dispatchers.IO) {
+ exportViewModel.cleanUpOldPdfs(1000 * 3600)
+ }
enableEdgeToEdge()
setContent {
LaunchedEffect(Unit) {
@@ -88,7 +91,7 @@ class MainActivity : ComponentActivity() {
val liveAnalysisState by cameraViewModel.liveAnalysisState.collectAsStateWithLifecycle()
val document by viewModel.documentUiModel.collectAsStateWithLifecycle()
val cameraPermission = rememberCameraPermissionState()
- val savePdf = { savePdf(viewModel.getFinalPdf(), viewModel) }
+ val savePdf = { savePdf(exportViewModel.getFinalPdf(), viewModel, exportViewModel) }
val storagePermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
@@ -146,12 +149,12 @@ class MainActivity : ComponentActivity() {
ExportScreenWrapper(
navigation = navigation,
pdfActions = PdfGenerationActions(
- startGeneration = viewModel::startPdfGeneration,
- setFilename = viewModel::setFilename,
- uiStateFlow = viewModel.pdfUiState,
- sharePdf = { sharePdf(viewModel.getFinalPdf(), viewModel) },
+ startGeneration = exportViewModel::startPdfGeneration,
+ setFilename = exportViewModel::setFilename,
+ uiStateFlow = exportViewModel.pdfUiState,
+ sharePdf = { sharePdf(exportViewModel.getFinalPdf(), exportViewModel) },
savePdf = { checkPermissionThen(storagePermissionLauncher, savePdf) },
- openPdf = { openPdf(viewModel.pdfUiState.value.savedFileUri) }
+ openPdf = { openPdf(exportViewModel.pdfUiState.value.savedFileUri) }
),
onCloseScan = {
viewModel.startNewDocument()
@@ -170,7 +173,7 @@ class MainActivity : ComponentActivity() {
}
}
- private fun sharePdf(generatedPdf: GeneratedPdf?, viewModel: MainViewModel) {
+ private fun sharePdf(generatedPdf: GeneratedPdf?, viewModel: ExportViewModel) {
if (generatedPdf == null)
return
viewModel.setPdfAsShared()
@@ -205,14 +208,18 @@ class MainActivity : ComponentActivity() {
}
}
- private fun savePdf(generatedPdf: GeneratedPdf?, viewModel: MainViewModel) {
+ private fun savePdf(
+ generatedPdf: GeneratedPdf?,
+ viewModel: MainViewModel,
+ exportViewModel: ExportViewModel
+ ) {
if (generatedPdf == null)
return
val appScope = CoroutineScope(Dispatchers.IO)
val context = this
appScope.launch {
try {
- val targetFile = viewModel.saveFile(generatedPdf.file)
+ val targetFile = exportViewModel.saveFile(generatedPdf.file)
viewModel.addRecentDocument(targetFile.absolutePath, generatedPdf.pageCount)
suspendCancellableCoroutine { continuation ->
diff --git a/app/src/main/java/org/fairscan/app/MainViewModel.kt b/app/src/main/java/org/fairscan/app/MainViewModel.kt
index 559cb2d..d7f9bd1 100644
--- a/app/src/main/java/org/fairscan/app/MainViewModel.kt
+++ b/app/src/main/java/org/fairscan/app/MainViewModel.kt
@@ -17,44 +17,29 @@ package org.fairscan.app
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
-import android.os.Environment
-import android.util.Log
-import androidx.core.net.toUri
import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import kotlinx.collections.immutable.persistentListOf
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import org.fairscan.app.data.GeneratedPdf
import org.fairscan.app.data.ImageRepository
-import org.fairscan.app.data.PdfFileManager
import org.fairscan.app.data.recentDocumentsDataStore
-import org.fairscan.app.platform.AndroidPdfWriter
-import org.fairscan.app.platform.OpenCvTransformations
import org.fairscan.app.ui.NavigationState
import org.fairscan.app.ui.Screen
import org.fairscan.app.ui.state.DocumentUiModel
-import org.fairscan.app.ui.state.PdfGenerationUiState
import org.fairscan.app.ui.state.RecentDocumentUiState
import java.io.File
-const val THUMBNAIL_SIZE_DP = 120
-
class MainViewModel(
private val imageRepository: ImageRepository,
- private val pdfFileManager: PdfFileManager,
private val recentDocumentsDataStore: DataStore,
): ViewModel() {
@@ -62,15 +47,9 @@ class MainViewModel(
fun getFactory(context: Context) = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun create(modelClass: Class, extras: CreationExtras): T {
- val density = context.resources.displayMetrics.density
- val thumbnailSizePx = (THUMBNAIL_SIZE_DP * density).toInt()
+ val app = context.applicationContext as FairScanApp
return MainViewModel(
- ImageRepository(context.filesDir, OpenCvTransformations(), thumbnailSizePx),
- PdfFileManager(
- File(context.cacheDir, "pdfs"),
- Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
- AndroidPdfWriter()
- ),
+ app.appContainer.imageRepository,
context.recentDocumentsDataStore,
) as T
}
@@ -137,85 +116,6 @@ class MainViewModel(
return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
}
- private suspend fun generatePdf(): GeneratedPdf = withContext(Dispatchers.IO) {
- val imageIds = imageRepository.imageIds()
- val jpegs = imageIds.asSequence()
- .map { id -> imageRepository.getContent(id) }
- .filterNotNull()
- return@withContext pdfFileManager.generatePdf(jpegs)
- }
-
- private val _pdfUiState = MutableStateFlow(PdfGenerationUiState())
- val pdfUiState: StateFlow = _pdfUiState.asStateFlow()
-
- private var generationJob: Job? = null
- private var desiredFilename: String = ""
-
- fun setFilename(name: String) {
- desiredFilename = name
- }
-
- fun startPdfGeneration() {
- cancelPdfGeneration()
- generationJob = viewModelScope.launch {
- try {
- val result = generatePdf()
- _pdfUiState.update {
- it.copy(
- isGenerating = false,
- generatedPdf = result
- )
- }
- } catch (e: Exception) {
- Log.e("FairScan", "PDF generation failed", e)
- _pdfUiState.update {
- it.copy(
- isGenerating = false,
- errorMessage = "PDF generation failed"
- )
- }
- }
- }
- }
-
- fun cancelPdfGeneration() {
- generationJob?.cancel()
- _pdfUiState.value = PdfGenerationUiState()
- }
-
- fun setPdfAsShared() {
- _pdfUiState.update { it.copy(hasSharedPdf = true) }
- }
-
- fun getFinalPdf(): GeneratedPdf? {
- val tempPdf = _pdfUiState.value.generatedPdf ?: return null
- val tempFile = tempPdf.file
- val fileName = PdfFileManager.addExtensionIfMissing(desiredFilename)
- val newFile = File(tempFile.parentFile, fileName)
- if (tempFile.absolutePath != newFile.absolutePath) {
- if (newFile.exists()) newFile.delete()
- val success = tempFile.renameTo(newFile)
- if (!success) return null
- _pdfUiState.update {
- it.copy(generatedPdf = GeneratedPdf(
- newFile, tempPdf.sizeInBytes, tempPdf.pageCount)
- )
- }
- }
- return _pdfUiState.value.generatedPdf
- }
-
- fun saveFile(pdfFile: File): File {
- val copiedFile = pdfFileManager.copyToExternalDir(pdfFile)
- _pdfUiState.update { it.copy(savedFileUri = copiedFile.toUri()) }
- return copiedFile
- }
-
- fun cleanUpOldPdfs(thresholdInMillis: Int) {
- pdfFileManager.cleanUpOldFiles(thresholdInMillis)
- }
-
-
val recentDocuments: StateFlow> =
recentDocumentsDataStore.data.map {
it.documentsList.map {
@@ -256,13 +156,3 @@ class MainViewModel(
_pageIds.value = imageRepository.imageIds()
}
}
-
-// TODO Move somewhere else: ViewModel should not depend on that
-data class PdfGenerationActions(
- val startGeneration: () -> Unit,
- val setFilename: (String) -> Unit,
- val uiStateFlow: StateFlow,// TODO is it ok to have that here?
- val sharePdf: () -> Unit,
- val savePdf: () -> Unit,
- val openPdf: () -> Unit,
-)
diff --git a/app/src/main/java/org/fairscan/app/ui/screens/ExportScreen.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt
similarity index 99%
rename from app/src/main/java/org/fairscan/app/ui/screens/ExportScreen.kt
rename to app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt
index 4a37da7..4f54142 100644
--- a/app/src/main/java/org/fairscan/app/ui/screens/ExportScreen.kt
+++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportScreen.kt
@@ -12,7 +12,7 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see .
*/
-package org.fairscan.app.ui.screens
+package org.fairscan.app.ui.screens.export
import android.content.Context
import android.text.format.Formatter
@@ -66,11 +66,9 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
+import org.fairscan.app.R
import org.fairscan.app.data.GeneratedPdf
import org.fairscan.app.ui.Navigation
-import org.fairscan.app.PdfGenerationActions
-import org.fairscan.app.R
-import org.fairscan.app.ui.state.PdfGenerationUiState
import org.fairscan.app.ui.components.AboutScreenNavButton
import org.fairscan.app.ui.components.BackButton
import org.fairscan.app.ui.components.MainActionButton
@@ -78,6 +76,7 @@ import org.fairscan.app.ui.components.NewDocumentDialog
import org.fairscan.app.ui.components.isLandscape
import org.fairscan.app.ui.components.pageCountText
import org.fairscan.app.ui.dummyNavigation
+import org.fairscan.app.ui.state.PdfGenerationUiState
import org.fairscan.app.ui.theme.FairScanTheme
import java.io.File
import java.text.SimpleDateFormat
diff --git a/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt
new file mode 100644
index 0000000..39081bc
--- /dev/null
+++ b/app/src/main/java/org/fairscan/app/ui/screens/export/ExportViewModel.kt
@@ -0,0 +1,143 @@
+/*
+ * 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.fairscan.app.ui.screens.export
+
+import android.content.Context
+import android.util.Log
+import androidx.core.net.toUri
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.CreationExtras
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.fairscan.app.FairScanApp
+import org.fairscan.app.data.GeneratedPdf
+import org.fairscan.app.data.ImageRepository
+import org.fairscan.app.data.PdfFileManager
+import org.fairscan.app.ui.state.PdfGenerationUiState
+import java.io.File
+
+class ExportViewModel(
+ private val pdfFileManager: PdfFileManager,
+ private val imageRepository: ImageRepository,
+): ViewModel() {
+
+ companion object {
+ fun getFactory(context: Context) = object : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class, extras: CreationExtras): T {
+ val app = context.applicationContext as FairScanApp
+ val pdfFileManager = app.appContainer.pdfFileManager
+ val imageRepository = app.appContainer.imageRepository
+ return ExportViewModel(pdfFileManager, imageRepository) as T
+ }
+ }
+ }
+
+ private suspend fun generatePdf(): GeneratedPdf = withContext(Dispatchers.IO) {
+ val imageIds = imageRepository.imageIds()
+ val jpegs = imageIds.asSequence()
+ .map { id -> imageRepository.getContent(id) }
+ .filterNotNull()
+ return@withContext pdfFileManager.generatePdf(jpegs)
+ }
+
+ private val _pdfUiState = MutableStateFlow(PdfGenerationUiState())
+ val pdfUiState: StateFlow = _pdfUiState.asStateFlow()
+
+ private var generationJob: Job? = null
+ private var desiredFilename: String = ""
+
+ fun setFilename(name: String) {
+ desiredFilename = name
+ }
+
+ fun startPdfGeneration() {
+ cancelPdfGeneration()
+ generationJob = viewModelScope.launch {
+ try {
+ val result = generatePdf()
+ _pdfUiState.update {
+ it.copy(
+ isGenerating = false,
+ generatedPdf = result
+ )
+ }
+ } catch (e: Exception) {
+ Log.e("FairScan", "PDF generation failed", e)
+ _pdfUiState.update {
+ it.copy(
+ isGenerating = false,
+ errorMessage = "PDF generation failed"
+ )
+ }
+ }
+ }
+ }
+
+ fun cancelPdfGeneration() {
+ generationJob?.cancel()
+ _pdfUiState.value = PdfGenerationUiState()
+ }
+
+ fun setPdfAsShared() {
+ _pdfUiState.update { it.copy(hasSharedPdf = true) }
+ }
+
+ fun getFinalPdf(): GeneratedPdf? {
+ val tempPdf = _pdfUiState.value.generatedPdf ?: return null
+ val tempFile = tempPdf.file
+ val fileName = PdfFileManager.addExtensionIfMissing(desiredFilename)
+ val newFile = File(tempFile.parentFile, fileName)
+ if (tempFile.absolutePath != newFile.absolutePath) {
+ if (newFile.exists()) newFile.delete()
+ val success = tempFile.renameTo(newFile)
+ if (!success) return null
+ _pdfUiState.update {
+ it.copy(generatedPdf = GeneratedPdf(
+ newFile, tempPdf.sizeInBytes, tempPdf.pageCount)
+ )
+ }
+ }
+ return _pdfUiState.value.generatedPdf
+ }
+
+ fun saveFile(pdfFile: File): File {
+ val copiedFile = pdfFileManager.copyToExternalDir(pdfFile)
+ _pdfUiState.update { it.copy(savedFileUri = copiedFile.toUri()) }
+ return copiedFile
+ }
+
+ fun cleanUpOldPdfs(thresholdInMillis: Int) {
+ pdfFileManager.cleanUpOldFiles(thresholdInMillis)
+ }
+
+}
+
+data class PdfGenerationActions(
+ val startGeneration: () -> Unit,
+ val setFilename: (String) -> Unit,
+ val uiStateFlow: StateFlow,// TODO is it ok to have that here?
+ val sharePdf: () -> Unit,
+ val savePdf: () -> Unit,
+ val openPdf: () -> Unit,
+)