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)
val context = ApplicationProvider.getApplicationContext<Context>()
val segmentationService = ImageSegmentationService(context)
val segmentationService = ImageSegmentationService(context) { _, _, _ -> }
segmentationService.initialize()
OpenCVLoader.initLocal()

View File

@@ -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 <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?) {
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)
}

View File

@@ -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 <T : ViewModel> create(modelClass: Class<T>, 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<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.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)
}
}

View File

@@ -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 <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
return CameraViewModel(ImageSegmentationService(context)) as T
}
}
}
private val imageSegmentationService = appContainer.imageSegmentationService
private val _events = MutableSharedFlow<CameraEvent>()
val events = _events.asSharedFlow()

View File

@@ -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 <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 pdfFileManager = container.pdfFileManager
private val imageRepository = container.imageRepository
private val logger = container.logger
private val _events = MutableSharedFlow<ExportEvent>()
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"))
}
}

View File

@@ -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<RecentDocuments>): ViewModel() {
class HomeViewModel(appContainer: AppContainer): ViewModel() {
companion object {
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
}
}
}
private val recentDocumentsDataStore = appContainer.recentDocumentsDataStore
val recentDocuments: StateFlow<List<RecentDocumentUiState>> =
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())
}
}