aboutsummaryrefslogtreecommitdiffstats
path: root/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'app/src')
-rw-r--r--app/src/main/AndroidManifest.xml12
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt69
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt12
-rw-r--r--app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt79
-rw-r--r--app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt10
-rw-r--r--app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt9
-rw-r--r--app/src/main/java/app/passwordstore/ui/list/ListDestination.kt2
-rw-r--r--app/src/main/java/app/passwordstore/ui/list/ListScreen.kt11
-rw-r--r--app/src/main/java/app/passwordstore/ui/list/StoreActions.kt36
-rw-r--r--app/src/main/java/app/passwordstore/ui/main/MainActivity.kt2
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/AboutScreen.kt234
-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.kt9
-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.kt14
-rw-r--r--app/src/main/java/app/passwordstore/ui/settings/SettingsViewModel.kt30
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt2
-rw-r--r--app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt2
-rw-r--r--app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt2
-rw-r--r--app/src/main/java/app/passwordstore/util/log/LogcatLogger.kt9
-rw-r--r--app/src/main/java/app/passwordstore/util/services/CredentialService.kt134
-rw-r--r--app/src/main/java/app/passwordstore/util/settings/GitSettings.kt6
-rw-r--r--app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt1
-rw-r--r--app/src/main/res/drawable-nodpi/ic_kofi_symbol.webpbin0 -> 4100 bytes
-rw-r--r--app/src/main/res/drawable/ic_open_in_new_24dp.xml12
-rw-r--r--app/src/main/res/values-it/strings.xml24
-rw-r--r--app/src/main/res/values/strings.xml24
-rw-r--r--app/src/main/res/xml/credential_provider.xml10
-rw-r--r--app/src/test/java/app/passwordstore/ui/settings/AboutTest.kt39
-rw-r--r--app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt17
30 files changed, 771 insertions, 68 deletions
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0eabbab4..026be6c8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -79,6 +79,18 @@
android:name="android.autofill"
android:resource="@xml/oreo_autofill_service" />
</service>
+ <service
+ android:name=".util.services.CredentialService"
+ android:exported="true"
+ android:permission="android.permission.BIND_CREDENTIAL_PROVIDER_SERVICE"
+ tools:targetApi="34">
+ <intent-filter>
+ <action android:name="android.service.credentials.CredentialProviderService" />
+ </intent-filter>
+ <meta-data
+ android:name="android.credentials.provider"
+ android:resource="@xml/credential_provider" />
+ </service>
<!-- ponytail: rotation, resizing and keyboard changes must not cancel a fill in progress;
other recreations (theme, locale) cancel it. -->
diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt
index 3b62de4c..38c86d86 100644
--- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt
@@ -9,6 +9,7 @@ import android.content.Context
import android.content.Intent
import android.content.IntentSender
import android.content.SharedPreferences
+import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import android.view.autofill.AutofillManager
@@ -23,6 +24,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import androidx.core.content.edit
+import androidx.credentials.GetCredentialResponse
+import androidx.credentials.PasswordCredential
+import androidx.credentials.provider.PendingIntentHandler
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope
import app.passwordstore.Application.Companion.screenWasOff
@@ -81,7 +85,8 @@ class AutofillDecryptActivity : FragmentActivity() {
private lateinit var directoryStructure: DirectoryStructure
private lateinit var filePath: String
- private lateinit var clientState: Bundle
+ /** The autofill request's state; null when Credential Manager asked. */
+ private var clientState: Bundle? = null
private lateinit var action: AutofillAction
private var clearCache = true
@@ -124,13 +129,15 @@ class AutofillDecryptActivity : FragmentActivity() {
finish()
return
}
- clientState =
- intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
- ?: run {
- logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
- finish()
- return
- }
+ val fromCredentialManager =
+ Build.VERSION.SDK_INT >= 34 &&
+ PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) != null
+ clientState = intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
+ if (clientState == null && !fromCredentialManager) {
+ logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
+ finish()
+ return
+ }
val isSearchAction = intent.getBooleanExtra(EXTRA_SEARCH_ACTION, true)
action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match
directoryStructure = AutofillPreferences.directoryStructure(this)
@@ -278,26 +285,34 @@ class AutofillDecryptActivity : FragmentActivity() {
askPassphrase(identifiers, wrong = true)
return
}
- if (credentials == null) {
- setResult(RESULT_CANCELED)
- } else {
- val fillInDataset =
- AutofillResponseBuilder.makeFillInDataset(
- this@AutofillDecryptActivity,
- credentials,
- clientState,
- action,
- )
- withContext(dispatcherProvider.main()) {
- setResult(
- RESULT_OK,
- Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) },
- )
- }
+ val result = credentials?.let(::resultIntent)
+ withContext(dispatcherProvider.main()) {
+ if (result == null) setResult(RESULT_CANCELED) else setResult(RESULT_OK, result)
}
withContext(dispatcherProvider.main()) { finish() }
}
+ /** The fill dataset, or the password for Credential Manager; null for an entry without one. */
+ private fun resultIntent(credentials: Credentials): Intent? {
+ val state = clientState
+ if (state != null) {
+ val fillInDataset =
+ AutofillResponseBuilder.makeFillInDataset(this, credentials, state, action)
+ return Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset)
+ }
+ if (Build.VERSION.SDK_INT < 34) return null
+ val password = credentials.password?.takeUnless { it.isEmpty() } ?: return null
+ // PasswordCredential rejects an empty id: fall back to the entry's name.
+ val id =
+ credentials.username?.takeUnless { it.isEmpty() } ?: File(filePath).nameWithoutExtension
+ return Intent().also {
+ PendingIntentHandler.setGetCredentialResponse(
+ it,
+ GetCredentialResponse(PasswordCredential(id, password)),
+ )
+ }
+ }
+
private suspend fun decryptCredential(
file: File,
password: String,
@@ -357,6 +372,12 @@ class AutofillDecryptActivity : FragmentActivity() {
}
}
+ /** Decrypts [file] for a Credential Manager request, which the system adds to the intent. */
+ fun makeCredentialIntent(file: File, context: Context): Intent =
+ Intent(context, AutofillDecryptActivity::class.java)
+ .putExtra(EXTRA_SEARCH_ACTION, false)
+ .putExtra(EXTRA_FILE_PATH, file.absolutePath)
+
fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender {
val intent =
Intent(context, AutofillDecryptActivity::class.java).apply {
diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt
index 474005b5..799015cb 100644
--- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt
+++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillFilterView.kt
@@ -8,6 +8,7 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.IntentSender
+import android.os.Build
import android.os.Bundle
import android.view.autofill.AutofillManager
import androidx.activity.compose.setContent
@@ -19,6 +20,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
+import androidx.credentials.provider.PendingIntentHandler
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.passwordstore.R
@@ -58,6 +60,11 @@ class AutofillFilterView : FragmentActivity() {
"app.passwordstore.autofill.oreo.ui.EXTRA_FORM_ORIGIN_APP"
private var matchAndDecryptFileRequestCode = 1
+ /** The picker for a Credential Manager request, which the system adds to the intent. */
+ fun makeCredentialIntent(context: Context, formOrigin: FormOrigin.App): Intent =
+ Intent(context, AutofillFilterView::class.java)
+ .putExtra(EXTRA_FORM_ORIGIN_APP, formOrigin.identifier)
+
fun makeMatchAndDecryptFileIntentSender(
context: Context,
formOrigin: FormOrigin,
@@ -107,7 +114,10 @@ class AutofillFilterView : FragmentActivity() {
params.width = (WIDTH_PERCENTAGE * resources.displayMetrics.widthPixels).toInt()
window.attributes = params
- if (intent?.hasExtra(AutofillManager.EXTRA_CLIENT_STATE) != true) {
+ val fromCredentialManager =
+ Build.VERSION.SDK_INT >= 34 &&
+ PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) != null
+ if (intent?.hasExtra(AutofillManager.EXTRA_CLIENT_STATE) != true && !fromCredentialManager) {
logcat(ERROR) { "AutofillFilterActivity started without EXTRA_CLIENT_STATE" }
finish()
return
diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt
index 2ed0adef..d32df6ca 100644
--- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt
@@ -8,11 +8,15 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.IntentSender
+import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import android.view.autofill.AutofillManager
import androidx.activity.compose.setContent
import androidx.core.os.bundleOf
+import androidx.credentials.CreatePasswordRequest
+import androidx.credentials.CreatePasswordResponse
+import androidx.credentials.provider.PendingIntentHandler
import androidx.fragment.app.FragmentActivity
import app.passwordstore.data.passfile.PasswordEntry
import app.passwordstore.ui.compose.theme.APSTheme
@@ -60,6 +64,24 @@ class AutofillSaveActivity : FragmentActivity() {
credentials: Credentials?,
formOrigin: FormOrigin,
): IntentSender {
+ val intent =
+ Intent(context, AutofillSaveActivity::class.java)
+ .putExtras(saveExtras(context, credentials, formOrigin))
+ return PendingIntent.getActivity(
+ context,
+ saveRequestCode++,
+ intent,
+ PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ .intentSender
+ }
+
+ /** The editor's prefill: folder and name from [formOrigin] and the username. */
+ private fun saveExtras(
+ context: Context,
+ credentials: Credentials?,
+ formOrigin: FormOrigin,
+ ): Bundle {
val identifier = formOrigin.getPrettyIdentifier(context, untrusted = false)
// Prevent directory traversals
val sanitizedIdentifier =
@@ -76,28 +98,14 @@ class AutofillSaveActivity : FragmentActivity() {
username = credentials?.username,
identifier = identifier,
)
- val intent =
- Intent(context, AutofillSaveActivity::class.java).apply {
- putExtras(
- bundleOf(
- EXTRA_FOLDER_NAME to folderName,
- EXTRA_NAME to fileName,
- EXTRA_PASSWORD to credentials?.password,
- EXTRA_SHOULD_MATCH_APP to
- formOrigin.identifier.takeIf { formOrigin is FormOrigin.App },
- EXTRA_SHOULD_MATCH_WEB to
- formOrigin.identifier.takeIf { formOrigin is FormOrigin.Web },
- EXTRA_GENERATE_PASSWORD to (credentials == null),
- )
- )
- }
- return PendingIntent.getActivity(
- context,
- saveRequestCode++,
- intent,
- PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE,
- )
- .intentSender
+ return bundleOf(
+ EXTRA_FOLDER_NAME to folderName,
+ EXTRA_NAME to fileName,
+ EXTRA_PASSWORD to credentials?.password,
+ EXTRA_SHOULD_MATCH_APP to formOrigin.identifier.takeIf { formOrigin is FormOrigin.App },
+ EXTRA_SHOULD_MATCH_WEB to formOrigin.identifier.takeIf { formOrigin is FormOrigin.Web },
+ EXTRA_GENERATE_PASSWORD to (credentials == null),
+ )
}
}
@@ -117,8 +125,29 @@ class AutofillSaveActivity : FragmentActivity() {
@Inject lateinit var passwordEntryFactory: PasswordEntry.Factory
+ /** Set when Credential Manager asked to save a password, which then answers it. */
+ private var fromCredentialManager = false
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ if (Build.VERSION.SDK_INT >= 34) {
+ PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)?.let { request ->
+ val create =
+ request.callingRequest as? CreatePasswordRequest
+ ?: run {
+ finish()
+ return
+ }
+ fromCredentialManager = true
+ intent.putExtras(
+ saveExtras(
+ this,
+ Credentials(create.id, create.password, null),
+ FormOrigin.App(request.callingAppInfo.packageName),
+ )
+ )
+ }
+ }
// The whole window is the editor: keep it out of screenshots and recents.
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
val initial =
@@ -149,7 +178,11 @@ class AutofillSaveActivity : FragmentActivity() {
private fun finishSaved(saved: SaveResult.Saved) {
formOrigin?.let { AutofillMatcher.addMatchFor(this, it, saved.file) }
val resultIntent =
- if (generate) {
+ if (fromCredentialManager && Build.VERSION.SDK_INT >= 34) {
+ Intent().also {
+ PendingIntentHandler.setCreateCredentialResponse(it, CreatePasswordResponse())
+ }
+ } else if (generate) {
val clientState =
intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
?: run {
diff --git a/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt b/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt
index 4a6813f7..626ec130 100644
--- a/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt
+++ b/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt
@@ -48,12 +48,14 @@ fun getLongName(fullPath: String, repositoryPath: String, basename: String): Str
}
}
-/** Copies provided [text] to the clipboard, flagged sensitive. */
-fun FragmentActivity.copyTextToClipboard(text: String?) {
+/** Copies provided [text] to the clipboard; [sensitive] text is hidden from its preview. */
+fun FragmentActivity.copyTextToClipboard(text: String?, sensitive: Boolean = true) {
val clipboard = clipboard ?: return
val clip = ClipData.newPlainText("pgp_handler_result_pm", text)
- clip.description.extras =
- PersistableBundle().apply { putBoolean("android.content.extra.IS_SENSITIVE", true) }
+ if (sensitive) {
+ clip.description.extras =
+ PersistableBundle().apply { putBoolean("android.content.extra.IS_SENSITIVE", true) }
+ }
clipboard.setPrimaryClip(clip)
}
diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt
index 8e433957..617d804a 100644
--- a/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt
+++ b/app/src/main/java/app/passwordstore/ui/editor/EditorDestination.kt
@@ -35,7 +35,9 @@ import app.passwordstore.R
import app.passwordstore.data.repo.PasswordRepository
import app.passwordstore.ui.crypto.copyPasswordToClipboard
import app.passwordstore.ui.crypto.getPGPIdentifiers
+import app.passwordstore.ui.git.base.BaseGitActivity
import app.passwordstore.ui.list.FolderPicker
+import app.passwordstore.ui.list.commitAndPush
import app.passwordstore.util.extensions.commitChange
import app.passwordstore.util.extensions.getString
import app.passwordstore.util.extensions.isInsideRepository
@@ -148,12 +150,15 @@ fun EditorDestination(
}
// ponytail: commit failures are not shown, as for the list's move and delete. A commit cut
// short by recreation runs again; one that already went through finds nothing to commit.
- activity.commitChange(
+ val message =
activity.getString(
if (original != null) R.string.git_commit_edit_text else R.string.git_commit_add_text,
saved.file.relativeTo(root).path.removeSuffix(".gpg"),
)
- )
+ // ponytail: AutofillSaveActivity closes right after saving, which would cancel a push, so its
+ // saves stay local until the next push or sync.
+ if (activity is BaseGitActivity) activity.commitAndPush(message)
+ else activity.commitChange(message)
keyboard?.hide()
focusManager.clearFocus()
viewModel.left()
diff --git a/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt
index 54a7e66c..97092716 100644
--- a/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt
+++ b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt
@@ -73,6 +73,7 @@ fun ListDestination(
onNewEntry: () -> Unit,
onClone: () -> Unit,
onSettings: () -> Unit,
+ onAbout: () -> Unit,
onNoStore: () -> Unit,
modifier: Modifier = Modifier,
viewModel: SearchableRepositoryViewModel = hiltViewModel(),
@@ -159,6 +160,7 @@ fun ListDestination(
},
onGitOp = { op -> ifInitialized { runGit(op) } },
onSettings = onSettings,
+ onAbout = onAbout,
onNew = { dialog = ListDialog.Create },
onMove = { dialog = ListDialog.Move },
onDelete = {
diff --git a/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt b/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt
index 1b359d5b..593c744b 100644
--- a/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt
+++ b/app/src/main/java/app/passwordstore/ui/list/ListScreen.kt
@@ -91,6 +91,7 @@ fun ListScreen(
onSync: () -> Unit,
onGitOp: (GitOp) -> Unit,
onSettings: () -> Unit,
+ onAbout: () -> Unit,
onNew: () -> Unit,
onMove: () -> Unit,
onDelete: () -> Unit,
@@ -133,7 +134,7 @@ fun ListScreen(
SelectionBar(rows, selected, onClearSelection, onMove, onDelete, onRename, onPin)
} else {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
- SearchField(query, gitOps, onQueryChange, onGitOp, onSettings, searchFocus)
+ SearchField(query, gitOps, onQueryChange, onGitOp, onSettings, onAbout, searchFocus)
BreadcrumbBar(
crumbsFor(path),
stringResource(R.string.list_store_root),
@@ -238,6 +239,7 @@ private fun SearchField(
onQueryChange: (String) -> Unit,
onGitOp: (GitOp) -> Unit,
onSettings: () -> Unit,
+ onAbout: () -> Unit,
focus: FocusRequester,
) {
val focusManager = LocalFocusManager.current
@@ -282,6 +284,13 @@ private fun SearchField(
onSettings()
},
)
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.about)) },
+ onClick = {
+ menu = false
+ onAbout()
+ },
+ )
}
}
}
diff --git a/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt
index cff0964c..f300a814 100644
--- a/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt
+++ b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt
@@ -6,7 +6,9 @@
package app.passwordstore.ui.list
import android.content.Context
+import androidx.compose.material3.SnackbarDuration
import androidx.core.content.edit
+import androidx.lifecycle.lifecycleScope
import app.passwordstore.R
import app.passwordstore.data.repo.PasswordRepository
import app.passwordstore.ui.crypto.getLongName
@@ -20,12 +22,17 @@ import app.passwordstore.util.extensions.getString
import app.passwordstore.util.extensions.isInsideRepository
import app.passwordstore.util.extensions.listFilesRecursively
import app.passwordstore.util.extensions.sharedPrefs
+import app.passwordstore.util.extensions.snackbar
import app.passwordstore.util.settings.PasswordSortOrder
import app.passwordstore.util.settings.PreferenceKeys
+import com.github.michaelbull.result.onFailure
+import com.github.michaelbull.result.onSuccess
import java.io.File
import java.nio.file.Files
+import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import logcat.LogPriority.ERROR
+import logcat.asLog
import logcat.logcat
/** Shared preferences file of the "recently used" sort order, keyed by base64 of the path. */
@@ -64,6 +71,27 @@ internal fun File.deleteWithoutFollowingLinks(): Boolean {
return delete()
}
+/**
+ * Commits the store and, when the user asked for it, pushes without holding up the caller. A failed
+ * push only says so: the commit stays local, and [BaseGitActivity.promptOnErrorHandler] would
+ * delete the saved credentials over what may just be a missing network.
+ *
+ * ponytail: a push cut off by recreation is not resumed; the next one sends everything. An SSH key
+ * behind biometrics prompts on each change.
+ */
+suspend fun BaseGitActivity.commitAndPush(message: String) {
+ commitChange(message).onSuccess {
+ if (gitSettings.pushAfterCommit && gitSettings.url != null) {
+ lifecycleScope.launch {
+ launchGitOperation(BaseGitActivity.GitOp.PUSH).onFailure {
+ logcat(ERROR) { it.asLog() }
+ snackbar(getString(R.string.git_push_after_commit_failed), SnackbarDuration.Long)
+ }
+ }
+ }
+ }
+}
+
/** Deletes [files] (entries or whole folders), drops their autofill matches and commits. */
suspend fun BaseGitActivity.deleteItems(files: List<File>) {
val root = PasswordRepository.getRepositoryDirectory()
@@ -72,7 +100,7 @@ suspend fun BaseGitActivity.deleteItems(files: List<File>) {
files.forEach { it.deleteWithoutFollowingLinks() }
AutofillMatcher.updateMatches(applicationContext, delete = deleted)
}
- commitChange(
+ commitAndPush(
getString(
R.string.git_commit_remove_text,
files.joinToString(", ") { it.toRelativeString(root) },
@@ -116,14 +144,14 @@ suspend fun BaseGitActivity.moveItems(files: List<File>, target: File) {
val relative = "${target.absolutePath}/".replace(repo, "").replace("/+".toRegex(), "/")
getString(R.string.git_commit_move_multiple_text, relative)
}
- commitChange(message)
+ commitAndPush(message)
}
/** Renames the folder [from] to [to], keeps its recently-used time, then commits. */
suspend fun BaseGitActivity.renameFolder(from: File, to: File) {
if (!moveFile(from, to)) return
moveHistory(from, to)
- commitChange(getString(R.string.git_commit_move_text, from.name, to.name))
+ commitAndPush(getString(R.string.git_commit_move_text, from.name, to.name))
}
/** Carries the "recently used" time of [from] over to [to]. */
@@ -151,7 +179,7 @@ private suspend fun BaseGitActivity.moveFile(source: File, destination: File): B
}
}
if (!moved) {
- logcat(ERROR) { "Something went wrong while moving $source to $destination." }
+ logcat(ERROR) { "Something went wrong while moving a file." }
prompts.ask(
title = getString(R.string.password_move_error_title),
message = getString(R.string.password_move_error_message, source, destination),
diff --git a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt
index f3d7d3a0..5037e646 100644
--- a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt
+++ b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt
@@ -30,6 +30,7 @@ import app.passwordstore.ui.git.base.BaseGitActivity
import app.passwordstore.ui.list.ListDestination
import app.passwordstore.ui.list.folderMoves
import app.passwordstore.ui.prompts.PromptHost
+import app.passwordstore.ui.settings.AboutRoute
import app.passwordstore.ui.settings.SettingsRoute
import app.passwordstore.ui.settings.settingsGraph
import app.passwordstore.ui.setup.GitServerRoute
@@ -130,6 +131,7 @@ class MainActivity : BaseGitActivity() {
onSettings = {
if (nav.currentBackStackEntry?.id == entry.id) nav.navigate(SettingsRoute)
},
+ onAbout = { if (nav.currentBackStackEntry?.id == entry.id) nav.navigate(AboutRoute) },
onNoStore = {
// The store is gone (deleted from Settings): setup starts over.
if (nav.currentBackStackEntry?.id == entry.id)
diff --git a/app/src/main/java/app/passwordstore/ui/settings/AboutScreen.kt b/app/src/main/java/app/passwordstore/ui/settings/AboutScreen.kt
new file mode 100644
index 00000000..6c20d3af
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/ui/settings/AboutScreen.kt
@@ -0,0 +1,234 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.core.net.toUri
+import androidx.fragment.app.FragmentActivity
+import app.passwordstore.BuildConfig
+import app.passwordstore.R
+import app.passwordstore.ui.compose.theme.SpacingMedium
+import app.passwordstore.ui.crypto.copyTextToClipboard
+import app.passwordstore.util.extensions.getPackageInfoCompat
+import app.passwordstore.util.extensions.snackbar
+import java.security.MessageDigest
+
+private const val REPOSITORY = "https://github.com/danixland/HardenedPass"
+private const val DOCUMENTATION = "https://danix.xyz/hardenedpass"
+private const val KO_FI = "https://ko-fi.com/K6Y520C9I6"
+
+/**
+ * What ships in the APK and its license, by hand: update it when a dependency comes or goes. A
+ * generating plugin would be one more build dependency for a list this short.
+ */
+private val LICENSES =
+ listOf(
+ "AndroidX, Jetpack Compose" to "Apache-2.0",
+ "Apache Commons Codec, Curtains, Guava, JavaEWAH, JSpecify, uri-kmp" to "Apache-2.0",
+ "Bouncy Castle" to "Bouncy Castle Licence (MIT)",
+ "Dagger, Hilt" to "Apache-2.0",
+ "EFF Diceware word list" to "CC-BY",
+ "JGit" to "EDL-1.0 (BSD-3-Clause)",
+ "kotlin-result" to "ISC",
+ "Kotlin, kotlinx" to "Apache-2.0",
+ "LeakCanary plumber, logcat" to "Apache-2.0",
+ "Manrope" to "OFL-1.1",
+ "PGPainless" to "Apache-2.0",
+ "Public Suffix List" to "MPL-2.0",
+ "SLF4J" to "MIT",
+ "sshj" to "Apache-2.0",
+ "sublime-fuzzy" to "MIT",
+ "ZXing, zxing-android-embedded" to "Apache-2.0",
+ )
+
+/** `0.3.0 (300)`, followed by the [commit] on the debug builds that have one. */
+internal fun versionLine(name: String, code: Int, commit: String): String =
+ if (commit.isEmpty()) "$name ($code)" else "$name ($code) · $commit"
+
+/** The changelog at the tag of [versionName], without a suffix like `-debug`. */
+internal fun changelogUrl(versionName: String): String =
+ "$REPOSITORY/blob/v${versionName.substringBefore('-')}/CHANGELOG.md"
+
+/** SHA-256 of [certificate] as `apksigner` and `keytool` users compare it, colon separated. */
+internal fun certificateFingerprint(certificate: ByteArray): String =
+ MessageDigest.getInstance("SHA-256").digest(certificate).joinToString(":") { "%02X".format(it) }
+
+/** The certificate this copy was really signed with, so it can be compared with the published. */
+private fun Context.signingFingerprint(): String? =
+ packageManager
+ .getPackageInfoCompat(packageName, PackageManager.GET_SIGNING_CERTIFICATES)
+ .signingInfo
+ ?.apkContentsSigners
+ ?.firstOrNull()
+ ?.let { certificateFingerprint(it.toByteArray()) }
+
+/** The about screen. Links leave for the browser: the app itself makes no connection for them. */
+@Composable
+fun AboutDestination(
+ activity: FragmentActivity,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val fingerprint = remember { activity.signingFingerprint() }
+ AboutScreen(
+ version =
+ versionLine(BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE, BuildConfig.GIT_COMMIT),
+ changelog = changelogUrl(BuildConfig.VERSION_NAME),
+ fingerprint = fingerprint,
+ onCopy = { activity.copyTextToClipboard(it, sensitive = false) },
+ onOpen = { url ->
+ try {
+ activity.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
+ } catch (_: ActivityNotFoundException) {
+ activity.snackbar(message = activity.getString(R.string.about_no_browser))
+ }
+ },
+ onBack = onBack,
+ modifier = modifier,
+ )
+}
+
+@Composable
+private fun AboutScreen(
+ version: String,
+ changelog: String,
+ fingerprint: String?,
+ onCopy: (String) -> Unit,
+ onOpen: (String) -> Unit,
+ onBack: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ SettingsScaffold(stringResource(R.string.about), onBack, modifier) {
+ Column(
+ Modifier.fillMaxWidth().padding(SpacingMedium),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(SpacingMedium),
+ ) {
+ Box(Modifier.size(96.dp).clip(CircleShape)) {
+ Image(painterResource(R.drawable.ic_launcher_background), contentDescription = null)
+ Image(painterResource(R.drawable.ic_launcher_foreground), contentDescription = null)
+ }
+ Text(stringResource(R.string.app_name), style = MaterialTheme.typography.headlineSmall)
+ Text(stringResource(R.string.about_summary), textAlign = TextAlign.Center)
+ }
+ AboutRow(stringResource(R.string.about_version), version) { onCopy(version) }
+ LinkRow(stringResource(R.string.about_docs)) { onOpen(DOCUMENTATION) }
+ LinkRow(stringResource(R.string.about_changelog)) { onOpen(changelog) }
+ LinkRow(stringResource(R.string.about_source)) { onOpen(REPOSITORY) }
+ LinkRow(stringResource(R.string.about_issues)) { onOpen("$REPOSITORY/issues") }
+ LinkRow(stringResource(R.string.about_security)) {
+ onOpen("$REPOSITORY/blob/master/SECURITY.md")
+ }
+ // White in both themes: the logo's dark outline disappears on a dark background, and Ko-fi
+ // asks that the logo itself stays as it is.
+ Button(
+ onClick = { onOpen(KO_FI) },
+ modifier = Modifier.fillMaxWidth().padding(SpacingMedium),
+ colors =
+ ButtonDefaults.buttonColors(containerColor = Color.White, contentColor = Color.Black),
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
+ ) {
+ Image(
+ painterResource(R.drawable.ic_kofi_symbol),
+ contentDescription = null,
+ modifier = Modifier.height(24.dp),
+ )
+ Box(Modifier.width(SpacingMedium))
+ Text(stringResource(R.string.about_support))
+ }
+ ListItem(
+ headlineContent = { Text(stringResource(R.string.about_privacy_title)) },
+ supportingContent = { Text(stringResource(R.string.about_privacy)) },
+ colors = rowColors(),
+ )
+ if (fingerprint != null)
+ ListItem(
+ headlineContent = { Text(stringResource(R.string.about_certificate)) },
+ supportingContent = { Text(fingerprint, fontFamily = FontFamily.Monospace) },
+ modifier = Modifier.clickable(role = Role.Button) { onCopy(fingerprint) },
+ colors = rowColors(),
+ )
+ AboutRow(stringResource(R.string.about_license), stringResource(R.string.about_license_text)) {
+ onOpen("$REPOSITORY/blob/master/LICENSE.txt")
+ }
+ ListItem(
+ headlineContent = { Text(stringResource(R.string.about_libraries)) },
+ colors = rowColors(),
+ )
+ LICENSES.forEach { (name, license) ->
+ ListItem(
+ headlineContent = { Text(name) },
+ supportingContent = { Text(license) },
+ colors = rowColors(),
+ )
+ }
+ }
+}
+
+@Composable
+private fun AboutRow(
+ title: String,
+ summary: String? = null,
+ trailing: (@Composable () -> Unit)? = null,
+ onClick: () -> Unit,
+) {
+ ListItem(
+ headlineContent = { Text(title) },
+ supportingContent = summary?.let { { Text(it) } },
+ trailingContent = trailing,
+ modifier = Modifier.clickable(role = Role.Button, onClick = onClick),
+ colors = rowColors(),
+ )
+}
+
+/** A row that leaves for the browser, and says so. */
+@Composable
+private fun LinkRow(title: String, summary: String? = null, onClick: () -> Unit) {
+ AboutRow(
+ title = title,
+ summary = summary,
+ trailing = {
+ Icon(
+ painterResource(R.drawable.ic_open_in_new_24dp),
+ contentDescription = stringResource(R.string.about_opens_browser),
+ )
+ },
+ onClick = onClick,
+ )
+}
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 ae4e277b..e45166e0 100644
--- a/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt
+++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsGraph.kt
@@ -30,6 +30,9 @@ import kotlinx.serialization.Serializable
/** The store's commit log. */
@Serializable data object GitLogRoute
+/** Version, links, certificate and licenses. */
+@Serializable data object AboutRoute
+
/** HTTP(S) proxy settings. */
@Serializable data object ProxyRoute
@@ -47,6 +50,9 @@ fun NavGraphBuilder.settingsGraph(activity: MainActivity, nav: NavHostController
onBack = { close(entry) },
)
}
+ composable<AboutRoute> { entry ->
+ AboutDestination(activity = activity, onBack = { close(entry) })
+ }
composable<SettingsSectionRoute> { entry ->
SettingsSectionDestination(
activity = activity,
@@ -64,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 d13d1cf5..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,12 +63,13 @@ enum class SettingsAction {
ClearSecret,
DeleteRepository,
Export,
+ ExportLog,
}
/** Top bar with [title] and back, then [content] in one grouped surface, scrolling. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-private fun SettingsScaffold(
+internal fun SettingsScaffold(
title: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
@@ -258,6 +259,12 @@ fun SettingsSectionScreen(
else R.string.pref_rebase_on_pull_summary
),
)
+ PreferenceSwitch(
+ title = stringResource(R.string.pref_push_after_commit_title),
+ checked = state.pushAfterCommit,
+ onCheckedChange = { onBoolean(PreferenceKeys.PUSH_AFTER_COMMIT, it) },
+ summary = stringResource(R.string.pref_push_after_commit_summary),
+ )
if (state.isGitRepo) {
PreferenceAction(
title = stringResource(R.string.pref_edit_git_server_settings),
@@ -318,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 ef604f1f..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(
@@ -55,6 +62,7 @@ data class SettingsState(
val showPassword: Boolean,
val copyOnDecrypt: Boolean,
val rebaseOnPull: Boolean,
+ val pushAfterCommit: Boolean,
val isGitRepo: Boolean,
val httpsRemote: Boolean,
val canShowSshKey: Boolean,
@@ -110,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) }
}
@@ -205,6 +234,7 @@ constructor(
showPassword = prefs.getBoolean(PreferenceKeys.SHOW_PASSWORD, true),
copyOnDecrypt = prefs.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false),
rebaseOnPull = gitSettings.rebaseOnPull,
+ pushAfterCommit = gitSettings.pushAfterCommit,
isGitRepo = PasswordRepository.isGitRepo(),
httpsRemote = gitSettings.url?.startsWith("https") == true,
canShowSshKey = SshKey.canShowSshPublicKey,
diff --git a/app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt
index f93fc9be..390f1160 100644
--- a/app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt
+++ b/app/src/main/java/app/passwordstore/ui/setup/SshKeyScreen.kt
@@ -195,7 +195,7 @@ fun SshKeyDestination(
busy = viewModel.busy,
onImport = { if (SshKey.exists) replace = true else picker.launch(arrayOf("*/*")) },
onGenerate = onGenerate,
- onCopy = { publicKey?.let { activity.copyTextToClipboard(it) } },
+ onCopy = { publicKey?.let { activity.copyTextToClipboard(it, sensitive = false) } },
onShare = { publicKey?.let { activity.startActivity(shareKey(it)) } },
onBack = { if (!viewModel.busy) onClose() },
)
diff --git a/app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt b/app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt
index dbaa5675..b4b3d017 100644
--- a/app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt
+++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt
@@ -194,7 +194,7 @@ class AutofillMatcher {
.minus(oldNewPathMap.values)
.map { match ->
val newPath = oldNewPathMap[match] ?: return@map match
- logcat { "Updating match for $key: $match --> $newPath" }
+ logcat { "Updating match for $key" }
newPath
}
.toSet()
diff --git a/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt
index 382a5617..e24f96ca 100644
--- a/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt
+++ b/app/src/main/java/app/passwordstore/util/extensions/AndroidExtensions.kt
@@ -71,7 +71,7 @@ suspend fun FragmentActivity.commitChange(message: String): Result<Unit, Throwab
)
override suspend fun preExecute(): Boolean {
- logcat { "Committing with message: '$message'" }
+ logcat { "Committing" }
return true
}
}
diff --git a/app/src/main/java/app/passwordstore/util/log/LogcatLogger.kt b/app/src/main/java/app/passwordstore/util/log/LogcatLogger.kt
index c62e60a8..5ac2e0db 100644
--- a/app/src/main/java/app/passwordstore/util/log/LogcatLogger.kt
+++ b/app/src/main/java/app/passwordstore/util/log/LogcatLogger.kt
@@ -12,7 +12,8 @@ import logcat.logcat
/** An implementation of [AbstractLogger] that forwards logging calls to [logcat]. */
class LogcatLogger(name: String) : AbstractLogger(name) {
- override fun isTraceEnabled() = true
+ // sshj's trace level dumps every SSH packet in the clear, passwords included. Never log it.
+ override fun isTraceEnabled() = false
override fun isDebugEnabled() = true
@@ -27,11 +28,11 @@ class LogcatLogger(name: String) : AbstractLogger(name) {
@Suppress("RegExpRedundantEscape")
private fun String.fix() = replace("""(?!<\\)\{\}""".toRegex(), "%s")
- override fun t(message: String, t: Throwable?, vararg args: Any?) {
- logcat(name, LogPriority.VERBOSE) { message.fix().format(*args) + (t?.asLog() ?: "") }
- }
+ override fun t(message: String, t: Throwable?, vararg args: Any?) = Unit
override fun d(message: String, t: Throwable?, vararg args: Any?) {
+ // JGit's FileSnapshot writes thousands of lines per sync, pushing the rest out of the buffer.
+ if (name.endsWith(".FileSnapshot")) return
logcat(name) { message.fix().format(*args) + (t?.asLog() ?: "") }
}
diff --git a/app/src/main/java/app/passwordstore/util/services/CredentialService.kt b/app/src/main/java/app/passwordstore/util/services/CredentialService.kt
new file mode 100644
index 00000000..97b2666f
--- /dev/null
+++ b/app/src/main/java/app/passwordstore/util/services/CredentialService.kt
@@ -0,0 +1,134 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+package app.passwordstore.util.services
+
+import android.app.PendingIntent
+import android.content.Intent
+import android.os.CancellationSignal
+import android.os.OutcomeReceiver
+import androidx.annotation.RequiresApi
+import androidx.credentials.exceptions.ClearCredentialException
+import androidx.credentials.exceptions.CreateCredentialException
+import androidx.credentials.exceptions.GetCredentialException
+import androidx.credentials.provider.Action
+import androidx.credentials.provider.BeginCreateCredentialRequest
+import androidx.credentials.provider.BeginCreateCredentialResponse
+import androidx.credentials.provider.BeginCreatePasswordCredentialRequest
+import androidx.credentials.provider.BeginGetCredentialRequest
+import androidx.credentials.provider.BeginGetCredentialResponse
+import androidx.credentials.provider.BeginGetPasswordOption
+import androidx.credentials.provider.CallingAppInfo
+import androidx.credentials.provider.CreateEntry
+import androidx.credentials.provider.CredentialProviderService
+import androidx.credentials.provider.PasswordCredentialEntry
+import androidx.credentials.provider.ProviderClearCredentialStateRequest
+import app.passwordstore.R
+import app.passwordstore.ui.autofill.AutofillDecryptActivity
+import app.passwordstore.ui.autofill.AutofillFilterView
+import app.passwordstore.ui.autofill.AutofillSaveActivity
+import app.passwordstore.util.autofill.AutofillMatcher
+import app.passwordstore.util.autofill.makeFillMatchMetadata
+import com.github.androidpasswordstore.autofillparser.FormOrigin
+import com.github.michaelbull.result.getOrElse
+import logcat.LogPriority.WARN
+import logcat.logcat
+
+/**
+ * Credential Manager provider (Android 14+): apps that save or ask for a password through
+ * `CredentialManager` (e.g. Signal's backup key) reach the store here. It reuses the autofill
+ * matches, picker, decryption and save activities, keyed by the calling app's package.
+ */
+@RequiresApi(34)
+class CredentialService : CredentialProviderService() {
+
+ private var requestCode = 1
+
+ /**
+ * The calling app, or null for a browser asking for a website: its origin would need a
+ * privileged-browser allowlist, and autofill already covers browsers.
+ */
+ private fun appOrigin(info: CallingAppInfo?): FormOrigin.App? =
+ info?.takeUnless { it.isOriginPopulated() }?.let { FormOrigin.App(it.packageName) }
+
+ private fun pendingIntent(intent: Intent): PendingIntent =
+ PendingIntent.getActivity(
+ this,
+ requestCode++,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
+ )
+
+ override fun onBeginCreateCredentialRequest(
+ request: BeginCreateCredentialRequest,
+ cancellationSignal: CancellationSignal,
+ callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,
+ ) {
+ val origin = appOrigin(request.callingAppInfo)
+ if (request !is BeginCreatePasswordCredentialRequest || origin == null) {
+ callback.onResult(BeginCreateCredentialResponse())
+ return
+ }
+ // The username and password arrive in the activity's intent once the user picks this entry.
+ val entry =
+ CreateEntry.Builder(
+ applicationInfo.loadLabel(packageManager),
+ pendingIntent(Intent(this, AutofillSaveActivity::class.java)),
+ )
+ .build()
+ callback.onResult(BeginCreateCredentialResponse(createEntries = listOf(entry)))
+ }
+
+ override fun onBeginGetCredentialRequest(
+ request: BeginGetCredentialRequest,
+ cancellationSignal: CancellationSignal,
+ callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>,
+ ) {
+ val origin = appOrigin(request.callingAppInfo)
+ val option = request.beginGetCredentialOptions.filterIsInstance<BeginGetPasswordOption>()
+ if (origin == null || option.isEmpty()) {
+ callback.onResult(BeginGetCredentialResponse())
+ return
+ }
+ // ponytail: a changed publisher gets no entries at all; autofill shows a warning dataset
+ // instead. Offer AutofillPublisherChangedActivity here if this ever bites.
+ val files =
+ AutofillMatcher.getMatchesFor(this, origin).getOrElse {
+ logcat(WARN) { it.message.orEmpty() }
+ callback.onResult(BeginGetCredentialResponse())
+ return
+ }
+ val entries =
+ option.flatMap { opt ->
+ files.map { file ->
+ val metadata = makeFillMatchMetadata(this, file)
+ PasswordCredentialEntry.Builder(
+ this,
+ metadata.subtitle ?: metadata.title,
+ pendingIntent(AutofillDecryptActivity.makeCredentialIntent(file, this)),
+ opt,
+ )
+ .setDisplayName(metadata.title)
+ .build()
+ }
+ }
+ val search =
+ Action(
+ getString(R.string.oreo_autofill_search_in_store),
+ pendingIntent(AutofillFilterView.makeCredentialIntent(this, origin)),
+ )
+ callback.onResult(
+ BeginGetCredentialResponse(credentialEntries = entries, actions = listOf(search))
+ )
+ }
+
+ override fun onClearCredentialStateRequest(
+ request: ProviderClearCredentialStateRequest,
+ cancellationSignal: CancellationSignal,
+ callback: OutcomeReceiver<Void?, ClearCredentialException>,
+ ) {
+ // Nothing is signed in: there is no state to clear.
+ callback.onResult(null)
+ }
+}
diff --git a/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt b/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt
index 004028c4..0052f79c 100644
--- a/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt
+++ b/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt
@@ -125,6 +125,12 @@ constructor(
settings.edit { putBoolean(PreferenceKeys.REBASE_ON_PULL, value) }
}
+ var pushAfterCommit
+ get() = settings.getBoolean(PreferenceKeys.PUSH_AFTER_COMMIT, false)
+ set(value) {
+ settings.edit { putBoolean(PreferenceKeys.PUSH_AFTER_COMMIT, value) }
+ }
+
sealed class UpdateConnectionSettingsResult {
class MissingUsername(val newProtocol: Protocol) : UpdateConnectionSettingsResult()
diff --git a/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt b/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt
index ecc7a51c..9fd2658d 100644
--- a/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt
+++ b/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt
@@ -86,6 +86,7 @@ object PreferenceKeys {
const val PROXY_PASSWORD = "proxy_password"
const val REBASE_ON_PULL = "rebase_on_pull"
+ const val PUSH_AFTER_COMMIT = "push_after_commit"
const val DICEWARE_SEPARATOR = "diceware_separator"
const val DICEWARE_LENGTH = "diceware_length"
diff --git a/app/src/main/res/drawable-nodpi/ic_kofi_symbol.webp b/app/src/main/res/drawable-nodpi/ic_kofi_symbol.webp
new file mode 100644
index 00000000..f31847d8
--- /dev/null
+++ b/app/src/main/res/drawable-nodpi/ic_kofi_symbol.webp
Binary files differ
diff --git a/app/src/main/res/drawable/ic_open_in_new_24dp.xml b/app/src/main/res/drawable/ic_open_in_new_24dp.xml
new file mode 100644
index 00000000..b82ebe62
--- /dev/null
+++ b/app/src/main/res/drawable/ic_open_in_new_24dp.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:autoMirrored="true"
+ android:tint="?android:attr/colorControlNormal"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M19,19H5V5h7V3H5c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2v-7h-2v7zM14,3v2h3.59l-9.83,9.83 1.41,1.41L19,6.41V10h2V3h-7z" />
+</vector>
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index e15b903f..18065976 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -288,6 +288,13 @@
<string name="pref_rebase_on_pull_title">Rebase al pull</string>
<string name="pref_rebase_on_pull_summary">Durante pull o sincronizzazione, crea un merge commit con le modifiche del remote</string>
<string name="pref_rebase_on_pull_summary_on">Durante pull o sincronizzazione, esegui il rebase dei commit che non sono sul remote</string>
+ <string name="pref_export_log_title">Esporta log</string>
+ <string name="pref_export_log_summary">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</string>
+ <string name="log_exported">Log salvato</string>
+ <string name="log_export_failed">Impossibile salvare il log</string>
+ <string name="pref_push_after_commit_title">Push dopo ogni modifica</string>
+ <string name="pref_push_after_commit_summary">Esegui il push sul repository remoto dopo aver salvato, spostato o eliminato nell\'app</string>
+ <string name="git_push_after_commit_failed">Salvato sul dispositivo, ma push non riuscito. Sincronizza per riprovare</string>
<string name="pref_disable_sync_on_pull_title">Disattiva la sincronizzazione trascinando</string>
<string name="pref_disable_sync_on_pull_summary">Trascinare verso il basso l\'elenco delle password non avvia una sincronizzazione Git</string>
<string name="pref_import_pgp_key_title">Importa chiave PGP</string>
@@ -379,4 +386,21 @@
<string name="git_reset_confirm_message">I commit e le modifiche locali che non sono su questo remote branch vengono scartati.</string>
<string name="git_tool_done">%1$s: completato</string>
<string name="store_delete_confirm">Eliminare l\'archivio locale? Le voci di cui non è stato fatto il push sul remote vanno perse.</string>
+ <string name="about">Informazioni</string>
+ <string name="about_summary">Un client Android per pass, il gestore di password standard di Unix. Un fork mantenuto di Android Password Store.</string>
+ <string name="about_version">Versione</string>
+ <string name="about_docs">Documentazione</string>
+ <string name="about_changelog">Novità</string>
+ <string name="about_source">Codice sorgente</string>
+ <string name="about_issues">Segnala un problema</string>
+ <string name="about_security">Segnala un problema di sicurezza</string>
+ <string name="about_support">Sostieni su Ko-fi</string>
+ <string name="about_privacy_title">Privacy</string>
+ <string name="about_privacy">Nessuna telemetria, nessuna analisi, nessun servizio Google. HardenedPass si collega solo al tuo server git, tramite il tuo proxy se ne imposti uno.</string>
+ <string name="about_certificate">Certificato di firma (SHA-256)</string>
+ <string name="about_license">Licenza</string>
+ <string name="about_license_text">GNU General Public License v3.0 only. Copyright degli autori di Android Password Store e dei contributori di HardenedPass.</string>
+ <string name="about_libraries">Licenze open source</string>
+ <string name="about_opens_browser">Si apre nel browser</string>
+ <string name="about_no_browser">Nessun browser trovato per aprire questo link</string>
</resources>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 755fa9bc..ec2396be 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -126,6 +126,13 @@
<string name="pref_rebase_on_pull_title">Rebase on pull</string>
<string name="pref_rebase_on_pull_summary">When pulling or syncing, create a merge commit with upstream changes</string>
<string name="pref_rebase_on_pull_summary_on">When pulling or syncing, rebase commits that are not present in the remote repository</string>
+ <string name="pref_export_log_title">Export log</string>
+ <string name="pref_export_log_summary">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</string>
+ <string name="log_exported">Log saved</string>
+ <string name="log_export_failed">Could not save the log</string>
+ <string name="pref_push_after_commit_title">Push after each change</string>
+ <string name="pref_push_after_commit_summary">Push to the remote repository after saving, moving or deleting in the app</string>
+ <string name="git_push_after_commit_failed">Saved on this device, but not pushed. Sync to try again</string>
<string name="pref_disable_sync_on_pull_title">Disable pull to sync action</string>
<string name="pref_disable_sync_on_pull_summary">Prevent pulling down on the password list from triggering a Git sync</string>
<string name="pref_import_pgp_key_title">Import PGP key</string>
@@ -413,4 +420,21 @@
<string name="git_reset_confirm_message">Local commits and changes that are not on this remote branch are discarded.</string>
<string name="git_tool_done">%1$s: done</string>
<string name="store_delete_confirm">Delete the local store? Entries not pushed to the remote are lost.</string>
+ <string name="about">About</string>
+ <string name="about_summary">An Android client for pass, the standard Unix password manager. A maintained fork of Android Password Store.</string>
+ <string name="about_version">Version</string>
+ <string name="about_docs">Documentation</string>
+ <string name="about_changelog">What\'s new</string>
+ <string name="about_source">Source code</string>
+ <string name="about_issues">Report a problem</string>
+ <string name="about_security">Report a security issue</string>
+ <string name="about_support">Support on Ko-fi</string>
+ <string name="about_privacy_title">Privacy</string>
+ <string name="about_privacy">No telemetry, no analytics, no Google services. HardenedPass connects only to your git server, through your proxy if you set one.</string>
+ <string name="about_certificate">Signing certificate (SHA-256)</string>
+ <string name="about_license">License</string>
+ <string name="about_license_text">GNU General Public License v3.0 only. Copyright the Android Password Store authors and HardenedPass contributors.</string>
+ <string name="about_libraries">Open source licenses</string>
+ <string name="about_opens_browser">Opens in the browser</string>
+ <string name="about_no_browser">No browser found to open this link</string>
</resources>
diff --git a/app/src/main/res/xml/credential_provider.xml b/app/src/main/res/xml/credential_provider.xml
new file mode 100644
index 00000000..d8e2d5b8
--- /dev/null
+++ b/app/src/main/res/xml/credential_provider.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8" ?><!--
+ ~ Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ ~ SPDX-License-Identifier: GPL-3.0-only
+ -->
+
+<credential-provider xmlns:android="http://schemas.android.com/apk/res/android">
+ <capabilities>
+ <capability name="android.credentials.TYPE_PASSWORD_CREDENTIAL" />
+ </capabilities>
+</credential-provider>
diff --git a/app/src/test/java/app/passwordstore/ui/settings/AboutTest.kt b/app/src/test/java/app/passwordstore/ui/settings/AboutTest.kt
new file mode 100644
index 00000000..e692c229
--- /dev/null
+++ b/app/src/test/java/app/passwordstore/ui/settings/AboutTest.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package app.passwordstore.ui.settings
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class AboutTest {
+
+ @Test
+ fun versionLineWithoutCommit() {
+ assertEquals("0.3.0 (300)", versionLine("0.3.0", 300, ""))
+ }
+
+ @Test
+ fun versionLineWithCommit() {
+ assertEquals("0.3.0-debug (300) · daa7ee01", versionLine("0.3.0-debug", 300, "daa7ee01"))
+ }
+
+ @Test
+ fun changelogUrlPointsAtTheReleaseTag() {
+ assertEquals(
+ "https://github.com/danixland/HardenedPass/blob/v0.3.0/CHANGELOG.md",
+ changelogUrl("0.3.0-debug"),
+ )
+ }
+
+ @Test
+ fun fingerprintIsColonSeparatedUppercaseSha256() {
+ assertEquals(
+ "BA:78:16:BF:8F:01:CF:EA:41:41:40:DE:5D:AE:22:23:" +
+ "B0:03:61:A3:96:17:7A:9C:B4:10:FF:61:F2:00:15:AD",
+ certificateFingerprint("abc".encodeToByteArray()),
+ )
+ }
+}
diff --git a/app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt b/app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt
index 6b30c248..111e7f51 100644
--- a/app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt
+++ b/app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt
@@ -9,7 +9,9 @@ import app.passwordstore.util.extensions.getString
import com.github.ivanshafran.sharedpreferencesmock.SPMockBuilder
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertFalse
import kotlin.test.assertNull
+import kotlin.test.assertTrue
class GitSettingsTest {
@@ -24,4 +26,19 @@ class GitSettingsTest {
assertEquals("proxy-user", proxy.getString(PreferenceKeys.PROXY_USERNAME))
assertNull(settings.getString(PreferenceKeys.PROXY_USERNAME))
}
+
+ @Test
+ fun pushAfterCommitIsOptIn() {
+ val settings = SPMockBuilder().createSharedPreferences()
+ val gitSettings =
+ GitSettings(
+ settings,
+ SPMockBuilder().createSharedPreferences(),
+ SPMockBuilder().createSharedPreferences(),
+ "/nonexistent",
+ )
+ assertFalse(gitSettings.pushAfterCommit)
+ gitSettings.pushAfterCommit = true
+ assertTrue(settings.getBoolean(PreferenceKeys.PUSH_AFTER_COMMIT, false))
+ }
}