diff --git a/app/src/androidTest/java/org/fairscan/app/domain/DocumentDetectionTest.kt b/app/src/androidTest/java/org/fairscan/app/domain/DocumentDetectionTest.kt index 9a542be..5d1625f 100644 --- a/app/src/androidTest/java/org/fairscan/app/domain/DocumentDetectionTest.kt +++ b/app/src/androidTest/java/org/fairscan/app/domain/DocumentDetectionTest.kt @@ -38,7 +38,7 @@ class DocumentDetectionTest { assertEquals("org.fairscan.app", appContext.packageName) val context = ApplicationProvider.getApplicationContext() - val segmentationService = ImageSegmentationService(context) + val segmentationService = ImageSegmentationService(context) { _, _, _ -> } segmentationService.initialize() OpenCVLoader.initLocal() diff --git a/app/src/main/java/org/fairscan/app/FairScanApp.kt b/app/src/main/java/org/fairscan/app/FairScanApp.kt index c3471fe..a34e850 100644 --- a/app/src/main/java/org/fairscan/app/FairScanApp.kt +++ b/app/src/main/java/org/fairscan/app/FairScanApp.kt @@ -17,10 +17,20 @@ package org.fairscan.app import android.app.Application import android.content.Context import android.os.Environment +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.CreationExtras +import org.fairscan.app.data.FileLogger import org.fairscan.app.data.ImageRepository +import org.fairscan.app.data.LogRepository import org.fairscan.app.data.PdfFileManager +import org.fairscan.app.data.recentDocumentsDataStore +import org.fairscan.app.domain.ImageSegmentationService import org.fairscan.app.platform.AndroidPdfWriter import org.fairscan.app.platform.OpenCvTransformations +import org.fairscan.app.ui.screens.camera.CameraViewModel +import org.fairscan.app.ui.screens.export.ExportViewModel +import org.fairscan.app.ui.screens.home.HomeViewModel import java.io.File class FairScanApp : Application() { @@ -43,4 +53,21 @@ class AppContainer(context: Context) { Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), AndroidPdfWriter() ) + val logger = FileLogger(LogRepository(File(context.filesDir, "logs.txt"))) + val imageSegmentationService = ImageSegmentationService(context, logger) + val recentDocumentsDataStore = context.recentDocumentsDataStore + + @Suppress("UNCHECKED_CAST") + inline fun viewModelFactory( + crossinline create: (AppContainer) -> VM + ) = object : ViewModelProvider.Factory { + override fun create(modelClass: Class, extras: CreationExtras): T { + return create(this@AppContainer) as T + } + } + + val mainViewModelFactory = viewModelFactory { MainViewModel(it) } + val homeViewModelFactory = viewModelFactory { HomeViewModel(it) } + val cameraViewModelFactory = viewModelFactory { CameraViewModel(it) } + val exportViewModelFactory = viewModelFactory { ExportViewModel(it) } } diff --git a/app/src/main/java/org/fairscan/app/MainActivity.kt b/app/src/main/java/org/fairscan/app/MainActivity.kt index 78f21ea..8236137 100644 --- a/app/src/main/java/org/fairscan/app/MainActivity.kt +++ b/app/src/main/java/org/fairscan/app/MainActivity.kt @@ -69,10 +69,11 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) initLibraries() - val viewModel: MainViewModel by viewModels { MainViewModel.getFactory(this) } - val homeViewModel: HomeViewModel by viewModels { HomeViewModel.getFactory(this) } - val cameraViewModel: CameraViewModel by viewModels { CameraViewModel.getFactory(this) } - val exportViewModel: ExportViewModel by viewModels { ExportViewModel.getFactory(this) } + val appContainer = (application as FairScanApp).appContainer + val viewModel: MainViewModel by viewModels { appContainer.mainViewModelFactory } + val homeViewModel: HomeViewModel by viewModels { appContainer.homeViewModelFactory } + val cameraViewModel: CameraViewModel by viewModels { appContainer.cameraViewModelFactory } + val exportViewModel: ExportViewModel by viewModels { appContainer.exportViewModelFactory } lifecycleScope.launch(Dispatchers.IO) { exportViewModel.cleanUpOldPdfs(1000 * 3600) } diff --git a/app/src/main/java/org/fairscan/app/MainViewModel.kt b/app/src/main/java/org/fairscan/app/MainViewModel.kt index 400f9ed..e432807 100644 --- a/app/src/main/java/org/fairscan/app/MainViewModel.kt +++ b/app/src/main/java/org/fairscan/app/MainViewModel.kt @@ -14,13 +14,10 @@ */ package org.fairscan.app -import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory 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.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -29,24 +26,13 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.fairscan.app.data.ImageRepository import org.fairscan.app.ui.NavigationState import org.fairscan.app.ui.Screen import org.fairscan.app.ui.state.DocumentUiModel -class MainViewModel( - private val imageRepository: ImageRepository -): ViewModel() { +class MainViewModel(appContainer: AppContainer): 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 - return MainViewModel(app.appContainer.imageRepository) as T - } - } - } + private val imageRepository = appContainer.imageRepository private val _navigationState = MutableStateFlow(NavigationState.initial()) val currentScreen: StateFlow = _navigationState.map { it.current } diff --git a/app/src/main/java/org/fairscan/app/data/LogRepository.kt b/app/src/main/java/org/fairscan/app/data/LogRepository.kt new file mode 100644 index 0000000..440a40a --- /dev/null +++ b/app/src/main/java/org/fairscan/app/data/LogRepository.kt @@ -0,0 +1,42 @@ +/* + * 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.data + +import java.io.File + +class LogRepository(private val file: File) { + + fun getLogs(): String = file.readText() + + fun log(tag: String, message: String, throwable: Throwable) { + val line = buildString { + append("${System.currentTimeMillis()} [$tag] $message") + append("\n${throwable.stackTraceToString()}") + } + + try { + ensureFileSizeIsReasonable() + file.appendText(line + "\n\n") + } catch (_: Exception) { + // Avoid throwing another exception: do nothing + } + } + + private fun ensureFileSizeIsReasonable() { + if (file.length() > 128 * 1024) { + file.writeText("") + } + } +} diff --git a/app/src/main/java/org/fairscan/app/data/Logger.kt b/app/src/main/java/org/fairscan/app/data/Logger.kt new file mode 100644 index 0000000..14fd536 --- /dev/null +++ b/app/src/main/java/org/fairscan/app/data/Logger.kt @@ -0,0 +1,30 @@ +/* + * 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.data + +import android.util.Log + +fun interface Logger { + fun e(tag: String, message: String, throwable: Throwable) +} + +class FileLogger( + private val logRepository: LogRepository +) : Logger { + override fun e(tag: String, message: String, throwable: Throwable) { + Log.e(tag, message, throwable) + logRepository.log(tag, message, throwable) + } +} diff --git a/app/src/main/java/org/fairscan/app/domain/ImageSegmentation.kt b/app/src/main/java/org/fairscan/app/domain/ImageSegmentation.kt index 4f1c1d2..2cf34d4 100644 --- a/app/src/main/java/org/fairscan/app/domain/ImageSegmentation.kt +++ b/app/src/main/java/org/fairscan/app/domain/ImageSegmentation.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import org.fairscan.app.data.Logger import org.opencv.core.CvType import org.opencv.core.Mat import org.tensorflow.lite.DataType @@ -41,7 +42,7 @@ import org.tensorflow.lite.support.image.ops.Rot90Op import java.nio.ByteBuffer import java.nio.ByteOrder -class ImageSegmentationService(private val context: Context) { +class ImageSegmentationService(private val context: Context, private val logger: Logger) { companion object { private const val TAG = "ImageSegmentation" @@ -63,7 +64,7 @@ class ImageSegmentationService(private val context: Context) { Interpreter(litertBuffer, options) } catch (e: Error) { // That should not happen: let the app crash so that we know about it - Log.e(TAG, "Failed to load LiteRT model: ${e.message}") + logger.e(TAG, "Failed to load LiteRT model", e) throw IllegalStateException("Failed to load LiteRT model", e) } } @@ -107,7 +108,7 @@ class ImageSegmentationService(private val context: Context) { } } } catch (e: Exception) { - Log.e(TAG, "Error occurred in image segmentation: ${e.message}") + logger.e(TAG, "Error occurred in image segmentation", e) } } diff --git a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt index 5b25fc7..ee966cb 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/camera/CameraViewModel.kt @@ -14,14 +14,11 @@ */ package org.fairscan.app.ui.screens.camera -import android.content.Context import android.graphics.Bitmap import android.util.Log import androidx.camera.core.ImageProxy import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.CreationExtras import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -32,7 +29,7 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.fairscan.app.domain.ImageSegmentationService +import org.fairscan.app.AppContainer import org.fairscan.app.domain.detectDocumentQuad import org.fairscan.app.domain.extractDocument import org.fairscan.app.domain.scaledTo @@ -43,18 +40,9 @@ sealed interface CameraEvent { data class ImageCaptured(val jpegBytes: ByteArray) : CameraEvent } -class CameraViewModel( - private val imageSegmentationService: ImageSegmentationService -): ViewModel() { +class CameraViewModel(appContainer: AppContainer): ViewModel() { - companion object { - fun getFactory(context: Context) = object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class, extras: CreationExtras): T { - return CameraViewModel(ImageSegmentationService(context)) as T - } - } - } + private val imageSegmentationService = appContainer.imageSegmentationService private val _events = MutableSharedFlow() val events = _events.asSharedFlow() 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 a2c532d..c02e32a 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 @@ -16,12 +16,9 @@ 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 -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 @@ -34,9 +31,8 @@ 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.AppContainer 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 @@ -50,22 +46,11 @@ sealed interface ExportEvent { data object PdfSaved : ExportEvent } -class ExportViewModel( - private val pdfFileManager: PdfFileManager, - private val imageRepository: ImageRepository, -): ViewModel() { +class ExportViewModel(container: AppContainer): 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 val pdfFileManager = container.pdfFileManager + private val imageRepository = container.imageRepository + private val logger = container.logger private val _events = MutableSharedFlow() val events = _events.asSharedFlow() @@ -100,7 +85,7 @@ class ExportViewModel( ) } } catch (e: Exception) { - Log.e("FairScan", "PDF generation failed", e) + logger.e("FairScan", "PDF generation failed", e) _pdfUiState.update { it.copy( isGenerating = false, @@ -172,7 +157,7 @@ class ExportViewModel( _events.emit(ExportEvent.PdfSaved) } catch (e: Exception) { - Log.e("FairScan", "Failed to save PDF", e) + logger.e("FairScan", "Failed to save PDF", e) _events.emit(ExportEvent.ShowToast("Error while saving PDF")) } } diff --git a/app/src/main/java/org/fairscan/app/ui/screens/home/HomeViewModel.kt b/app/src/main/java/org/fairscan/app/ui/screens/home/HomeViewModel.kt index ab03980..5c94b5c 100644 --- a/app/src/main/java/org/fairscan/app/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/java/org/fairscan/app/ui/screens/home/HomeViewModel.kt @@ -14,33 +14,21 @@ */ package org.fairscan.app.ui.screens.home -import android.content.Context -import androidx.datastore.core.DataStore import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.CreationExtras import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import org.fairscan.app.AppContainer import org.fairscan.app.RecentDocument -import org.fairscan.app.RecentDocuments -import org.fairscan.app.data.recentDocumentsDataStore import org.fairscan.app.ui.state.RecentDocumentUiState import java.io.File -class HomeViewModel(private val recentDocumentsDataStore: DataStore): ViewModel() { +class HomeViewModel(appContainer: AppContainer): ViewModel() { - companion object { - fun getFactory(context: Context) = object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class, extras: CreationExtras): T { - return HomeViewModel(context.recentDocumentsDataStore) as T - } - } - } + private val recentDocumentsDataStore = appContainer.recentDocumentsDataStore val recentDocuments: StateFlow> = recentDocumentsDataStore.data.map { diff --git a/app/src/test/java/org/fairscan/app/data/LogRepositoryTest.kt b/app/src/test/java/org/fairscan/app/data/LogRepositoryTest.kt new file mode 100644 index 0000000..6b20ac7 --- /dev/null +++ b/app/src/test/java/org/fairscan/app/data/LogRepositoryTest.kt @@ -0,0 +1,36 @@ +/* + * 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.data + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class LogRepositoryTest { + + @get:Rule + var folder: TemporaryFolder = TemporaryFolder() + + @Test + fun log_with_exception() { + val repo = LogRepository(folder.newFile()) + assertThat(repo.getLogs()).isEmpty() + repo.log("tag1", "message1", IllegalArgumentException("my exception")) + assertThat(repo.getLogs()).contains("[tag1] message1") + assertThat(repo.getLogs()).contains("my exception") + print(repo.getLogs()) + } +} \ No newline at end of file