From b57cd1813bcf50f1cf5bfe2234b9074d7a3e03be Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 6 Nov 2026 10:24:24 +0100 Subject: feat(app): export the app log to a file Reporting a bug needed adb. Settings, Misc now has Export log: the app reads its own logcat lines, adds its version and the Android version, and saves them through the system file picker, so nothing leaves the device unless the user sends it. The bug report template explains it and points at this project instead of upstream. Co-Authored-By: Claude Opus 5.5 --- .../ui/settings/SettingsDestination.kt | 13 ++++++++++ .../app/passwordstore/ui/settings/SettingsGraph.kt | 3 ++- .../app/passwordstore/ui/settings/SettingsModel.kt | 15 ++++++++++++ .../passwordstore/ui/settings/SettingsScreen.kt | 6 +++++ .../passwordstore/ui/settings/SettingsViewModel.kt | 28 ++++++++++++++++++++++ app/src/main/res/values-it/strings.xml | 4 ++++ app/src/main/res/values/strings.xml | 4 ++++ 7 files changed, 72 insertions(+), 1 deletion(-) (limited to 'app') diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt index c3809b36..05dc6b65 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt @@ -38,6 +38,7 @@ import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.lifecycleScope import app.passwordstore.BuildConfig import app.passwordstore.R import app.passwordstore.ui.compose.theme.SpacingLarge @@ -52,6 +53,7 @@ import app.passwordstore.util.services.PasswordExportService import app.passwordstore.util.settings.PreferenceKeys import com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel import com.github.androidpasswordstore.autofillparser.getInstalledBrowsersWithAutofillSupportLevel +import kotlinx.coroutines.launch private enum class SettingsDialog { Autofill, @@ -125,6 +127,16 @@ fun SettingsSectionDestination( ) } + val exportLog = + rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri -> + if (uri != null) + activity.lifecycleScope.launch { + val message = + if (viewModel.exportLog(uri)) R.string.log_exported else R.string.log_export_failed + activity.snackbar(message = activity.getString(message)) + } + } + // One screen lock prompt at a time: a double tap must not open two. A recreation resets it // (remember), and the old activity's prompt is gone with it. var prompting by remember { mutableStateOf(false) } @@ -206,6 +218,7 @@ fun SettingsSectionDestination( SettingsAction.ClearSecret -> dialog = SettingsDialog.ClearSecret SettingsAction.DeleteRepository -> dialog = SettingsDialog.DeleteRepository SettingsAction.Export -> export.launch(null) + SettingsAction.ExportLog -> exportLog.launch("hardenedpass-log.txt") else -> onOpen(action) } }, diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt index ba4d158c..e45166e0 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt @@ -70,7 +70,8 @@ fun NavGraphBuilder.settingsGraph(activity: MainActivity, nav: NavHostController // Handled by the destination itself. SettingsAction.ClearSecret, SettingsAction.DeleteRepository, - SettingsAction.Export -> Unit + SettingsAction.Export, + SettingsAction.ExportLog -> Unit } }, onDeleted = { diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt index eca6770c..54250f74 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt @@ -9,6 +9,7 @@ import app.passwordstore.util.extensions.hash import app.passwordstore.util.extensions.time import app.passwordstore.util.git.GitCommit import java.io.IOException +import java.io.OutputStream import logcat.LogPriority.ERROR import logcat.asLog import logcat.logcat @@ -77,3 +78,17 @@ fun gitLog(repo: Repository?): List { emptyList() } } + +/** + * Writes [header], then what logcat holds for this app: Android shows an app only its own lines, + * and those of its earlier runs and crashes until the buffer rotates. + */ +fun writeAppLog(header: String, out: OutputStream) { + out.write(header.toByteArray()) + val logcat = + ProcessBuilder("logcat", "-d", "-b", "main,crash", "-v", "threadtime") + .redirectErrorStream(true) + .start() + logcat.inputStream.use { it.copyTo(out) } + logcat.waitFor() +} diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt index a7bbe30a..2e7840ad 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt @@ -63,6 +63,7 @@ enum class SettingsAction { ClearSecret, DeleteRepository, Export, + ExportLog, } /** Top bar with [title] and back, then [content] in one grouped surface, scrolling. */ @@ -324,6 +325,11 @@ fun SettingsSectionScreen( onCheckedChange = { onBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, it) }, summary = stringResource(R.string.pref_debug_logging_summary), ) + PreferenceAction( + title = stringResource(R.string.pref_export_log_title), + summary = stringResource(R.string.pref_export_log_summary), + onClick = { onAction(SettingsAction.ExportLog) }, + ) } SettingsSection.Pgp -> { PreferenceAction( diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt index cfc576ae..90aecb2a 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt @@ -8,6 +8,8 @@ package app.passwordstore.ui.settings import android.app.Application import android.content.SharedPreferences import android.content.pm.ShortcutManager +import android.net.Uri +import android.os.Build import androidx.annotation.StringRes import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -16,6 +18,7 @@ import androidx.core.content.edit import androidx.core.content.getSystemService import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import app.passwordstore.BuildConfig import app.passwordstore.R import app.passwordstore.data.crypto.PGPPassphraseCache import app.passwordstore.data.repo.PasswordRepository @@ -31,12 +34,16 @@ import app.passwordstore.util.settings.GitSettings import app.passwordstore.util.settings.PasswordSortOrder import app.passwordstore.util.settings.PreferenceKeys import dagger.hilt.android.lifecycle.HiltViewModel +import java.io.IOException import java.nio.file.Files import java.nio.file.LinkOption import javax.inject.Inject import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import logcat.LogPriority.ERROR +import logcat.asLog +import logcat.logcat /** What the settings sections show: stored values, and the state rows depend on. */ data class SettingsState( @@ -111,6 +118,27 @@ constructor( state = read() } + /** Saves the app's log to [uri]; false when that failed. */ + suspend fun exportLog(uri: Uri): Boolean = + withContext(dispatcherProvider.io()) { + val header = + "HardenedPass ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}), " + + "Android ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT}), ${Build.MODEL}\n\n" + try { + val out = + getApplication().contentResolver.openOutputStream(uri) + ?: throw IOException("No stream for the chosen file") + out.use { writeAppLog(header, it) } + true + } catch (e: IOException) { + logcat(ERROR) { e.asLog() } + false + } catch (e: SecurityException) { + logcat(ERROR) { e.asLog() } + false + } + } + fun putBoolean(key: String, value: Boolean) { prefs.edit { putBoolean(key, value) } } diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index b4569746..fed8adec 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -288,6 +288,10 @@ Rebase al pull Durante pull o sincronizzazione, crea un merge commit con le modifiche del remote Durante pull o sincronizzazione, esegui il rebase dei commit che non sono sul remote + Esporta log + Salva in un file i log di questa app. Possono mostrare nomi delle voci, siti, app e il tuo server git: leggi il file prima di condividerlo + Log salvato + Impossibile salvare il log Push dopo ogni modifica Esegui il push sul repository remoto dopo aver salvato, spostato o eliminato nell\'app Salvato sul dispositivo, ma push non riuscito. Sincronizza per riprovare diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fa9fb0f3..a415e95f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -126,6 +126,10 @@ Rebase on pull When pulling or syncing, create a merge commit with upstream changes When pulling or syncing, rebase commits that are not present in the remote repository + Export log + Save this app\'s logs to a file. They can show entry names, websites, apps and your git server, so read the file before sharing it + Log saved + Could not save the log Push after each change Push to the remote repository after saving, moving or deleting in the app Saved on this device, but not pushed. Sync to try again -- cgit v1.2.3