Log errors in a file

This commit is contained in:
Pierre-Yves Nicolas
2025-11-24 17:38:02 +01:00
committed by pynicolas
parent f805201768
commit f4aad46cb6
11 changed files with 160 additions and 76 deletions

View File

@@ -38,7 +38,7 @@ class DocumentDetectionTest {
assertEquals("org.fairscan.app", appContext.packageName) assertEquals("org.fairscan.app", appContext.packageName)
val context = ApplicationProvider.getApplicationContext<Context>() val context = ApplicationProvider.getApplicationContext<Context>()
val segmentationService = ImageSegmentationService(context) val segmentationService = ImageSegmentationService(context) { _, _, _ -> }
segmentationService.initialize() segmentationService.initialize()
OpenCVLoader.initLocal() OpenCVLoader.initLocal()

View File

@@ -17,10 +17,20 @@ package org.fairscan.app
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.os.Environment 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.ImageRepository
import org.fairscan.app.data.LogRepository
import org.fairscan.app.data.PdfFileManager 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.AndroidPdfWriter
import org.fairscan.app.platform.OpenCvTransformations 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 import java.io.File
class FairScanApp : Application() { class FairScanApp : Application() {
@@ -43,4 +53,21 @@ class AppContainer(context: Context) {
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
AndroidPdfWriter() AndroidPdfWriter()
) )
val logger = FileLogger(LogRepository(File(context.filesDir, "logs.txt")))
val imageSegmentationService = ImageSegmentationService(context, logger)
val recentDocumentsDataStore = context.recentDocumentsDataStore
@Suppress("UNCHECKED_CAST")
inline fun <reified VM : ViewModel> viewModelFactory(
crossinline create: (AppContainer) -> VM
) = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>, 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) }
} }

View File

@@ -69,10 +69,11 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
initLibraries() initLibraries()
val viewModel: MainViewModel by viewModels { MainViewModel.getFactory(this) } val appContainer = (application as FairScanApp).appContainer
val homeViewModel: HomeViewModel by viewModels { HomeViewModel.getFactory(this) } val viewModel: MainViewModel by viewModels { appContainer.mainViewModelFactory }
val cameraViewModel: CameraViewModel by viewModels { CameraViewModel.getFactory(this) } val homeViewModel: HomeViewModel by viewModels { appContainer.homeViewModelFactory }
val exportViewModel: ExportViewModel by viewModels { ExportViewModel.getFactory(this) } val cameraViewModel: CameraViewModel by viewModels { appContainer.cameraViewModelFactory }
val exportViewModel: ExportViewModel by viewModels { appContainer.exportViewModelFactory }
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
exportViewModel.cleanUpOldPdfs(1000 * 3600) exportViewModel.cleanUpOldPdfs(1000 * 3600)
} }

View File

@@ -14,13 +14,10 @@
*/ */
package org.fairscan.app package org.fairscan.app
import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -29,24 +26,13 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.fairscan.app.data.ImageRepository
import org.fairscan.app.ui.NavigationState import org.fairscan.app.ui.NavigationState
import org.fairscan.app.ui.Screen import org.fairscan.app.ui.Screen
import org.fairscan.app.ui.state.DocumentUiModel import org.fairscan.app.ui.state.DocumentUiModel
class MainViewModel( class MainViewModel(appContainer: AppContainer): ViewModel() {
private val imageRepository: ImageRepository
): ViewModel() {
companion object { private val imageRepository = appContainer.imageRepository
fun getFactory(context: Context) = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
val app = context.applicationContext as FairScanApp
return MainViewModel(app.appContainer.imageRepository) as T
}
}
}
private val _navigationState = MutableStateFlow(NavigationState.initial()) private val _navigationState = MutableStateFlow(NavigationState.initial())
val currentScreen: StateFlow<Screen> = _navigationState.map { it.current } val currentScreen: StateFlow<Screen> = _navigationState.map { it.current }

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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("")
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View File

@@ -28,6 +28,7 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.fairscan.app.data.Logger
import org.opencv.core.CvType import org.opencv.core.CvType
import org.opencv.core.Mat import org.opencv.core.Mat
import org.tensorflow.lite.DataType import org.tensorflow.lite.DataType
@@ -41,7 +42,7 @@ import org.tensorflow.lite.support.image.ops.Rot90Op
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
class ImageSegmentationService(private val context: Context) { class ImageSegmentationService(private val context: Context, private val logger: Logger) {
companion object { companion object {
private const val TAG = "ImageSegmentation" private const val TAG = "ImageSegmentation"
@@ -63,7 +64,7 @@ class ImageSegmentationService(private val context: Context) {
Interpreter(litertBuffer, options) Interpreter(litertBuffer, options)
} catch (e: Error) { } catch (e: Error) {
// That should not happen: let the app crash so that we know about it // 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) throw IllegalStateException("Failed to load LiteRT model", e)
} }
} }
@@ -107,7 +108,7 @@ class ImageSegmentationService(private val context: Context) {
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error occurred in image segmentation: ${e.message}") logger.e(TAG, "Error occurred in image segmentation", e)
} }
} }

