aboutsummaryrefslogtreecommitdiffstats
path: root/app/src/main/java
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-06 10:24:24 +0100
committerDanilo M. <danix@danix.xyz>2026-11-06 10:24:24 +0100
commitb57cd1813bcf50f1cf5bfe2234b9074d7a3e03be (patch)
tree452b1f5a3dd820e4eedca8f6dfc107b2294a5823 /app/src/main/java
parent4f1b534aec26e6f33e40f877282465f334c5f1fd (diff)
downloadHardenedPass-b57cd1813bcf50f1cf5bfe2234b9074d7a3e03be.tar.gz
HardenedPass-b57cd1813bcf50f1cf5bfe2234b9074d7a3e03be.zip
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 <noreply@anthropic.com>
Diffstat (limited to 'app/src/main/java')
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsDestination.kt13
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt3
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt15
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsScreen.kt6
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt28
5 files changed, 64 insertions, 1 deletions
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<GitCommit> {
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<Application>().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) }
}