diff options
Diffstat (limited to 'app')
33 files changed, 777 insertions, 71 deletions
diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9ad30d32..b21d0aed 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -60,6 +60,7 @@ dependencies { implementation(libs.androidx.autofill) implementation(libs.androidx.biometricKtx) implementation(libs.androidx.core.ktx) + implementation(libs.androidx.credentials) implementation(libs.androidx.documentfile) // FragmentActivity, which BiometricPrompt needs. implementation(libs.androidx.fragment.ktx) @@ -68,7 +69,7 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.navigation.compose) - implementation(libs.compose.ui.tooling) + debugImplementation(libs.compose.ui.tooling) implementation(libs.dagger.hilt.android) implementation(libs.kotlinx.collections.immutable) diff --git a/app/lint.xml b/app/lint.xml index 42a888eb..6babd470 100644 --- a/app/lint.xml +++ b/app/lint.xml @@ -25,6 +25,8 @@ <ignore path="src/main/res/drawable/ic_miscellaneous_services_24px.xml" /> <ignore path="src/main/res/drawable/ic_miscellaneous_services_24px.xml" /> </issue> + <!-- The app is a Credential Manager provider (Android 14+), not a client: no Play Services. --> + <issue id="CredentialDependency" severity="ignore" /> <issue id="GradleDependency" severity="ignore" /> <issue id="ReportShortcutUsage" severity="ignore" /> <issue id="MissingTranslation" severity="ignore" /> 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 Binary files differnew file mode 100644 index 00000000..f31847d8 --- /dev/null +++ b/app/src/main/res/drawable-nodpi/ic_kofi_symbol.webp 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)) + } } diff --git a/app/version.properties b/app/version.properties index 77a7d569..cd3fecb2 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,5 +1,5 @@ # # This file was automatically generated by 'versioning-plugin'. DO NOT EDIT MANUALLY. # -versioning-plugin.versionCode=200 -versioning-plugin.versionName=0.2.0 +versioning-plugin.versionCode=304 +versioning-plugin.versionName=0.3.4 |
