From f805201768c50ae4a09fe934165333834df304a6 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Nicolas <6371790+pynicolas@users.noreply.github.com> Date: Sun, 23 Nov 2025 19:05:20 +0100 Subject: [PATCH] MainActivity shouldn't do the job of a ViewModel --- .../java/org/fairscan/app/MainActivity.kt | 61 +++++++------------ .../app/ui/screens/export/ExportViewModel.kt | 60 ++++++++++++++++++ 2 files changed, 82 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/org/fairscan/app/MainActivity.kt b/app/src/main/java/org/fairscan/app/MainActivity.kt index 06c0d33..78f21ea 100644 --- a/app/src/main/java/org/fairscan/app/MainActivity.kt +++ b/app/src/main/java/org/fairscan/app/MainActivity.kt @@ -19,7 +19,6 @@ import android.content.ActivityNotFoundException import android.content.Intent import android.content.pm.PackageManager import android.content.pm.PackageManager.PERMISSION_GRANTED -import android.media.MediaScannerConnection import android.net.Uri import android.os.Build.VERSION.SDK_INT import android.os.Build.VERSION_CODES.Q @@ -42,25 +41,23 @@ import androidx.core.net.toFile import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext 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.screens.AboutScreen import org.fairscan.app.ui.screens.DocumentScreen -import org.fairscan.app.ui.screens.home.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.ExportEvent 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.screens.home.HomeScreen import org.fairscan.app.ui.screens.home.HomeViewModel import org.fairscan.app.ui.theme.FairScanTheme import org.opencv.android.OpenCVLoader @@ -93,17 +90,34 @@ class MainActivity : ComponentActivity() { val liveAnalysisState by cameraViewModel.liveAnalysisState.collectAsStateWithLifecycle() val document by viewModel.documentUiModel.collectAsStateWithLifecycle() val cameraPermission = rememberCameraPermissionState() - val savePdf = { savePdf(exportViewModel.getFinalPdf(), homeViewModel, exportViewModel) } val storagePermissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() ) { isGranted -> if (isGranted) { - savePdf() + exportViewModel.onSavePdfClicked() } else { val message = getString(R.string.storage_permission_denied) Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } } + LaunchedEffect(Unit) { + exportViewModel.events.collect { event -> + when (event) { + ExportEvent.RequestSavePdf -> { + checkPermissionThen(storagePermissionLauncher) { + exportViewModel.onRequestPdfSave(context, homeViewModel) + } + } + is ExportEvent.ShowToast -> { + Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() + } + ExportEvent.PdfSaved -> { + Toast.makeText(context, "PDF saved", Toast.LENGTH_SHORT).show() + } + } + } + } + FairScanTheme { val navigation = Navigation( toHomeScreen = { viewModel.navigateTo(Screen.Main.Home) }, @@ -155,7 +169,7 @@ class MainActivity : ComponentActivity() { setFilename = exportViewModel::setFilename, uiStateFlow = exportViewModel.pdfUiState, sharePdf = { sharePdf(exportViewModel.getFinalPdf(), exportViewModel) }, - savePdf = { checkPermissionThen(storagePermissionLauncher, savePdf) }, + savePdf = { exportViewModel.onSavePdfClicked() }, openPdf = { openPdf(exportViewModel.pdfUiState.value.savedFileUri) } ), onCloseScan = { @@ -210,37 +224,6 @@ class MainActivity : ComponentActivity() { } } - private fun savePdf( - generatedPdf: GeneratedPdf?, - homeViewModel: HomeViewModel, - exportViewModel: ExportViewModel - ) { - if (generatedPdf == null) - return - val appScope = CoroutineScope(Dispatchers.IO) - val context = this - appScope.launch { - try { - val targetFile = exportViewModel.saveFile(generatedPdf.file) - homeViewModel.addRecentDocument(targetFile.absolutePath, generatedPdf.pageCount) - - suspendCancellableCoroutine { continuation -> - MediaScannerConnection.scanFile( - context, - arrayOf(targetFile.absolutePath), - arrayOf(PDF_MIME_TYPE) - ) { _, _ -> continuation.resume(Unit) {} } - } - } catch (e: Exception) { - Log.e("FairScan", "Failed to save PDF", e) - withContext(Dispatchers.Main) { - Toast.makeText(context, - getString(R.string.error_save), Toast.LENGTH_SHORT).show() - } - } - } - } - private fun openPdf(fileUri: Uri?) { if (fileUri == null) return val uri = FileProvider.getUriForFile( 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 index 39081bc..a2c532d 100644 --- 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 @@ -15,6 +15,7 @@ package org.fairscan.app.ui.screens.export import android.content.Context +import android.media.MediaScannerConnection import android.util.Log import androidx.core.net.toUri import androidx.lifecycle.ViewModel @@ -22,20 +23,33 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine 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.screens.home.HomeViewModel import org.fairscan.app.ui.state.PdfGenerationUiState import java.io.File +private const val PDF_MIME_TYPE = "application/pdf" + +sealed interface ExportEvent { + data object RequestSavePdf : ExportEvent + data class ShowToast(val message: String) : ExportEvent + data object PdfSaved : ExportEvent +} + class ExportViewModel( private val pdfFileManager: PdfFileManager, private val imageRepository: ImageRepository, @@ -53,6 +67,9 @@ class ExportViewModel( } } + private val _events = MutableSharedFlow() + val events = _events.asSharedFlow() + private suspend fun generatePdf(): GeneratedPdf = withContext(Dispatchers.IO) { val imageIds = imageRepository.imageIds() val jpegs = imageIds.asSequence() @@ -127,6 +144,49 @@ class ExportViewModel( return copiedFile } + fun onSavePdfClicked() { + viewModelScope.launch { + _events.emit(ExportEvent.RequestSavePdf) + } + } + + fun onRequestPdfSave(context: Context, homeViewModel: HomeViewModel) { + viewModelScope.launch { + performPdfSave(context, homeViewModel) + } + } + + private suspend fun performPdfSave(context: Context, homeViewModel: HomeViewModel) { + try { + val pdf = getFinalPdf() ?: return + val targetFile = saveFile(pdf.file) + + mediaScan(context, targetFile) + + // TODO remove that call: that should be handled through the ExportEvent + homeViewModel.addRecentDocument( + targetFile.absolutePath, + pdf.pageCount + ) + + _events.emit(ExportEvent.PdfSaved) + + } catch (e: Exception) { + Log.e("FairScan", "Failed to save PDF", e) + _events.emit(ExportEvent.ShowToast("Error while saving PDF")) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private suspend fun mediaScan(context: Context, file: File) = + suspendCancellableCoroutine { continuation -> + MediaScannerConnection.scanFile( + context, + arrayOf(file.absolutePath), + arrayOf(PDF_MIME_TYPE) + ) { _, _ -> continuation.resume(Unit) {} } + } + fun cleanUpOldPdfs(thresholdInMillis: Int) { pdfFileManager.cleanUpOldFiles(thresholdInMillis) }