AboutScreen: add button to copy logs to clipboard

This commit is contained in:
Pierre-Yves Nicolas
2025-11-26 17:02:47 +01:00
committed by pynicolas
parent f4aad46cb6
commit 3f10a1cd55
15 changed files with 150 additions and 11 deletions

View File

@@ -28,6 +28,7 @@ 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.about.AboutViewModel
import org.fairscan.app.ui.screens.camera.CameraViewModel
import org.fairscan.app.ui.screens.export.ExportViewModel
import org.fairscan.app.ui.screens.home.HomeViewModel
@@ -53,7 +54,8 @@ class AppContainer(context: Context) {
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
AndroidPdfWriter()
)
val logger = FileLogger(LogRepository(File(context.filesDir, "logs.txt")))
val logRepository = LogRepository(File(context.filesDir, "logs.txt"))
val logger = FileLogger(logRepository)
val imageSegmentationService = ImageSegmentationService(context, logger)
val recentDocumentsDataStore = context.recentDocumentsDataStore
@@ -70,4 +72,5 @@ class AppContainer(context: Context) {
val homeViewModelFactory = viewModelFactory { HomeViewModel(it) }
val cameraViewModelFactory = viewModelFactory { CameraViewModel(it) }
val exportViewModelFactory = viewModelFactory { ExportViewModel(it) }
val aboutViewModelFactory = viewModelFactory { AboutViewModel(it) }
}

View File

