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, +)