View File

@@ -14,14 +14,11 @@
*/ */
package org.fairscan.app.ui.screens.camera package org.fairscan.app.ui.screens.camera
import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.util.Log import android.util.Log
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -32,7 +29,7 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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.detectDocumentQuad
import org.fairscan.app.domain.extractDocument import org.fairscan.app.domain.extractDocument
import org.fairscan.app.domain.scaledTo import org.fairscan.app.domain.scaledTo
@@ -43,18 +40,9 @@ sealed interface CameraEvent {
data class ImageCaptured(val jpegBytes: ByteArray) : CameraEvent data class ImageCaptured(val jpegBytes: ByteArray) : CameraEvent
} }
class CameraViewModel( class CameraViewModel(appContainer: AppContainer): ViewModel() {
private val imageSegmentationService: ImageSegmentationService
): ViewModel() {
companion object { private val imageSegmentationService = appContainer.imageSegmentationService
fun getFactory(context: Context) = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
return CameraViewModel(ImageSegmentationService(context)) as T
}
}
}
private val _events = MutableSharedFlow<CameraEvent>() private val _events = MutableSharedFlow<CameraEvent>()
val events = _events.asSharedFlow() val events = _events.asSharedFlow()

View File

@@ -16,12 +16,9 @@ package org.fairscan.app.ui.screens.export
import android.content.Context import android.content.Context
import android.media.MediaScannerConnection import android.media.MediaScannerConnection
import android.util.Log
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -34,9 +31,8 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext 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.GeneratedPdf
import org.fairscan.app.data.ImageRepository
import org.fairscan.app.data.PdfFileManager import org.fairscan.app.data.PdfFileManager
import org.fairscan.app.ui.screens.home.HomeViewModel import org.fairscan.app.ui.screens.home.HomeViewModel
import org.fairscan.app.ui.state.PdfGenerationUiState import org.fairscan.app.ui.state.PdfGenerationUiState
@@ -50,22 +46,11 @@ sealed interface ExportEvent {
data object PdfSaved : ExportEvent data object PdfSaved : ExportEvent
} }
class ExportViewModel( class ExportViewModel(container: AppContainer): ViewModel() {
private val pdfFileManager: PdfFileManager,
private val imageRepository: ImageRepository,
): ViewModel() {
companion object { private val pdfFileManager = container.pdfFileManager
fun getFactory(context: Context) = object : ViewModelProvider.Factory { private val imageRepository = container.imageRepository
@Suppress("UNCHECKED_CAST") private val logger = container.logger
override fun <T : ViewModel> create(modelClass: Class<T>, 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 _events = MutableSharedFlow<ExportEvent>() private val _events = MutableSharedFlow<ExportEvent>()
val events = _events.asSharedFlow() val events = _events.asSharedFlow()
@@ -100,7 +85,7 @@ class ExportViewModel(
) )
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("FairScan", "PDF generation failed", e) logger.e("FairScan", "PDF generation failed", e)
_pdfUiState.update { _pdfUiState.update {
it.copy( it.copy(
isGenerating = false, isGenerating = false,
@@ -172,7 +157,7 @@ class ExportViewModel(
_events.emit(ExportEvent.PdfSaved) _events.emit(ExportEvent.PdfSaved)
} catch (e: Exception) { } 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")) _events.emit(ExportEvent.ShowToast("Error while saving PDF"))
} }
} }

View File

@@ -14,33 +14,21 @@
*/ */
package org.fairscan.app.ui.screens.home package org.fairscan.app.ui.screens.home
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.fairscan.app.AppContainer
import org.fairscan.app.RecentDocument import org.fairscan.app.RecentDocument
import org.fairscan.app.RecentDocuments
import org.fairscan.app.data.recentDocumentsDataStore
import org.fairscan.app.ui.state.RecentDocumentUiState import org.fairscan.app.ui.state.RecentDocumentUiState
import java.io.File import java.io.File
class HomeViewModel(private val recentDocumentsDataStore: DataStore<RecentDocuments>): ViewModel() { class HomeViewModel(appContainer: AppContainer): ViewModel() {
companion object { private val recentDocumentsDataStore = appContainer.recentDocumentsDataStore
fun getFactory(context: Context) = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
return HomeViewModel(context.recentDocumentsDataStore) as T
}
}
}
val recentDocuments: StateFlow<List<RecentDocumentUiState>> = val recentDocuments: StateFlow<List<RecentDocumentUiState>> =
recentDocumentsDataStore.data.map { recentDocumentsDataStore.data.map {

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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())
}
}