Feature/optional logging (#416)

This commit is contained in:
Max Grakov
2026-05-26 23:25:27 +03:00
committed by GitHub
parent 0f99828d70
commit fad66ebbcc
14 changed files with 244 additions and 95 deletions

View File

@@ -185,11 +185,13 @@ dependencies {
implementation(libs.converter.moshi)
implementation(libs.moshi)
implementation(libs.zip4j)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
testImplementation(libs.junit.jupiter)
testImplementation(libs.mockk)
testRuntimeOnly(libs.junit.platform.launcher)
androidTestImplementation(libs.androidx.test.ext.junit)

View File

@@ -13,6 +13,7 @@ import org.acra.security.TLS
import org.acra.sender.HttpSender
import org.grakovne.lissen.common.RunningComponent
import org.grakovne.lissen.logging.LissenLogProvider
import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences
import timber.log.Timber
import javax.inject.Inject
@@ -24,6 +25,9 @@ class LissenApplication : Application() {
@Inject
lateinit var lissenLogProvider: LissenLogProvider
@Inject
lateinit var preferences: LissenSharedPreferences
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
@@ -55,9 +59,11 @@ class LissenApplication : Application() {
Timber.plant(Timber.DebugTree())
}
try {
Timber.plant(lissenLogProvider.provideLoggingTree())
} catch (_: Exception) {
if (preferences.isActivityLoggingEnabled()) {
try {
Timber.plant(lissenLogProvider.provideLoggingTree())
} catch (_: Exception) {
}
}
}

View File

@@ -49,6 +49,8 @@ private fun sourceWithBackdropBlur(
scaled.recycle()
scaled.recycle()
val backdrop = Bitmap.createBitmap(blurredPadded, padding / 2, padding / 2, size, size)
blurredPadded.recycle()

View File

@@ -19,7 +19,7 @@ class CachedBookmarkProvider
@Inject
constructor(
private val channelProvider: AudiobookshelfChannelProvider,
private val localCacheRepository: LocalCacheRepository
private val localCacheRepository: LocalCacheRepository,
) {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

View File

@@ -2,6 +2,11 @@ package org.grakovne.lissen.logging
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import net.lingala.zip4j.ZipFile
import net.lingala.zip4j.model.ZipParameters
import net.lingala.zip4j.model.enums.CompressionLevel
import net.lingala.zip4j.model.enums.CompressionMethod
import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@@ -11,6 +16,7 @@ class LissenLogProvider
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val preferences: LissenSharedPreferences,
) {
private val tree: FileLoggingTree by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
FileLoggingTree(profileLogFile())
@@ -20,7 +26,36 @@ class LissenLogProvider
fun provideLoggingTree(): FileLoggingTree = tree
fun enableLogging() {
preferences.saveActivityLoggingEnabled(true)
}
fun disableLogging() {
preferences.saveActivityLoggingEnabled(false)
}
fun archiveLogFile(): File? {
val logFile = profileLogFile()
if (!logFile.exists() || logFile.length() == 0L) return null
val archiveFile = File(context.cacheDir, FILE_LOG_ARCHIVE_NAME)
archiveFile.delete()
val parameters =
ZipParameters().apply {
compressionMethod = CompressionMethod.DEFLATE
compressionLevel = CompressionLevel.ULTRA
}
ZipFile(archiveFile).use { zip ->
zip.addFile(logFile, parameters)
}
return archiveFile
}
companion object {
private const val FILE_LOG_NAME = "lissen_log.txt"
private const val FILE_LOG_ARCHIVE_NAME = "lissen_logs.zip"
}
}

View File

@@ -499,6 +499,13 @@ class LissenSharedPreferences
putBoolean(KEY_SOFTWARE_CODECS, value)
}
fun isActivityLoggingEnabled(): Boolean = sharedPreferences.getBoolean(KEY_ACTIVITY_LOGGING, true)
fun saveActivityLoggingEnabled(value: Boolean) =
sharedPreferences.edit {
putBoolean(KEY_ACTIVITY_LOGGING, value)
}
fun getHideCompleted(): Boolean = sharedPreferences.getBoolean(KEY_HIDE_COMPLETED, false)
fun saveHideCompleted(value: Boolean) =
@@ -534,6 +541,7 @@ class LissenSharedPreferences
private const val KEY_AUTO_DOWNLOAD_DELAYED = "auto_download_delayed"
private const val KEY_PREFERRED_LIBRARY_ORDERING = "preferred_library_ordering"
private const val KEY_SOFTWARE_CODECS = "software_codecs"
private const val KEY_ACTIVITY_LOGGING = "activity_logging_enabled"
private const val KEY_HIDE_COMPLETED = "hide_completed"
private const val KEY_CUSTOM_HEADERS = "custom_headers"

View File

@@ -6,7 +6,6 @@ import android.os.Build
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -16,6 +15,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material.icons.outlined.Memory
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -24,7 +24,6 @@ import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -43,6 +42,7 @@ import org.grakovne.lissen.R
import org.grakovne.lissen.common.restartApplication
import org.grakovne.lissen.ui.navigation.AppNavigationService
import org.grakovne.lissen.ui.screens.settings.composable.PlaybackVolumeBoostSettingsComposable
import org.grakovne.lissen.ui.screens.settings.composable.SettingsInfoBanner
import org.grakovne.lissen.ui.screens.settings.composable.SettingsToggleItem
import org.grakovne.lissen.viewmodel.CachingModelView
import org.grakovne.lissen.viewmodel.SettingsViewModel
@@ -63,6 +63,8 @@ fun AdvancedSettingsComposable(
val materialYouColorsEnabled by viewModel.materialYouEnabled.observeAsState(false)
val softwareCodecsEnabled by viewModel.softwareCodecsEnabled.observeAsState(false)
val softwareCodecsEnabledOnStart = viewModel.softwareCodecsEnabledOnStart
val activityLoggingEnabled by viewModel.activityLoggingEnabled.observeAsState(true)
val activityLoggingEnabledOnStart = viewModel.activityLoggingEnabledOnStart
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -152,9 +154,16 @@ fun AdvancedSettingsComposable(
},
)
SettingsToggleItem(
title = stringResource(R.string.settings_screen_activity_logging_title),
description = stringResource(R.string.settings_screen_activity_logging_description),
initialState = activityLoggingEnabled,
) { viewModel.preferActivityLoggingEnabled(it) }
AdvancedSettingsSimpleItemComposable(
title = stringResource(R.string.export_logs_title),
description = stringResource(R.string.export_logs_description),
enabled = activityLoggingEnabledOnStart,
onclick = { shareLogs(context, viewModel) },
)
}
@@ -162,6 +171,10 @@ fun AdvancedSettingsComposable(
if (softwareCodecsEnabledOnStart != softwareCodecsEnabled) {
SoftwareCodecsPreferenceBanner()
}
if (activityLoggingEnabledOnStart != activityLoggingEnabled) {
ActivityLoggingPreferenceBanner()
}
}
},
)
@@ -170,52 +183,34 @@ fun AdvancedSettingsComposable(
@Composable
fun SoftwareCodecsPreferenceBanner(modifier: Modifier = Modifier) {
val context = LocalContext.current
SettingsInfoBanner(
icon = Icons.Outlined.Memory,
text = stringResource(R.string.restart_the_app_to_start_using_the_new_codecs_title),
ctaText = stringResource(R.string.restart_the_app_to_start_using_the_new_codecs_cta),
onAction = { context.restartApplication() },
modifier = modifier,
)
}
Row(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Outlined.Memory,
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier.padding(end = 12.dp),
)
Text(
text = stringResource(R.string.restart_the_app_to_start_using_the_new_codecs_title),
style =
typography.bodyMedium.copy(
color = colorScheme.onSurface,
),
modifier = Modifier.weight(1f),
)
TextButton(
onClick = { context.restartApplication() },
) {
Text(
text = stringResource(R.string.restart_the_app_to_start_using_the_new_codecs_cta),
style =
typography.bodyMedium.copy(
color = colorScheme.primary,
fontWeight = FontWeight.SemiBold,
),
)
}
}
@Composable
fun ActivityLoggingPreferenceBanner(modifier: Modifier = Modifier) {
val context = LocalContext.current
SettingsInfoBanner(
icon = Icons.Outlined.Description,
text = stringResource(R.string.restart_the_app_to_apply_logging_changes_title),
ctaText = stringResource(R.string.restart_the_app_to_apply_logging_changes_cta),
onAction = { context.restartApplication() },
modifier = modifier,
)
}
private fun shareLogs(
context: Context,
viewModel: SettingsViewModel,
) {
val logFile = viewModel.provideLogFileOrNull()
val archiveFile = viewModel.provideLogArchiveOrNull()
if (logFile == null) {
if (archiveFile == null) {
Toast.makeText(context, context.getString(R.string.export_logs_no_logs), Toast.LENGTH_SHORT).show()
return
}
@@ -224,7 +219,7 @@ private fun shareLogs(
FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
logFile,
archiveFile,
)
val exportTimestamp = OffsetDateTime.now()
@@ -232,7 +227,7 @@ private fun shareLogs(
val formattedTimestamp = exportTimestamp.format(formatter)
val sizeKb = logFile.length() / 1024
val sizeKb = archiveFile.length() / 1024
val subject =
"${context.getString(R.string.app_name)} logs • $formattedTimestamp$sizeKb KB"
@@ -246,14 +241,13 @@ private fun shareLogs(
val shareIntent =
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
type = "application/zip"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, subject)
putExtra(Intent.EXTRA_TEXT, details)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(shareIntent, "Export logs"))

View File

@@ -19,13 +19,14 @@ import androidx.compose.ui.unit.dp
fun AdvancedSettingsSimpleItemComposable(
title: String,
description: String,
enabled: Boolean = true,
onclick: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { onclick() }
.let { if (enabled) it.clickable { onclick() } else it }
.padding(start = 24.dp, end = 12.dp, top = 12.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -38,11 +39,12 @@ fun AdvancedSettingsSimpleItemComposable(
modifier = Modifier.padding(bottom = 4.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (enabled) colorScheme.onBackground else colorScheme.onBackground.copy(alpha = 0.4f),
)
Text(
text = description,
style = typography.bodyMedium,
color = colorScheme.onSurfaceVariant,
color = if (enabled) colorScheme.onSurfaceVariant else colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)

View File

@@ -49,6 +49,7 @@ import kotlinx.coroutines.launch
import org.grakovne.lissen.R
import org.grakovne.lissen.lib.domain.connection.LocalUrl
import org.grakovne.lissen.ui.screens.common.hasLocationPermission
import org.grakovne.lissen.ui.screens.settings.composable.SettingsInfoBanner
import org.grakovne.lissen.viewmodel.SettingsViewModel
import kotlin.math.max
@@ -182,46 +183,17 @@ fun LocationPermissionBanner(
modifier: Modifier = Modifier,
onResult: (Boolean) -> Unit,
) {
Row(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.LocationOn,
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier.padding(end = 12.dp),
val permissionRequestLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = onResult,
)
val permissionRequestLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = onResult,
)
Text(
text = stringResource(R.string.location_permission_request_hint),
style =
typography.bodyMedium.copy(
color = colorScheme.onSurface,
),
modifier = Modifier.weight(1f),
)
TextButton(
onClick = { permissionRequestLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) },
) {
Text(
text = stringResource(R.string.permission_request_grant_button),
style =
typography.bodyMedium.copy(
color = colorScheme.primary,
fontWeight = FontWeight.SemiBold,
),
)
}
}
SettingsInfoBanner(
icon = Icons.Default.LocationOn,
text = stringResource(R.string.location_permission_request_hint),
ctaText = stringResource(R.string.permission_request_grant_button),
onAction = { permissionRequestLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) },
modifier = modifier,
)
}

View File

@@ -93,17 +93,18 @@ class SettingsViewModel
val softwareCodecsEnabled: LiveData<Boolean> = _softwareCodecsEnabled
val softwareCodecsEnabledOnStart: Boolean = preferences.getSoftwareCodecsEnabled()
private val _activityLoggingEnabled = MutableLiveData(preferences.isActivityLoggingEnabled())
val activityLoggingEnabled: LiveData<Boolean> = _activityLoggingEnabled
val activityLoggingEnabledOnStart: Boolean = preferences.isActivityLoggingEnabled()
private val _hideCompleted = preferences.hideCompletedFlow
val hideCompleted = _hideCompleted
private val _autoDownloadDelayed = MutableLiveData(preferences.getAutoDownloadDelayed())
val autoDownloadDelayed = _autoDownloadDelayed
fun provideLogFileOrNull(): File? {
val logFile = logProvider.profileLogFile()
return logFile.takeIf { it.exists() && it.length() > 0L }
}
fun provideLogArchiveOrNull(): File? = logProvider.archiveLogFile()
fun preferCrashReporting(value: Boolean) {
Timber.d("User action: preferCrashReporting $value")
@@ -249,6 +250,12 @@ class SettingsViewModel
preferences.saveSoftwareCodecsEnabled(value)
}
fun preferActivityLoggingEnabled(value: Boolean) {
Timber.d("User action: preferActivityLoggingEnabled $value")
_activityLoggingEnabled.postValue(value)
if (value) logProvider.enableLogging() else logProvider.disableLogging()
}
fun preferAutoDownloadOption(option: DownloadOption?) {
Timber.d("User action: preferAutoDownloadOption $option")
_preferredAutoDownloadOption.postValue(option)

View File

@@ -190,6 +190,10 @@
<string name="settings_screen_client_cert_remove_action">Отвязать сертификат</string>
<string name="settings_screen_client_cert_picker_cancelled_toast">mTLS не найден</string>
<string name="login_error_client_cert_error">mTLS не прошел проверку на сервере</string>
<string name="settings_screen_activity_logging_title">Записывать логи</string>
<string name="settings_screen_activity_logging_description">Сохранять диагностические данные в файл</string>
<string name="restart_the_app_to_apply_logging_changes_title">Перезапустите приложение, чтобы применить настройки логирования</string>
<string name="restart_the_app_to_apply_logging_changes_cta">Перезапустить</string>
<string name="export_logs_title">Экспортировать логи</string>
<string name="export_logs_description">Выгрузить данные приложения для отладки</string>
<string name="export_logs_no_logs">Файл с логами не найден</string>

View File

@@ -188,6 +188,10 @@
<string name="connection_settings_title">Connection preferences</string>
<string name="connection_settings_description">Control server address, proxy, and SSL settings</string>
<string name="disconnect_from_server_title">Disconnect from the server</string>
<string name="settings_screen_activity_logging_title">Activity Logging</string>
<string name="settings_screen_activity_logging_description">Record diagnostic events to a local file</string>
<string name="restart_the_app_to_apply_logging_changes_title">Restart the app to apply logging changes</string>
<string name="restart_the_app_to_apply_logging_changes_cta">Restart</string>
<string name="export_logs_title">Export logs</string>
<string name="export_logs_description">Share diagnostic data for troubleshooting</string>
<string name="export_logs_no_logs">No logs available</string>

View File

@@ -0,0 +1,110 @@
package org.grakovne.lissen.logging
import android.content.Context
import io.mockk.every
import io.mockk.justRun
import io.mockk.mockk
import io.mockk.verify
import net.lingala.zip4j.ZipFile
import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.File
class LissenLogProviderTest {
@TempDir
lateinit var tempDir: File
private lateinit var context: Context
private lateinit var preferences: LissenSharedPreferences
private lateinit var provider: LissenLogProvider
@BeforeEach
fun setUp() {
context = mockk()
preferences = mockk()
every { context.cacheDir } returns tempDir
provider = LissenLogProvider(context, preferences)
}
@Test
fun `archiveLogFile returns null when log file does not exist`() {
assertNull(provider.archiveLogFile())
}
@Test
fun `archiveLogFile returns null when log file is empty`() {
provider.profileLogFile().createNewFile()
assertNull(provider.archiveLogFile())
}
@Test
fun `archiveLogFile returns zip file when log file has content`() {
provider.profileLogFile().writeText("some log content")
val archive = provider.archiveLogFile()
assertNotNull(archive)
assertTrue(archive!!.exists())
assertTrue(archive.length() > 0)
}
@Test
fun `archiveLogFile produces valid zip containing the log file`() {
val logContent = "line1\nline2\nline3"
provider.profileLogFile().writeText(logContent)
val archive = provider.archiveLogFile()!!
val zip = ZipFile(archive)
assertTrue(zip.isValidZipFile)
val entry = zip.fileHeaders.single()
assertEquals(provider.profileLogFile().name, entry.fileName)
}
@Test
fun `archiveLogFile overwrites previous archive on repeated calls`() {
provider.profileLogFile().writeText("first run")
val first = provider.archiveLogFile()!!
provider.profileLogFile().writeText("second run with more content to make it larger")
val second = provider.archiveLogFile()!!
assertEquals(first.absolutePath, second.absolutePath)
}
@Test
fun `disableLogging saves preference as false`() {
provider.profileLogFile().writeText("logs")
justRun { preferences.saveActivityLoggingEnabled(false) }
provider.disableLogging()
verify { preferences.saveActivityLoggingEnabled(false) }
}
@Test
fun `disableLogging is safe when log file does not exist`() {
justRun { preferences.saveActivityLoggingEnabled(false) }
provider.disableLogging()
assertTrue(!provider.profileLogFile().exists())
}
@Test
fun `enableLogging saves preference as true`() {
justRun { preferences.saveActivityLoggingEnabled(true) }
provider.enableLogging()
verify { preferences.saveActivityLoggingEnabled(true) }
}
}

View File

@@ -37,6 +37,7 @@ junitJupiter = "6.1.0"
mockk = "1.14.9"
androidxTestExtJunit = "1.3.0"
androidxTestRunner = "1.7.0"
zip4j = "2.11.5"
[libraries]
acra-core = { module = "ch.acra:acra-core", version.ref = "acraCore" }
@@ -89,9 +90,11 @@ moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
moshi-kotlin-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" }
androidx-media3-ffmpeg-decoder = { module = "org.jellyfin.media3:media3-ffmpeg-decoder", version.ref = "media3Ffmpeg" }
process-phoenix = { module = "com.jakewharton:process-phoenix", version.ref = "processPhoenix" }
zip4j = { module = "net.lingala.zip4j:zip4j", version.ref = "zip4j" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junitJupiter" }
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junitJupiter" }
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestExtJunit" }
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxTestRunner" }