@@ -16,6 +16,7 @@ package org.fairscan.app
import android.Manifest
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.PackageManager.PERMISSION_GRANTED
@@ -34,7 +35,10 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.toClipEntry
import androidx.compose.ui.res.stringResource
import androidx.core.content.ContextCompat.checkSelfPermission
import androidx.core.content.FileProvider
import androidx.core.net.toFile
@@ -47,9 +51,11 @@ 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.LibrariesScreen
import org.fairscan.app.ui.screens.about.AboutEvent
import org.fairscan.app.ui.screens.about.AboutScreen
import org.fairscan.app.ui.screens.about.AboutViewModel
import org.fairscan.app.ui.screens.camera.CameraEvent
import org.fairscan.app.ui.screens.camera.CameraScreen
import org.fairscan.app.ui.screens.camera.CameraViewModel
@@ -74,6 +80,7 @@ class MainActivity : ComponentActivity() {
val homeViewModel: HomeViewModel by viewModels { appContainer.homeViewModelFactory }
val cameraViewModel: CameraViewModel by viewModels { appContainer.cameraViewModelFactory }
val exportViewModel: ExportViewModel by viewModels { appContainer.exportViewModelFactory }
val aboutViewModel: AboutViewModel by viewModels { appContainer.aboutViewModelFactory }
lifecycleScope.launch(Dispatchers.IO) {
exportViewModel.cleanUpOldPdfs(1000 * 3600)
}
@@ -118,6 +125,20 @@ class MainActivity : ComponentActivity() {
}
}
}
val clipboard = LocalClipboard.current
val msgCopiedLogs = stringResource(R.string.copied_logs)
LaunchedEffect(aboutViewModel.events) {
aboutViewModel.events.collect { event ->
when (event) {
is AboutEvent.CopyLogs -> {
clipboard.setClipEntry(
ClipData.newPlainText("FairScan logs", event.logs).toClipEntry()
)
Toast.makeText(context, msgCopiedLogs, Toast.LENGTH_SHORT).show()
}
}
}
}
FairScanTheme {
val navigation = Navigation(
@@ -180,7 +201,10 @@ class MainActivity : ComponentActivity() {
)
}
is Screen.Overlay.About -> {
AboutScreen(onBack = navigation.back, onViewLibraries = navigation.toLibrariesScreen)
AboutScreen(
onBack = navigation.back,
onCopyLogs = { aboutViewModel.onCopyLogsClicked() },
onViewLibraries = navigation.toLibrariesScreen)
}
is Screen.Overlay.Libraries -> {
LibrariesScreen(onBack = navigation.back)

View File

@@ -18,7 +18,7 @@ import java.io.File
class LogRepository(private val file: File) {
fun getLogs(): String = file.readText()
fun getLogs(): String = if (file.exists()) file.readText() else ""
fun log(tag: String, message: String, throwable: Throwable) {
val line = buildString {

View File

@@ -12,7 +12,7 @@
* 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.ui.screens
package org.fairscan.app.ui.screens.about
import android.content.Intent
import androidx.activity.compose.BackHandler
@@ -32,6 +32,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Language
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -69,7 +70,11 @@ import org.fairscan.app.ui.theme.FairScanTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AboutScreen(onBack: () -> Unit, onViewLibraries: () -> Unit) {
fun AboutScreen(
onBack: () -> Unit,
onCopyLogs: () -> Unit,
onViewLibraries: () -> Unit,
) {
val showLicenseDialog = rememberSaveable { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
BackHandler { onBack() }
@@ -81,7 +86,11 @@ fun AboutScreen(onBack: () -> Unit, onViewLibraries: () -> Unit) {
)
}
) { paddingValues ->
AboutContent(modifier = Modifier.padding(paddingValues), showLicenseDialog, onViewLibraries)
AboutContent(
modifier = Modifier.padding(paddingValues),
onCopyLogs,
showLicenseDialog,
onViewLibraries)
}
if (showLicenseDialog.value) {
LicenseBottomSheet(sheetState, onDismiss = { showLicenseDialog.value = false })
@@ -91,6 +100,7 @@ fun AboutScreen(onBack: () -> Unit, onViewLibraries: () -> Unit) {
@Composable
fun AboutContent(
modifier: Modifier = Modifier,
onCopyLogs: () -> Unit,
showLicenseDialog: MutableState<Boolean>,
onViewLibraries: () -> Unit,
) {
@@ -142,12 +152,12 @@ fun AboutContent(
context.startActivity(intent)
}
)
CopyLogsButton (onClick = onCopyLogs)
}
Section(title = stringResource(R.string.license)) {
Text(
stringResource(R.string.licensed_under),
)
Text(
text = stringResource(R.string.view_the_full_license),
@@ -156,7 +166,6 @@ fun AboutContent(
)
}
Section(title = stringResource(R.string.libraries)) {
Text(
stringResource(R.string.libraries_intro) +
@@ -259,10 +268,29 @@ fun LicenseBottomSheet(
}
}
@Composable
fun CopyLogsButton(onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = null
)
Spacer(modifier = Modifier.width(16.dp))
Text(stringResource(R.string.copy_logs))
}
}
@Preview
@Composable
fun AboutScreenPreview() {
FairScanTheme {
AboutScreen(onBack = {}, onViewLibraries = {})
AboutScreen(onBack = {}, onCopyLogs = {}, onViewLibraries = {})
}
}

View File

@@ -0,0 +1,58 @@
/*
* 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.ui.screens.about
import android.os.Build
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import org.fairscan.app.AppContainer
import org.fairscan.app.BuildConfig
import java.time.LocalDateTime
sealed interface AboutEvent {
data class CopyLogs(val logs: String) : AboutEvent
}
class AboutViewModel(container: AppContainer): ViewModel() {
private val logRepository = container.logRepository
private val _events = MutableSharedFlow<AboutEvent>()
val events = _events.asSharedFlow()
fun onCopyLogsClicked() {
viewModelScope.launch {
val logs = buildFullLogs()
_events.emit(AboutEvent.CopyLogs(logs))
}
}
private fun buildFullLogs(): String {
val header = buildString {
appendLine("FairScan diagnostics report")
appendLine("App version: ${BuildConfig.VERSION_NAME}")
appendLine("Android version: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
appendLine("Generated: ${LocalDateTime.now()}")
appendLine()
appendLine("-- Application logs --")
appendLine()
}
return header + logRepository.getLogs()
}
}

View File

@@ -1,7 +1,6 @@
<resources>
<string name="about">O aplikaci</string>
<string name="add_page">Přidat stránku</string>
<string name="app_name" translatable="false">FairScan</string>
<string name="app_tagline">Jednoduchá a respektující aplikace pro skenování vašich dokumentů</string>
<string name="back">Zpět</string>
<string name="camera_permission_denied">Byl odepřen přístup k fotoaparátu</string>
@@ -9,6 +8,8 @@
<string name="cancel">Zrušit</string>
<string name="clear_text">Smazat text</string>
<string name="contact">Kontakt</string>
<string name="copied_logs">Protokoly zkopírovány do schránky</string>
<string name="copy_logs">Kopírovat protokoly</string>
<string name="creating_pdf">Vytváření PDF…</string>
<string name="delete_page">Smazat stránku</string>
<string name="delete_page_warning">Chcete smazat tuto stránku?</string>

View File

@@ -8,6 +8,8 @@
<string name="cancel">Abbrechen</string>
<string name="clear_text">Text löschen</string>
<string name="contact">Kontakt</string>
<string name="copied_logs">Logs in die Zwischenablage kopiert</string>
<string name="copy_logs">Logs kopieren</string>
<string name="creating_pdf">PDF wird erstellt…</string>
<string name="delete_page">Seite löschen</string>
<string name="delete_page_warning">Möchten Sie diese Seite löschen?</string>

View File

@@ -8,6 +8,8 @@
<string name="cancel">Cancelar</string>
<string name="clear_text">Borrar texto</string>
<string name="contact">Contacto</string>
<string name="copied_logs">Registros copiados al portapapeles</string>
<string name="copy_logs">Copiar registros</string>
<string name="creating_pdf">Creando PDF…</string>
<string name="delete_page">Eliminar página</string>
<string name="delete_page_warning">¿Quieres eliminar esta página?</string>

View File

@@ -8,6 +8,8 @@
<string name="cancel">Annuler</string>
<string name="contact">Contact</string>
<string name="clear_text">Effacer le text</string>
<string name="copied_logs">Logs copiés dans le presse-papiers</string>
<string name="copy_logs">Copier les logs</string>
<string name="creating_pdf">Création du PDF…</string>
<string name="delete_page">Supprimer la page</string>
<string name="delete_page_warning">Voulez-vous supprimer cette page ?</string>

View File

@@ -8,6 +8,8 @@
<string name="cancel">Annulla</string>
<string name="clear_text">Svuota testo</string>
<string name="contact">Contatti</string>
<string name="copied_logs">Log copiati negli appunti</string>
<string name="copy_logs">Copia log</string>
<string name="creating_pdf">Creazione PDF…</string>
<string name="delete_page">Elimina pagina</string>
<string name="delete_page_warning">Vuoi eliminare questa pagina?</string>

View File

@@ -8,6 +8,8 @@
<string name="cancel">Cancelar</string>
<string name="clear_text">Limpar texto</string>
<string name="contact">Contato</string>
<string name="copied_logs">Registros copiados para a área de transferência</string>
<string name="copy_logs">Copiar registros</string>
<string name="creating_pdf">Criando PDF…</string>
<string name="delete_page">Excluir página</string>
<string name="delete_page_warning">Deseja excluir esta página?</string>

View File

@@ -8,6 +8,8 @@
<string name="cancel">Отмена</string>
<string name="clear_text">Стереть текст</string>
<string name="contact">Контакты</string>
<string name="copied_logs">Журналы скопированы в буфер обмена</string>
<string name="copy_logs">Копировать журналы</string>
<string name="creating_pdf">Создание PDF…</string>
<string name="delete_page">Удалить страницу</string>
<string name="delete_page_warning">Вы желаете удалить эту страницу?</string>

View File

@@ -8,6 +8,8 @@
<string name="cancel">取消</string>
<string name="clear_text">清除文字</string>
<string name="contact">联系人</string>
<string name="copied_logs">日志已复制到剪贴板</string>
<string name="copy_logs">复制日志</string>
<string name="creating_pdf">正在创建 PDF…</string>
<string name="delete_page">删除页面</string>
<string name="delete_page_warning">是否要删除此页面?</string>

View File

@@ -9,6 +9,8 @@
<string name="cancel">Cancel</string>
<string name="clear_text">Clear text</string>
<string name="contact">Contact</string>
<string name="copied_logs">Logs copied to clipboard</string>
<string name="copy_logs">Copy logs</string>
<string name="creating_pdf">Creating PDF…</string>
<string name="delete_page">Delete page</string>
<string name="delete_page_warning">Do you want to delete this page?</string>

View File

@@ -18,6 +18,7 @@ import org.assertj.core.api.Assertions.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
class LogRepositoryTest {
@@ -33,4 +34,12 @@ class LogRepositoryTest {
assertThat(repo.getLogs()).contains("my exception")
print(repo.getLogs())
}
@Test
fun get_logs_with_file_not_yet_created() {
val file = File(folder.newFolder(), "log.txt")
val repo = LogRepository(file)
assertThat(file).doesNotExist()
assertThat(repo.getLogs()).isEmpty()
}
}