diff --git a/.github/upstream-sync-baseline b/.github/upstream-sync-baseline index b06bbbbb5e..da8fd84324 100644 --- a/.github/upstream-sync-baseline +++ b/.github/upstream-sync-baseline @@ -1 +1 @@ -48ce3af5b4cd9b818d44edca4249a15d48e2170f +5f320126e14b9324db7a84f33e48fa9fdbdc30ca diff --git a/.opencode/skills/upstream-sync/SKILL.md b/.opencode/skills/upstream-sync/SKILL.md new file mode 100644 index 0000000000..a2ec397f92 --- /dev/null +++ b/.opencode/skills/upstream-sync/SKILL.md @@ -0,0 +1,95 @@ +--- +name: upstream-sync +description: Sync this fork with upstream agrahn/Android-Password-Store by selectively porting non-passkey changes into a dated PR. Use when the user asks to sync, reconcile, pull or bring changes from upstream / agrahn. +--- + +## Purpose + +Bring relevant upstream changes from [`agrahn/Android-Password-Store`](https://github.com/agrahn/Android-Password-Store) (`develop`) into this fork without importing upstream's passkey implementation. The full policy lives in `docs/upstream-reconciliation.md`; read it first. + +Key facts: + +- **Never `git merge upstream/develop`.** The fork has its own passkey/WebAuthn architecture. Changes are cherry-picked (`-x`) or ported one upstream commit/PR at a time. +- **The review baseline is an upstream SHA**, stored in `.github/upstream-sync-baseline`. It is *not* the Git merge-base: selective ports create no ancestry, so the merge-base stays old forever. +- **Passkey-owned paths are protected**: `passkeys/`, app passkey provider/injection packages, passkey provider XML, `PASSKEYS.md` and passkey docs. Upstream passkey changes are only an invariant/interoperability source; take the classic Password Store/Autofill part of mixed commits. +- **Dependency bumps are owned by the fork's Renovate.** Upstream CI, release, changelog and snapshot-bump commits are skipped. Only bump a dependency when a ported change requires it. + +## Workflow + +### 1. Audit + +```bash +git fetch upstream # remote: https://github.com/agrahn/Android-Password-Store.git +scripts/upstream-audit.sh "$SCRATCH/audit.md" +git rev-parse upstream/develop # write down: this is the SHA you are reviewing up to +``` + +The report lists every upstream commit after the baseline plus changed files classified by area (PASSKEY-PROTECTED, AUTOFILL, PGP/CRYPTO, ...). Filter out dependency/action/PSL/release noise to get the list of meaningful commits. + +### 2. Branch + +```bash +DATE=$(date +%F) +git switch -c sync/upstream-$DATE main +``` + +### 3. Port by area, with subagents + +Group the meaningful commits by area (e.g. autofill/password creation, SSH/git/network, misc UI/strings/build) and give each group to its own subagent in its own worktree so they run in parallel: + +```bash +git worktree add -b sync/$DATE- ../wt/ main +cp local.properties ../wt// # untracked SDK config needed to build +``` + +Brief each subagent with: the worktree path, the exact upstream SHAs in its scope, the policy doc, the passkey boundary, "skip upstream CHANGELOG.md hunks", "no `clean`, no full lint (sibling builds)", and ask for a final table `| upstream SHA | PR | disposition | note |` covering **every** commit in scope. Use a stronger model for large or security-sensitive groups (autofill series, host-key verification, crypto); a cheaper one for mechanical groups. + +Per commit: + +- applies cleanly or with trivial conflicts → `git cherry-pick -x ` (or `scripts/adopt-upstream-pr.sh `, which refuses passkey paths) +- needs rework → port manually, cite upstream SHA/PR in the commit message +- disposition is one of **adopted / adapted / already solved / skipped** + +Also handle in one place: + +- Public Suffix List: refresh `autofill-parser` PSL once from `upstream/develop` instead of replaying each update commit. +- Compare `gradle/libs.versions.toml` with upstream and list versions where the fork lags (report, don't bump unless needed). + +### 4. Integrate + +```bash +git switch sync/upstream-$DATE +git merge --no-ff sync/$DATE- # for each group; resolve strings.xml / CHANGELOG conflicts +./gradlew spotlessApply +CI=true ./gradlew lint spotlessCheck test -PslimTests assembleDebug +``` + +All checks must pass, including passkey compatibility tests. Then remove the temporary worktrees and branches (`git worktree remove ../wt/`; `git branch -D sync/$DATE-`). + +### 5. Record + +- Add a `## Reconciliation ()` section to `docs/upstream-reconciliation.md` with the reviewed-through SHA and the disposition table (adopted/adapted rows individually, skipped categories summarised). +- Set the baseline to the SHA recorded in step 1 (do not run `scripts/mark-upstream-reviewed.sh` if upstream moved during the review — it writes the *current* upstream head): + + ```bash + echo > .github/upstream-sync-baseline + ``` + +- Add user-visible changes to the `## [Unreleased]` section of `CHANGELOG.md`. + +### 6. Pull request + +```bash +git push -u origin sync/upstream-$DATE +gh pr create --repo pando85/Android-Password-Store --base main \ + --title "chore: sync upstream changes ($DATE)" --body-file +``` + +PR body sections: Summary (upstream range `..`), Adopted/adapted changes (with upstream PR numbers), Skipped (with reasons), Passkey boundary, Lagging dependencies, Validation. + +## Pitfalls + +- `scripts/upstream-audit.sh` and `mark-upstream-reviewed.sh` reset the `upstream` remote URL to HTTPS. +- Upstream "autofill" commits sometimes touch passkey screens too (e.g. shortening passkey ids); port only the classic part. +- Upstream runtime-permission and SSH host-key changes are security relevant; review them rather than blindly cherry-picking. +- The fork's applicationId, CI workflows and release pipeline differ from upstream; never take upstream `.github/workflows/` or release commits. diff --git a/CHANGELOG.md b/CHANGELOG.md index 277d5854ff..2d3b6b7cfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file ## [Unreleased] +### Added + +- Option to keep the password store in a shared external folder (`Documents/PasswordStore`); passkeys follow the selected repository +- Setting to enable Autofill directly from the app settings +- Dialog showing the SSH server's authentication banner, if any +- Android 17: runtime request for the local network permission and a connectivity check for local Git servers + +### Changed + +- Autofill search pane: whitespace-tolerant search, persistent filter switches, start-ellipsised paths, better username lookup (also from the file path) and more inline suggestions +- New password entries and Autofill-saved credentials honour the directory structure setting and keep the subfolder of the entry +- Bouncy Castle and coroutines versions are aligned through BOMs + +### Fixed + +- Classic Autofill no longer offers or fills passkey entries +- Autofill-saved credentials are filled in right after creation, with the username pre-filled +- SSH host key verification and compatibility with sshj 0.41 +- Alert dialogs are no longer clipped by the keyboard on short screens +- An empty Git author email is now allowed +- Spanish translation fixes + ## [1.26.0] - 2026-10-27 ### Changed diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d21fb2bf6e..3e69a65016 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,6 +42,7 @@ android { dependencies { implementation(platform(libs.compose.bom)) + implementation(platform(libs.thirdparty.bouncycastle.bom)) ksp(libs.dagger.hilt.compiler) implementation(libs.androidx.annotation) coreLibraryDesugaring(libs.android.desugarJdkLibs) @@ -76,7 +77,7 @@ dependencies { implementation(libs.dagger.hilt.android) implementation(libs.kotlinx.collections.immutable) - implementation(libs.kotlinx.coroutines.android) + implementation(platform(libs.kotlinx.coroutines.bom)) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d101b883b0..767c406f07 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -88,7 +88,7 @@ + dir.deleteRecursively() + } + closeRepository() } + fun getRepositoryDirectory(): File = + if (settings.getBoolean(PreferenceKeys.PREF_SHARED_REPO, false)) + File( + File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), + "PasswordStore", + ), + "store", + ) + else File(filesDir, "store") + /* referencePath: /a/b/c * /a/b/c/d/e/dir -> /d/e/dir * /a/b/c/d/e/file.ext -> /d/e/file.ext @@ -255,4 +285,58 @@ object PasswordRepository { passwordList.sortWith(sortOrder.comparator) return passwordList } + + /** + * Recursively finds files and/or directories (see [type]) below [startPath] whose name equals + * [name]. Hidden directories (such as `.git`) are not descended into. + */ + fun findByName( + startPath: String, + name: String, + type: Int = TYPE_DIR or TYPE_FILE, + ignoreCase: Boolean = false, + ): List = + walkNonHidden(Paths.get(startPath), type) { it.name.equals(name, ignoreCase = ignoreCase) } + + /** + * Recursively finds files and/or directories (see [type]) below [startPath] whose parent + * directory's name equals [name]. Hidden directories (such as `.git`) are not descended into. + */ + fun findByParentName( + startPath: String, + name: String, + type: Int = TYPE_DIR or TYPE_FILE, + ignoreCase: Boolean = false, + ): List = + walkNonHidden(Paths.get(startPath), type) { path -> + val parent: Path? = path.parent + parent != null && parent.name.equals(name, ignoreCase = ignoreCase) + } + + private fun walkNonHidden(start: Path, type: Int, predicate: (Path) -> Boolean): List { + val matches = mutableListOf() + if (!Files.isDirectory(start)) return matches + Files.walkFileTree( + start, + object : SimpleFileVisitor() { + override fun preVisitDirectory( + dir: Path, + attrs: BasicFileAttributes, + ): FileVisitResult { + if (dir != start && dir.name.startsWith('.')) return FileVisitResult.SKIP_SUBTREE + if (dir != start && type and TYPE_DIR != 0 && predicate(dir)) matches.add(dir) + return FileVisitResult.CONTINUE + } + + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + if (type and TYPE_FILE != 0 && attrs.isRegularFile && predicate(file)) matches.add(file) + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult = + FileVisitResult.CONTINUE + }, + ) + return matches + } } diff --git a/app/src/main/java/app/passwordstore/injection/passkeys/PasskeysModule.kt b/app/src/main/java/app/passwordstore/injection/passkeys/PasskeysModule.kt index 4994fbb2ec..832c22c974 100644 --- a/app/src/main/java/app/passwordstore/injection/passkeys/PasskeysModule.kt +++ b/app/src/main/java/app/passwordstore/injection/passkeys/PasskeysModule.kt @@ -16,6 +16,7 @@ import app.passwordstore.crypto.PGPainlessCryptoHandler import app.passwordstore.crypto.PgpainlessPasskeyDecryptor import app.passwordstore.data.crypto.OpenPgpInteractionCoordinator import app.passwordstore.data.crypto.OpenPgpProviderRepository +import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.injection.prefs.SettingsPreferences import app.passwordstore.passkeys.BiometricPasskeyAuthenticator import app.passwordstore.passkeys.DefaultRepositoryGenerationProvider @@ -46,7 +47,6 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import java.io.File import javax.inject.Singleton @Module @@ -89,20 +89,17 @@ object PasskeysModule { @Provides @Singleton - fun provideRepositoryGenerationProvider( - @ApplicationContext context: Context - ): RepositoryGenerationProvider = - DefaultRepositoryGenerationProvider(File(context.filesDir, "store")) + fun provideRepositoryGenerationProvider(): RepositoryGenerationProvider = + DefaultRepositoryGenerationProvider(PasswordRepository.getRepositoryDirectory()) @Provides @Singleton fun providePassRecipientResolver( - @ApplicationContext context: Context, keyManager: PGPKeyManager, providerRepository: OpenPgpProviderRepository, interactionCoordinator: OpenPgpInteractionCoordinator, ): PassRecipientResolver { - val repositoryRoot = File(context.filesDir, "store") + val repositoryRoot = PasswordRepository.getRepositoryDirectory() val localResolver = DefaultPassRecipientResolver(repositoryRoot, keyManager) return OpenPgpPassRecipientResolver( localResolver, @@ -114,7 +111,6 @@ object PasskeysModule { @Provides @Singleton fun providePasskeyStorage( - @ApplicationContext context: Context, cryptoHandler: PGPainlessCryptoHandler, passkeyPgpDecryptor: PasskeyPgpDecryptor, pgpUnlockContext: PgpUnlockContext, @@ -122,7 +118,7 @@ object PasskeysModule { generationProvider: RepositoryGenerationProvider, metadataIndex: PasskeyMetadataIndex, ): PasskeyStorage { - val repositoryRoot = File(context.filesDir, "store") + val repositoryRoot = PasswordRepository.getRepositoryDirectory() val passkeyConfig = PasskeyStorageConfig(passkeyDirectory = "fido2", fileExtension = ".gpg") val fileStorage = FilePasskeyStorage( 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 da8cb73e38..d8ae295a9b 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt @@ -20,6 +20,8 @@ import app.passwordstore.data.crypto.OpenPgpApiBackend import app.passwordstore.data.passfile.PasswordEntry import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.ui.crypto.BasePGPActivity +import app.passwordstore.util.autofill.AutofillMatcher +import app.passwordstore.util.autofill.AutofillPasskeyExclusion import app.passwordstore.util.autofill.AutofillPreferences import app.passwordstore.util.autofill.AutofillResponseBuilder import app.passwordstore.util.extensions.snackbar @@ -27,6 +29,7 @@ import app.passwordstore.util.extensions.toCharArray import app.passwordstore.util.extensions.wipe import app.passwordstore.util.settings.PreferenceKeys import com.github.androidpasswordstore.autofillparser.AutofillAction +import com.github.androidpasswordstore.autofillparser.FormOrigin import com.github.michaelbull.result.getError import com.github.michaelbull.result.getOrThrow import com.github.michaelbull.result.onSuccess @@ -50,6 +53,8 @@ class AutofillDecryptActivity : BasePGPActivity() { private lateinit var repositoryPath: String private lateinit var clientState: Bundle private lateinit var action: AutofillAction + private var formOrigin: FormOrigin? = null + private var origin: String? = null override fun onStart() { super.onStart() @@ -68,6 +73,18 @@ class AutofillDecryptActivity : BasePGPActivity() { finish() return } + // Derive the form origin from the client state instead of static state so it survives process + // death between the fill request and the dataset authentication. + formOrigin = FormOrigin.fromBundle(clientState) + origin = formOrigin?.getPrettyIdentifier(this, untrusted = false) // web origin or app name + // Classic Autofill must never fill passkey data: refuse before anything is decrypted. + if (AutofillPasskeyExclusion.isPasskeyCredentialFile(File(filePath))) { + logcat(ERROR) { "AutofillDecryptActivity refuses to fill a passkey credential file" } + AutofillMatcher.updateMatches(this, delete = listOf(File(filePath))) + snackbar(message = getString(R.string.password_decryption_no_autofill_with_passkey_error)) + Executors.newSingleThreadScheduledExecutor().schedule({ finish() }, 4, TimeUnit.SECONDS) + return + } val isSearchAction = intent?.getBooleanExtra(EXTRA_SEARCH_ACTION, true) ?: throw NullPointerException() action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match @@ -102,6 +119,7 @@ class AutofillDecryptActivity : BasePGPActivity() { this, encryptedFile, entry, + origin, directoryStructure, ) val fillInDataset = @@ -134,8 +152,9 @@ class AutofillDecryptActivity : BasePGPActivity() { ) } entry.clear() - finish() } + updateAutofillMatches() + withContext(dispatcherProvider.main()) { finish() } } finally { plaintextBytes.wipe() } @@ -184,6 +203,7 @@ class AutofillDecryptActivity : BasePGPActivity() { this, encryptedFile, entry, + origin, directoryStructure, ) val fillInDataset = @@ -213,6 +233,9 @@ class AutofillDecryptActivity : BasePGPActivity() { } } onSuccess(lastResult.first) // pass ID + + updateAutofillMatches() + withContext(dispatcherProvider.main()) { finish() } } else { passphrases.values.forEach { it?.wipe() } @@ -249,11 +272,22 @@ class AutofillDecryptActivity : BasePGPActivity() { } } + /** Update the autofill suggestion order (most recently used first). */ + private fun updateAutofillMatches() { + // add/move to front or remove + formOrigin?.let { + if (AutofillPreferences.addQuickSelectButton(this@AutofillDecryptActivity)) + AutofillMatcher.addMatchFor(this@AutofillDecryptActivity, it, File(filePath)) + else + AutofillMatcher.updateMatches(this@AutofillDecryptActivity, delete = listOf(File(filePath))) + } + } + companion object { private const val EXTRA_SEARCH_ACTION = "app.passwordstore.autofill.oreo.EXTRA_SEARCH_ACTION" - private var decryptFileRequestCode = 1 + private var decryptFileRequestCode = 100000 private var otpTimer: ScheduledExecutorService? = null fun makeDecryptFileIntent(file: File, forwardedExtras: Bundle, context: Context): Intent { 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 8ef3fd9b8d..e5ad235613 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,8 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.IntentSender +import android.content.SharedPreferences +import android.content.res.Configuration import android.os.Build import android.os.Bundle import android.view.View @@ -25,18 +27,22 @@ import androidx.recyclerview.widget.LinearLayoutManager import app.passwordstore.R import app.passwordstore.data.password.PasswordItem import app.passwordstore.databinding.ActivityOreoAutofillFilterBinding +import app.passwordstore.injection.prefs.SettingsPreferences import app.passwordstore.util.autofill.AutofillMatcher +import app.passwordstore.util.autofill.AutofillPasskeyExclusion import app.passwordstore.util.autofill.AutofillPreferences import app.passwordstore.util.coroutines.DispatcherProvider import app.passwordstore.util.extensions.enableEdgeToEdgeView import app.passwordstore.util.extensions.viewBinding import app.passwordstore.util.settings.DirectoryStructure +import app.passwordstore.util.settings.PreferenceKeys import app.passwordstore.util.viewmodel.FilterMode import app.passwordstore.util.viewmodel.ListMode import app.passwordstore.util.viewmodel.SearchMode import app.passwordstore.util.viewmodel.SearchableRepositoryAdapter import app.passwordstore.util.viewmodel.SearchableRepositoryViewModel import com.github.androidpasswordstore.autofillparser.FormOrigin +import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.flow.collect @@ -49,16 +55,18 @@ class AutofillFilterView : AppCompatActivity() { @Inject lateinit var dispatcherProvider: DispatcherProvider + @SettingsPreferences @Inject lateinit var settings: SharedPreferences + companion object { - private const val HEIGHT_PERCENTAGE = 0.9 - private const val WIDTH_PERCENTAGE = 0.75 + private const val HEIGHT_PERCENTAGE = .9 + private const val WIDTH_PERCENTAGE = .95 private const val EXTRA_FORM_ORIGIN_WEB = "app.passwordstore.autofill.oreo.ui.EXTRA_FORM_ORIGIN_WEB" private const val EXTRA_FORM_ORIGIN_APP = "app.passwordstore.autofill.oreo.ui.EXTRA_FORM_ORIGIN_APP" - private var matchAndDecryptFileRequestCode = 1 + private var matchAndDecryptFileRequestCode = 900000 fun makeMatchAndDecryptFileIntentSender( context: Context, @@ -99,22 +107,33 @@ class AutofillFilterView : AppCompatActivity() { finish() } + private fun resize() { + val params = window.attributes + params.height = (HEIGHT_PERCENTAGE * resources.displayMetrics.heightPixels).toInt() + params.width = (WIDTH_PERCENTAGE * resources.displayMetrics.widthPixels).toInt() + window.attributes = params + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + + resize() + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdgeView(binding.root) setContentView(binding.root) setFinishOnTouchOutside(true) - val params = window.attributes - params.height = (HEIGHT_PERCENTAGE * resources.displayMetrics.heightPixels).toInt() - params.width = (WIDTH_PERCENTAGE * resources.displayMetrics.widthPixels).toInt() - window.attributes = params + resize() if (intent?.hasExtra(AutofillManager.EXTRA_CLIENT_STATE) != true) { logcat(ERROR) { "AutofillFilterActivity started without EXTRA_CLIENT_STATE" } finish() return } + formOrigin = when { intent?.hasExtra(EXTRA_FORM_ORIGIN_WEB) == true -> { @@ -135,81 +154,79 @@ class AutofillFilterView : AppCompatActivity() { return } } + directoryStructure = AutofillPreferences.directoryStructure(this) supportActionBar?.hide() + + binding.filterMenuButton.setOnClickListener { + var items = + arrayOf( + getString( + R.string.oreo_autofill_match_with, + formOrigin.getPrettyIdentifier(applicationContext, untrusted = false), + ), + getString(R.string.oreo_autofill_matches_clear_existing), + ) + + var checked = + booleanArrayOf( + AutofillPreferences.addQuickSelectButton(this@AutofillFilterView), + AutofillPreferences.removeQuickSelectButtons(this@AutofillFilterView), + ) + + if (formOrigin is FormOrigin.Web) { + items = arrayOf(getString(R.string.oreo_autofill_strict_domain_search)) + items + checked = + booleanArrayOf(AutofillPreferences.strictDomainSearch(this@AutofillFilterView)) + checked + } + + MaterialAlertDialogBuilder(this) + .setMultiChoiceItems(items, checked) { _, which, isChecked -> + if (formOrigin is FormOrigin.Web) { + when (which) { + 0 -> { + AutofillPreferences.strictDomainSearch(this@AutofillFilterView, isChecked) + updateSearch() + } + 1 -> AutofillPreferences.addQuickSelectButton(this@AutofillFilterView, isChecked) + 2 -> AutofillPreferences.removeQuickSelectButtons(this@AutofillFilterView, isChecked) + } + } else { + when (which) { + 0 -> AutofillPreferences.addQuickSelectButton(this@AutofillFilterView, isChecked) + 1 -> AutofillPreferences.removeQuickSelectButtons(this@AutofillFilterView, isChecked) + } + } + } + .show() + } + bindUI() updateSearch() setResult(RESULT_CANCELED) } private fun bindUI() { - val strictDomainSearchPref = - formOrigin is FormOrigin.Web && AutofillPreferences.strictDomainSearch(this) with(binding) { - rvPassword.apply { - adapter = - SearchableRepositoryAdapter( - R.layout.oreo_autofill_filter_row, - ::PasswordViewHolder, - lifecycleScope, - dispatcherProvider, - ) { item, _ -> - val file = item.file.relativeTo(item.rootDir) - val pathToIdentifier = directoryStructure.getPathToIdentifierFor(file) - val identifier = directoryStructure.getIdentifierFor(file) - val accountPart = directoryStructure.getAccountPartFor(file) - check(identifier != null || accountPart != null) { - "At least one of identifier and accountPart should always be non-null" - } - title.text = - if (identifier != null) { - buildSpannedString { - if (pathToIdentifier != null) append("$pathToIdentifier/") - bold { underline { append(identifier) } } - } - } else { - accountPart - } - subtitle.apply { - if (identifier != null && accountPart != null) { - text = accountPart - visibility = View.VISIBLE - } else { - visibility = View.GONE - } - } - } - .onItemClicked { _, item -> decryptAndFill(item) } - layoutManager = LinearLayoutManager(context) - } search.apply { val initialSearch = formOrigin.getPrettyIdentifier(applicationContext, untrusted = false) setText(initialSearch, TextView.BufferType.EDITABLE) addTextChangedListener { updateSearch() } } origin.text = buildSpannedString { - append(getString(R.string.oreo_autofill_select_and_fill_into)) - append("\n") + append(getString(R.string.oreo_autofill_select_and_fill_into) + " ") bold { append(formOrigin.getPrettyIdentifier(applicationContext, untrusted = true)) } } - strictDomainSearch.apply { - visibility = if (formOrigin is FormOrigin.Web) View.VISIBLE else View.GONE - isChecked = strictDomainSearchPref - setOnCheckedChangeListener { _, _ -> updateSearch() } - } - shouldMatch.text = - getString( - R.string.oreo_autofill_match_with, - formOrigin.getPrettyIdentifier(applicationContext), - ) lifecycleScope.launch { handleSearchResults() } } } private suspend fun handleSearchResults() { model.searchResult.collect { result -> - val list = result.passwordItems + // Only offer password files: no hidden files or directories (even if the "show hidden + // contents" setting is enabled), and never passkey credentials. + val list = result.passwordItems.filter { isFillableSearchResult(it) } (binding.rvPassword.adapter as SearchableRepositoryAdapter).submitList(list) { binding.rvPassword.scrollToPosition(0) } @@ -224,27 +241,91 @@ class AutofillFilterView : AppCompatActivity() { } } + private fun isFillableSearchResult(item: PasswordItem): Boolean { + val relativeFile = item.file.relativeTo(item.rootDir) + return item.file.extension == "gpg" && + relativeFile.invariantSeparatorsPath.split('/').none { it.startsWith('.') } && + !AutofillPasskeyExclusion.isPasskeyCredentialFile( + item.file, + item.rootDir, + resolveLinks = false, + ) + } + + private fun buildResultList() { + binding.rvPassword.apply { + adapter = + SearchableRepositoryAdapter( + R.layout.oreo_autofill_filter_row, + ::PasswordViewHolder, + lifecycleScope, + dispatcherProvider, + ) { item, _ -> + val file = item.file.relativeTo(item.rootDir) + val pathString = + file.parentFile?.let { parentFile -> "${parentFile}/${file.nameWithoutExtension}" } + ?: file.nameWithoutExtension + + val searchString = binding.search.text.toString() + val parts = pathString.split(searchString, ignoreCase = true) + val matches = + Regex(Regex.escape(searchString), RegexOption.IGNORE_CASE) + .findAll(pathString) + .map { it.value } + .toList() + + val pathWithMatches = buildSpannedString { + append("/") + parts.getOrNull(0)?.let { append(it) } + matches.forEachIndexed { i, m -> + if (i == 0 || parts.getOrNull(i)?.isNotEmpty() ?: false) + bold { underline { append(m) } } + else append(m) + parts.getOrNull(i + 1)?.let { append(it) } + } + } + + val endPath = directoryStructure.getPathStringWithAtMostTwoParentsFor(file) + + title.text = pathWithMatches + + if (pathString.equals(endPath)) { + subtitle.visibility = View.GONE + title.setTextSize(14f) + } else { + subtitle.text = endPath + } + } + .onItemClicked { _, item -> decryptAndFill(item) } + layoutManager = LinearLayoutManager(context) + } + } + private fun updateSearch() { - if (formOrigin is FormOrigin.Web) - AutofillPreferences.setStrictDomainSearch(this, binding.strictDomainSearch.isChecked) model.search( - binding.search.text.toString().trim(), + binding.search.text.toString(), filterMode = - if (binding.strictDomainSearch.isChecked) FilterMode.StrictDomain else FilterMode.Fuzzy, + if ( + formOrigin is FormOrigin.Web && + AutofillPreferences.strictDomainSearch(this@AutofillFilterView) + ) + FilterMode.StrictDomain + else if (settings.getString(PreferenceKeys.SEARCH_FILTER_MODE, "exact") == "fuzzy") + FilterMode.Fuzzy + else FilterMode.Exact, searchMode = SearchMode.RecursivelyInSubdirectories, listMode = ListMode.FilesOnly, ) + buildResultList() } private fun decryptAndFill(item: PasswordItem) { - if (binding.shouldClear.isChecked) + if (AutofillPreferences.removeQuickSelectButtons(this@AutofillFilterView)) AutofillMatcher.clearMatchesFor(applicationContext, formOrigin) - if (binding.shouldMatch.isChecked) - AutofillMatcher.addMatchFor(applicationContext, formOrigin, item.file) - // intent?.extras? is checked to be non-null in onCreate decryptAction.launch( AutofillDecryptActivity.makeDecryptFileIntent( item.file, + // intent?.extras? is checked to be non-null in onCreate intent?.extras ?: throw NullPointerException(), this, ) diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt index 49acdfd497..aeabc38d41 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillPublisherChangedActivity.kt @@ -40,7 +40,7 @@ class AutofillPublisherChangedActivity : AppCompatActivity() { private const val EXTRA_APP_PACKAGE = "app.passwordstore.autofill.oreo.ui.EXTRA_APP_PACKAGE" private const val EXTRA_FILL_RESPONSE_AFTER_RESET = "app.passwordstore.autofill.oreo.ui.EXTRA_FILL_RESPONSE_AFTER_RESET" - private var publisherChangedRequestCode = 1 + private var publisherChangedRequestCode = 600000 fun makePublisherChangedIntentSender( context: Context, 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 0cd06e68d9..10eaa02438 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillSaveActivity.kt @@ -17,17 +17,18 @@ import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.ui.crypto.BasePGPActivity import app.passwordstore.ui.crypto.PasswordCreationActivity import app.passwordstore.util.autofill.AutofillMatcher +import app.passwordstore.util.autofill.AutofillPasskeyExclusion import app.passwordstore.util.autofill.AutofillPreferences import app.passwordstore.util.autofill.AutofillResponseBuilder import app.passwordstore.util.crypto.AESEncryption import app.passwordstore.util.extensions.unsafeLazy import app.passwordstore.util.extensions.wipe -import app.passwordstore.util.settings.DirectoryStructure import com.github.androidpasswordstore.autofillparser.AutofillAction import com.github.androidpasswordstore.autofillparser.Credentials import com.github.androidpasswordstore.autofillparser.FormOrigin import dagger.hilt.android.AndroidEntryPoint import java.io.File +import java.nio.file.Path import logcat.LogPriority.ERROR import logcat.logcat @@ -36,7 +37,6 @@ class AutofillSaveActivity : AppCompatActivity() { companion object { - private const val EXTRA_FOLDER_NAME = "app.passwordstore.autofill.oreo.ui.EXTRA_FOLDER_NAME" private const val EXTRA_ENTRY = "app.passwordstore.autofill.oreo.ui.EXTRA_ENTRY" private const val EXTRA_NAME = "app.passwordstore.autofill.oreo.ui.EXTRA_NAME" private const val EXTRA_SHOULD_MATCH_APP = @@ -52,43 +52,35 @@ class AutofillSaveActivity : AppCompatActivity() { context: Context, credentials: Credentials?, formOrigin: FormOrigin, + clientState: Bundle, ): IntentSender { - val identifier = formOrigin.getPrettyIdentifier(context, untrusted = false) + val identifier = + formOrigin.getPrettyIdentifier(context, untrusted = false) // web origin or app name // Prevent directory traversals - val sanitizedIdentifier = + val origin = identifier.replace('\\', '_').replace('/', '_').trimStart('.').takeUnless { it.isBlank() } ?: formOrigin.identifier - val directoryStructure = AutofillPreferences.directoryStructure(context) - val folderName = - directoryStructure.getSaveFolderName( - sanitizedIdentifier = sanitizedIdentifier, - username = credentials?.username, - ) - val fileName = - directoryStructure.getSaveFileName( - username = credentials?.username, - identifier = identifier, - ) + // The username is always passed on so that it can be pre-filled for every password file + // organisation (directory structure). val clearCredentials = credentials?.let { - if (directoryStructure == DirectoryStructure.EncryptedUsername) - listOf( - it.password ?: charArrayOf(), - "\nusername: ".toCharArray(), - it.username ?: charArrayOf(), - ) - .joinToCharArray() - else it.password + listOf( + it.password ?: charArrayOf(), + "\nusername: ".toCharArray(), + it.username ?: charArrayOf(), + ) + .joinToCharArray() } val encryptedCredentials = AESEncryption.encrypt(clearCredentials) credentials?.password?.wipe() clearCredentials?.wipe() + val intent = Intent(context, AutofillSaveActivity::class.java).apply { putExtras( Bundle().also { it.apply { - putString(EXTRA_FOLDER_NAME, folderName) - putString(EXTRA_NAME, fileName) + putBundle(AutofillManager.EXTRA_CLIENT_STATE, clientState) + putString(EXTRA_NAME, origin) putCharArray(EXTRA_ENTRY, encryptedCredentials) putString( EXTRA_SHOULD_MATCH_APP, @@ -113,6 +105,29 @@ class AutofillSaveActivity : AppCompatActivity() { } } + /** + * Searches for existing records related to the web origin/app name [origin] in the password store + * and returns the folder containing them (`//` or `/.gpg`), so + * that new related logins are saved close to existing ones. Passkey credentials are ignored. + */ + private fun findExistingFolderFor(repo: File, origin: String): File? { + fun List.firstClassicParent(): File? { + val match = + map(Path::toFile).firstOrNull { + !AutofillPasskeyExclusion.isPasskeyCredentialFile(it, repo) + } + return match?.parentFile + } + return PasswordRepository.findByName(repo.absolutePath, origin, PasswordRepository.TYPE_DIR) + .firstClassicParent() + ?: PasswordRepository.findByName( + repo.absolutePath, + "$origin.gpg", + PasswordRepository.TYPE_FILE, + ) + .firstClassicParent() + } + private val formOrigin by unsafeLazy { val shouldMatchApp: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_APP) val shouldMatchWeb: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_WEB) @@ -128,7 +143,11 @@ class AutofillSaveActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val repo = PasswordRepository.getRepositoryDirectory() - val saveRoot = AutofillPreferences.saveDirectory(this)?.let(repo::resolve) ?: repo + val origin = intent.getStringExtra(EXTRA_NAME) ?: throw NullPointerException() + val destinationFolder = + findExistingFolderFor(repo, origin) + ?: AutofillPreferences.saveDirectory(this)?.let(repo::resolve) + ?: repo val saveIntent = Intent(this, PasswordCreationActivity::class.java).apply { putExtras( @@ -137,11 +156,9 @@ class AutofillSaveActivity : AppCompatActivity() { putString(BasePGPActivity.EXTRA_REPO_PATH, repo.absolutePath) putString( BasePGPActivity.EXTRA_FILE_PATH, - saveRoot - .resolve(intent.getStringExtra(EXTRA_FOLDER_NAME) ?: throw NullPointerException()) - .absolutePath, + destinationFolder.absolutePath, ) - putString(PasswordCreationActivity.EXTRA_FILE_NAME, intent.getStringExtra(EXTRA_NAME)) + putString(PasswordCreationActivity.EXTRA_FILE_NAME, origin) putCharArray( PasswordCreationActivity.EXTRA_ENTRY, intent.getCharArrayExtra(EXTRA_ENTRY), @@ -167,7 +184,7 @@ class AutofillSaveActivity : AppCompatActivity() { val clientState = intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run { - logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" } + logcat(ERROR) { "AutofillSaveActivity started without EXTRA_CLIENT_STATE" } finish() return@registerForActivityResult } diff --git a/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt index 9b77b3426c..80fde7a14b 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt @@ -98,6 +98,9 @@ class PasswordCreationActivity : BasePGPActivity() { intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) } private val editing by unsafeLazy { intent.getBooleanExtra(EXTRA_EDITING, false) } + /** A new entry suggested by Autofill: [suggestedName] is the web origin or app name. */ + private val autofilling by unsafeLazy { !editing && suggestedName != null } + private val directoryStructure by unsafeLazy { AutofillPreferences.directoryStructure(this) } private var copy: Boolean = false private var pendingSave = false @@ -176,7 +179,8 @@ class PasswordCreationActivity : BasePGPActivity() { if (result.resultCode == RESULT_OK) { result.data?.getStringExtra(SelectFolderActivity.SELECTED_FOLDER_PATH)?.let { fullPath -> val relPath = PasswordRepository.getRelativePath(fullPath, repoPath) - binding.directory.setText(if (!relPath.isEmpty()) relPath else "/") + val path = if (relPath.isEmpty()) "/" else relPath + binding.directory.setText(path) } } } @@ -240,12 +244,6 @@ class PasswordCreationActivity : BasePGPActivity() { } } - val suggestedEntry: PasswordEntry? = suggestedEntryChars?.let { encrypted -> - AESEncryption.decrypt(encrypted)?.let { decrypted -> - passwordEntryFactory.create(decrypted).also { decrypted.wipe() } - } - } - directory.inputType = InputType.TYPE_NULL val relPath = PasswordRepository.getRelativePath(fullPath, repoPath) directory.setText(if (relPath.isEmpty()) "/" else relPath) @@ -256,46 +254,71 @@ class PasswordCreationActivity : BasePGPActivity() { selectFolderAction.launch(intent) } - if (suggestedName != null) { - filename.setText(suggestedName) - } else { - filename.requestFocus() - } - - if ( - AutofillPreferences.directoryStructure(this@PasswordCreationActivity) == - DirectoryStructure.EncryptedUsername || suggestedEntry?.username != null - ) { - usernameInputLayout.visibility = View.VISIBLE - if (suggestedEntry?.username != null) { - val charBuf = CharBuffer.wrap(suggestedEntry?.username) - username.setText(charBuf) - charBuf.array().wipe() - } else if (suggestedName != null) username.requestFocus() + val suggestedEntry: PasswordEntry? = suggestedEntryChars?.let { encrypted -> + AESEncryption.decrypt(encrypted)?.let { decrypted -> + passwordEntryFactory.create(decrypted).also { decrypted.wipe() } + } } - if ( - suggestedName == null && - AutofillPreferences.directoryStructure(this@PasswordCreationActivity) == - DirectoryStructure.FileBased - ) { - encryptUsername.apply { - visibility = View.VISIBLE - setOnClickListener { - if (isChecked) { - val login = filename.text.toString() - filename.text?.clear() - username.setText(login) - usernameInputLayout.apply { visibility = View.VISIBLE } - } else { - val login = username.text.toString() - username.text?.clear() - filename.setText(login) - usernameInputLayout.apply { visibility = View.GONE } + /* + * input fields + */ + + // visibilities + if (editing) { + nameInputLayout.visibility = View.GONE + filenameInputLayout.visibility = View.VISIBLE + if (suggestedEntry?.username == null) { + usernameInputLayout.visibility = View.GONE + insertUsername.apply { + visibility = View.VISIBLE + setOnClickListener { toggleUsernameInput(isChecked) } + } + } else { + usernameInputLayout.visibility = View.VISIBLE + } + insertUsername.isChecked = suggestedEntry?.username != null + } else if (autofilling) { + when (directoryStructure) { + DirectoryStructure.EncryptedUsername -> {} + DirectoryStructure.FileBased -> insertUsername.visibility = View.VISIBLE + DirectoryStructure.DirectoryBased -> { + filenameInputLayout.visibility = View.VISIBLE + insertUsername.visibility = View.VISIBLE + } + } + } else { // new entry via app UI + when (directoryStructure) { + DirectoryStructure.EncryptedUsername -> {} + DirectoryStructure.FileBased -> { + nameInputLayout.visibility = View.GONE + insertUsername.visibility = View.VISIBLE + } + DirectoryStructure.DirectoryBased -> { + nameInputLayout.visibility = View.GONE + usernameInputLayout.visibility = View.GONE + filenameInputLayout.visibility = View.VISIBLE + insertUsername.apply { + visibility = View.VISIBLE + setOnClickListener { toggleUsernameInput(isChecked) } } } } } + + // pre-sets + if (editing) { + filename.setText(suggestedName) + } else if (autofilling) { + name.setText(suggestedName) + } + + suggestedEntry?.username?.let { + val charBuf = CharBuffer.wrap(it) + username.setText(charBuf) + charBuf.array().wipe() + } + suggestedEntry?.password?.let { val charBuf = CharBuffer.wrap(it) password.setText(charBuf) @@ -315,7 +338,7 @@ class PasswordCreationActivity : BasePGPActivity() { password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD } } - listOf(binding.filename, binding.username, binding.extraContent).forEach { + listOf(binding.name, binding.username, binding.filename, binding.extraContent).forEach { it.doAfterTextChanged { updateViewState() } } updateViewState() @@ -348,7 +371,7 @@ class PasswordCreationActivity : BasePGPActivity() { private fun continueSave() { if (!pendingSave) return val repositoryRoot = File(repoPath) - val destinationDirectory = selectedDirectory() + val destinationDirectory = destination().directory val sourceFile = sourcePasswordFile() val requiredMetadata = passSecretsMutationService.requiredMetadataForPasswordWrite( @@ -383,6 +406,115 @@ class PasswordCreationActivity : BasePGPActivity() { return File(fullPath.trimEnd('/'), "$physicalName.gpg") } + /** Where an entry is written: the [directory] and the requested (logical) entry name. */ + private data class Destination(val directory: File, val requestedName: String) + + private fun isMappedDirectory(directory: File): Boolean = + PassSecretsMapStore.mapFileForDirectory(directory, File(repoPath)) != null + + /** + * Computes the destination of the entry from the input fields, following the directory structure + * setting (password file organisation) for new entries: + * - `.../example.org.gpg` (encrypted username), + * - `.../[example.org/]john@doe.org.gpg` (file-based), + * - `.../[example.org/john@doe.org/]password.gpg` (directory-based), + * + * where the bracketed parts are only added for entries created via Autofill. Inside a + * Pass-Secrets mapped directory no plaintext sub-directories are created; the structure becomes + * part of the (encrypted) logical entry name instead. + */ + private fun destination(): Destination { + with(binding) { + val selected = selectedDirectory() + val editName = name.text?.toString()?.trim().orEmpty() + val editUsername = username.text?.toString()?.trim().orEmpty() + val editFilename = filename.text?.toString()?.trim().orEmpty() + if (editing) return Destination(selected, editFilename) + val components = + when (directoryStructure) { + DirectoryStructure.EncryptedUsername -> listOf(editName) + DirectoryStructure.FileBased -> + if (autofilling) listOf(editName, editUsername) else listOf(editUsername) + DirectoryStructure.DirectoryBased -> + if (autofilling) listOf(editName, editUsername, editFilename) else listOf(editFilename) + } + if (isMappedDirectory(selected)) { + return Destination(selected, components.joinToString("/")) + } + val directory = components.dropLast(1).fold(selected) { parent, child -> File(parent, child) } + return Destination(directory, components.last()) + } + } + + private fun isInvalidPathComponent(value: String, mappedDestination: Boolean): Boolean = + !mappedDestination && + (value.contains('/') || value.contains('\\') || value == "." || value == "..") + + /** Checks the input fields required by the current mode, showing a message if invalid. */ + private fun validateInput(): Boolean { + with(binding) { + val mappedDestination = isMappedDirectory(selectedDirectory()) + val editName = name.text?.toString()?.trim().orEmpty() + val editUsername = username.text?.toString()?.trim().orEmpty() + val editFilename = filename.text?.toString()?.trim().orEmpty() + + // name (domain), e.g. example.org + if (autofilling || !editing && directoryStructure == DirectoryStructure.EncryptedUsername) { + if (editName.isBlank()) { + name.requestFocus() + snackbar(message = resources.getString(R.string.empty_name_toast_text)) + return false + } else if (isInvalidPathComponent(editName, mappedDestination)) { + name.requestFocus() + snackbar(message = resources.getString(R.string.invalid_name_text)) + return false + } + } + + // username, if it is part of the file path + if ( + autofilling && directoryStructure != DirectoryStructure.EncryptedUsername || + !editing && directoryStructure == DirectoryStructure.FileBased + ) { + if (editUsername.isBlank()) { + username.requestFocus() + snackbar(message = resources.getString(R.string.empty_username_toast_text)) + return false + } else if (isInvalidPathComponent(editUsername, mappedDestination)) { + username.requestFocus() + snackbar(message = resources.getString(R.string.invalid_username_text)) + return false + } + } + + // file name + if (editing || directoryStructure == DirectoryStructure.DirectoryBased) { + if (editFilename.isBlank()) { + filename.requestFocus() + snackbar(message = resources.getString(R.string.file_toast_text)) + return false + } else if (isInvalidPathComponent(editFilename, mappedDestination)) { + filename.requestFocus() + snackbar(message = resources.getString(R.string.invalid_filename_text)) + return false + } + } + } + return true + } + + private fun toggleUsernameInput(show: Boolean) { + with(binding) { + if (show) { + usernameInputLayout.visibility = View.VISIBLE + username.requestFocus() + } else { + username.text?.clear() + usernameInputLayout.visibility = View.GONE + } + } + } + private fun selectedDirectory(): File { val relative = binding.directory.text.toString().trim().trim('/') return File(repoPath, relative) @@ -406,13 +538,7 @@ class PasswordCreationActivity : BasePGPActivity() { private fun updateViewState() = with(binding) { - encryptUsername.apply { - if (visibility != View.VISIBLE) return@apply - val hasUsernameInFileName = filename.text.toString().isNotBlank() - val usernameIsEncrypted = username.text.toString().isNotEmpty() - isEnabled = hasUsernameInFileName xor usernameIsEncrypted - isChecked = usernameIsEncrypted - } + // Use PasswordEntry to parse extras for OTP val entry = passwordEntryFactory.create("PLACEHOLDER\n${extraContent.text}".toCharArray()) val hasTotp = entry.hasTotp() entry.clear() @@ -422,32 +548,15 @@ class PasswordCreationActivity : BasePGPActivity() { /** Encrypts the password and extra content, then atomically updates Pass-Secrets metadata. */ private fun encrypt(identifiers: List) { with(binding) { - val editName = filename.text.toString().trim() - var editUsername = username.text?.let { CharArray(it.length) { i -> it[i] } } ?: charArrayOf() + if (!validateInput()) return@with + val editUsername = username.text?.let { CharArray(it.length) { i -> it[i] } } ?: charArrayOf() val editPass = password.text?.let { CharArray(it.length) { i -> it[i] } } ?: charArrayOf() var editExtra = extraContent.text?.let { CharArray(it.length) { i -> it[i] } } ?: charArrayOf() - val destinationDirectory = selectedDirectory() - val mappedDestination = - PassSecretsMapStore.mapFileForDirectory(destinationDirectory, File(repoPath)) != null - - if (editName.isEmpty()) { - snackbar(message = resources.getString(R.string.file_toast_text)) - return@with - } else if (!mappedDestination && (editName.contains('/') || editName.contains('\\'))) { - snackbar(message = resources.getString(R.string.invalid_filename_text)) - return@with - } - - if (!editUsername.isEmpty()) { - editUsername = editUsername.let { - val withPrefix = "\nusername: ".toCharArray() + it - it.wipe() - withPrefix - } - } + val (destinationDirectory, requestedName) = destination() if (editPass.isEmpty() && editExtra.isEmpty()) { + password.requestFocus() snackbar(message = resources.getString(R.string.empty_toast_text)) return@with } @@ -468,8 +577,9 @@ class PasswordCreationActivity : BasePGPActivity() { clearTimer = copyPasswordToClipboard(editPass.copyOf(editPass.size)) } + // ensure destination dir exists destinationDirectory.toPath().createDirectories() - if (!destinationDirectory.toPath().exists()) { + if (!destinationDirectory.toPath().exists()) { // should not happen snackbar(message = "Failed to create directory ${destinationDirectory.path}") return@with } @@ -480,7 +590,7 @@ class PasswordCreationActivity : BasePGPActivity() { passSecretsMutationService.planPasswordWrite( sourcePasswordFile(), destinationDirectory, - editName, + requestedName, File(repoPath), ) if (!plan.destinationFile.isInsideRepository()) { @@ -488,7 +598,15 @@ class PasswordCreationActivity : BasePGPActivity() { return@runCatching } - val contentChars = editPass + editUsername + '\n' + editExtra + val contentChars = + if ( + !editUsername.isEmpty() && + (editing || + insertUsername.isChecked || + directoryStructure == DirectoryStructure.EncryptedUsername) + ) + editPass + "\nusername: ".toCharArray() + editUsername + '\n' + editExtra + else editPass + '\n' + editExtra val contentBytes = contentChars.toByteArray() contentChars.wipe() @@ -539,27 +657,21 @@ class PasswordCreationActivity : BasePGPActivity() { val returnIntent = Intent() returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, plan.destinationFile.absolutePath) - returnIntent.putExtra(RETURN_EXTRA_NAME, editName) + returnIntent.putExtra(RETURN_EXTRA_NAME, plan.logicalName) returnIntent.putExtra( RETURN_EXTRA_LONG_NAME, - PasswordRepository.getLongName(destinationDirectory.absolutePath, repoPath, editName), + PasswordRepository.getLongName( + destinationDirectory.absolutePath, + repoPath, + plan.logicalName, + ), ) if (shouldGeneratePassword) { - val directoryStructure = AutofillPreferences.directoryStructure(applicationContext) - val entry = passwordEntryFactory.create(editPass + editUsername + '\n' + editExtra) - - entry.password?.let { - val password = it.copyOf(it.size) - returnIntent.putExtra(RETURN_EXTRA_PASSWORD, password) - } - - val username = - entry.username?.let { it.copyOf(it.size) } - ?: directoryStructure.getUsernameFor(plan.destinationFile) - returnIntent.putExtra(RETURN_EXTRA_USERNAME, username) - - entry.clear() + // credentials to be filled into the Autofill form after creation + if (!editPass.isEmpty()) returnIntent.putExtra(RETURN_EXTRA_PASSWORD, editPass.copyOf()) + if (!editUsername.isEmpty()) + returnIntent.putExtra(RETURN_EXTRA_USERNAME, editUsername.copyOf()) } editPass.wipe() diff --git a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt b/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt index f9808c542b..1c197143fb 100644 --- a/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt +++ b/app/src/main/java/app/passwordstore/ui/folderselect/SelectFolderFragment.kt @@ -67,7 +67,7 @@ class SelectFolderFragment : Fragment(R.layout.password_recycler_view) { model.navigateTo( PasswordRepository.getRepositoryDirectory(), - listMode = ListMode.DirectoriesOnly, + listMode = ListMode.AllEntries, pushPreviousLocation = false, ) getArguments()?.getString(PasswordStore.REQUEST_ARG_PATH)?.let { relPath -> @@ -75,7 +75,7 @@ class SelectFolderFragment : Fragment(R.layout.password_recycler_view) { model.navigateTo( File(currentDir, dir), pushPreviousLocation = true, - listMode = ListMode.DirectoriesOnly, + listMode = ListMode.AllEntries, ) } } @@ -93,7 +93,7 @@ class SelectFolderFragment : Fragment(R.layout.password_recycler_view) { fun navigateTo(file: File) { model.navigateTo( file, - listMode = ListMode.DirectoriesOnly, + listMode = ListMode.AllEntries, recyclerViewState = binding.passRecycler.layoutManager?.onSaveInstanceState() ?: throw NullPointerException(), ) @@ -106,7 +106,7 @@ class SelectFolderFragment : Fragment(R.layout.password_recycler_view) { object : OnFragmentInteractionListener { override fun onFragmentInteraction(item: PasswordItem) { if (item.type == PasswordItem.TYPE_CATEGORY) { - model.navigateTo(item.file, listMode = ListMode.DirectoriesOnly) + model.navigateTo(item.file, listMode = ListMode.AllEntries) (requireActivity() as AppCompatActivity) .supportActionBar ?.setDisplayHomeAsUpEnabled(true) @@ -120,7 +120,7 @@ class SelectFolderFragment : Fragment(R.layout.password_recycler_view) { /** Returns true if the back press was handled by the [Fragment]. */ fun onBackPressedInActivity(): Boolean { if (!model.canNavigateBack) return false - model.navigateBack(listMode = ListMode.DirectoriesOnly) + model.navigateBack(listMode = ListMode.AllEntries) if (!model.canNavigateBack) (requireActivity() as AppCompatActivity).supportActionBar?.setDisplayHomeAsUpEnabled(false) return true diff --git a/app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt b/app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt index c57444530b..7df9b81dd4 100644 --- a/app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/git/base/BaseGitActivity.kt @@ -4,8 +4,13 @@ */ package app.passwordstore.ui.git.base +import android.Manifest import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.result.contract.ActivityResultContracts.RequestPermission import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat import androidx.core.content.edit import app.passwordstore.R import app.passwordstore.data.crypto.CryptoRepository @@ -20,15 +25,24 @@ import app.passwordstore.util.git.operation.PullOperation import app.passwordstore.util.git.operation.PushOperation import app.passwordstore.util.git.operation.ResetToRemoteOperation import app.passwordstore.util.git.operation.SyncOperation +import app.passwordstore.util.git.sshj.fixUri import app.passwordstore.util.settings.GitSettings import app.passwordstore.util.settings.PreferenceKeys import com.github.michaelbull.result.Err import com.github.michaelbull.result.Result import com.github.michaelbull.result.andThen import com.github.michaelbull.result.mapError +import com.github.michaelbull.result.onErr +import com.github.michaelbull.result.onOk +import com.github.michaelbull.result.runCatching import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint +import java.net.InetSocketAddress +import java.net.SocketTimeoutException import javax.inject.Inject +import javax.net.SocketFactory +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import logcat.asLog import logcat.logcat @@ -36,6 +50,7 @@ import net.schmizz.sshj.common.DisconnectReason import net.schmizz.sshj.common.SSHException import net.schmizz.sshj.transport.TransportException import net.schmizz.sshj.userauth.UserAuthException +import org.eclipse.jgit.transport.URIish /** * Abstract [AppCompatActivity] that holds some information that is commonly shared across @@ -44,6 +59,13 @@ import net.schmizz.sshj.userauth.UserAuthException @AndroidEntryPoint abstract class BaseGitActivity : AppCompatActivity() { + private var localNetworkAccessSetupCompletion: CompletableDeferred? = null + + private val requestLocalNetworkLauncher = + registerForActivityResult(RequestPermission()) { _ -> + localNetworkAccessSetupCompletion?.complete(Unit) + } + /** Enum of possible Git operations than can be run through [launchGitOperation]. */ enum class GitOp { BREAK_OUT_OF_DETACHED, @@ -67,28 +89,97 @@ abstract class BaseGitActivity : AppCompatActivity() { protected var remoteBranch = "" /** - * Attempt to launch the requested Git operation. All repository-mutating operations are - * serialized with background passkey sync so JGit never operates on the shared worktree from two - * callers at once. + * Attempt to launch the requested Git operation. * * @param operation The type of git operation to launch */ - suspend fun launchGitOperation(operation: GitOp): Result = - GitOperationCoordinator.withLock { - launchGitOperationUnlocked(operation) + suspend fun launchGitOperation(operation: GitOp): Result { + + if (gitSettings.url == null) + return Err(IllegalStateException(resources.getString(R.string.git_url_not_set_error))) + + val fixedUri = fixUri(URIish(gitSettings.url)) + val host = fixedUri.host + + // check whether local network access runtime permission needs to be granted (Android 17+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) { + + var networkPermission = Manifest.permission.ACCESS_LOCAL_NETWORK + + if ( + ContextCompat.checkSelfPermission(this, networkPermission) != + PackageManager.PERMISSION_GRANTED + ) { + + localNetworkAccessSetupCompletion = CompletableDeferred() + + runCatching { + withContext(dispatcherProvider.io()) { + val port = + fixedUri.port.takeUnless { it == -1 } + ?: run { + when (fixedUri.scheme) { + "http" -> 80 + "https" -> 443 + else -> 22 + } + } + SocketFactory.getDefault().createSocket().use { socket -> + socket.connect(InetSocketAddress(host, port), 5000) + } + } + } + .onOk { + localNetworkAccessSetupCompletion?.complete(Unit) + } + .onErr { e -> + if (e is SocketTimeoutException) { + withContext(dispatcherProvider.main()) { + MaterialAlertDialogBuilder(this@BaseGitActivity) + .setCancelable(false) + .setTitle(R.string.git_local_network_unreachable_dialog_title) + .setMessage( + resources.getString( + R.string.git_local_network_unreachable_dialog_message, + host, + ) + ) + .setPositiveButton(R.string.git_local_network_permission_grant) { _, _ -> + requestLocalNetworkLauncher.launch(networkPermission) + } + .setNegativeButton(R.string.git_local_network_permission_skip) { _, _ -> + localNetworkAccessSetupCompletion?.complete(Unit) + } + .show() + } + } else return Err(e) // something else must have gone wrong + } + } } - private suspend fun launchGitOperationUnlocked(operation: GitOp): Result { - if (gitSettings.url == null) { - return Err(IllegalStateException("Git url is not set!")) + localNetworkAccessSetupCompletion?.await() + + return lockedGitOperation(operation) + } + + /** + * Serialize with background passkey sync. Taken only after the (possibly interactive) local + * network permission flow so a pending dialog never blocks background workers. + */ + private suspend fun lockedGitOperation(operation: GitOp): Result = + GitOperationCoordinator.withLock { + launchGitOperationWithPermission(operation) } + + private suspend fun launchGitOperationWithPermission(operation: GitOp): Result { if (operation == GitOp.SYNC && !gitSettings.useMultiplexing) { - // Keep the lock across both operations so a background worker cannot interleave between - // pull and push when the server requires the reconnecting fallback. - return launchGitOperationUnlocked(GitOp.PULL).andThen { - launchGitOperationUnlocked(GitOp.PUSH) + // If the server does not support multiple SSH channels per connection, we cannot run + // a sync operation without reconnecting and thus break sync into its two parts. + return launchGitOperationWithPermission(GitOp.PULL).andThen { + launchGitOperationWithPermission(GitOp.PUSH) } } + val op = when (operation) { GitOp.CLONE -> CloneOperation(this, gitSettings.url ?: throw NullPointerException()) @@ -99,6 +190,7 @@ abstract class BaseGitActivity : AppCompatActivity() { GitOp.RESET -> ResetToRemoteOperation(this, remoteBranch) GitOp.GC -> GcOperation(this) } + return (if (op.requiresAuth) { op.executeAfterAuthentication(gitSettings.authMode) } else { diff --git a/app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt b/app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt index 4b170e21b9..9b67bb11f7 100644 --- a/app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/git/config/GitConfigActivity.kt @@ -52,7 +52,7 @@ class GitConfigActivity : BaseGitActivity() { binding.saveButton.setOnClickListener { val email = binding.gitUserEmail.text.toString().trim() val name = binding.gitUserName.text.toString().trim() - if (!email.matches(Patterns.EMAIL_ADDRESS.toRegex())) { + if (!email.isEmpty() && !email.matches(Patterns.EMAIL_ADDRESS.toRegex())) { MaterialAlertDialogBuilder(this) .setMessage(getString(R.string.invalid_email_dialog_text)) .setPositiveButton(getString(R.string.dialog_ok), null) diff --git a/app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt b/app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt index 684c935688..02adf3a219 100644 --- a/app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/git/config/GitServerConfigActivity.kt @@ -103,6 +103,18 @@ class GitServerConfigActivity : BaseGitActivity() { .show() it.isVisible = false } + + binding.showServerResponse.isChecked = gitSettings.showSshServerResponse + binding.showServerResponse.visibility = + gitSettings.url.let { + if (it.isNullOrEmpty() || !it.startsWith("http://") && !it.startsWith("https://")) + View.VISIBLE + else View.GONE + } + binding.showServerResponse.setOnCheckedChangeListener { _, isChecked -> + gitSettings.showSshServerResponse = isChecked + } + binding.saveButton.setOnClickListener { val newUrl = binding.serverUrl.text.toString().trim() if (newUrl.startsWith("git://")) { @@ -190,10 +202,14 @@ class GitServerConfigActivity : BaseGitActivity() { if (isHttps) { authModeSshKey.isVisible = false authModePassword.isVisible = true + showServerResponse.visibility = View.GONE + gitSettings.showSshServerResponse = true if (authModeGroup.checkedButtonId != authModePassword.id) authModeGroup.clearChecked() } else { authModeSshKey.isVisible = true authModePassword.isVisible = true + showServerResponse.visibility = View.VISIBLE + showServerResponse.isChecked = gitSettings.showSshServerResponse if (authModeGroup.checkedButtonId == View.NO_ID) authModeGroup.check(authModeSshKey.id) } } diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt index 47f86be068..e460e256bc 100644 --- a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt +++ b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/CloneFragment.kt @@ -7,83 +7,31 @@ package app.passwordstore.ui.onboarding.fragments import android.os.Bundle import android.view.View -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit import androidx.core.view.ViewCompat import androidx.fragment.app.Fragment import app.passwordstore.R -import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.databinding.FragmentCloneBinding -import app.passwordstore.ui.git.config.GitServerConfigActivity -import app.passwordstore.util.extensions.finish import app.passwordstore.util.extensions.performTransactionWithBackStack -import app.passwordstore.util.extensions.sharedPrefs -import app.passwordstore.util.extensions.unsafeLazy import app.passwordstore.util.extensions.viewBinding import app.passwordstore.util.extensions.windowInsetsLambda -import app.passwordstore.util.settings.PreferenceKeys -import com.github.michaelbull.result.onErr -import com.github.michaelbull.result.runCatching -import java.io.File -import logcat.LogPriority.ERROR -import logcat.asLog -import logcat.logcat class CloneFragment : Fragment(R.layout.fragment_clone) { private val binding by viewBinding(FragmentCloneBinding::bind) - private val settings by unsafeLazy { requireActivity().applicationContext.sharedPrefs } - - private val cloneAction = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == AppCompatActivity.RESULT_OK) { - if (File(PasswordRepository.getRepositoryDirectory(), ".gpg-id").isFile()) { - settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) } - finish() - } else { - // non-pass repository --> go to key selection - runCatching { - parentFragmentManager.performTransactionWithBackStack( - KeySelectionFragment.newInstance() - ) - } - .onErr { e -> logcat(ERROR) { e.asLog() } } - } - } - } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) ViewCompat.setOnApplyWindowInsetsListener(view, windowInsetsLambda) - binding.cloneRemote.setOnClickListener { cloneToHiddenDir() } - binding.createLocal.setOnClickListener { createRepository() } - } - - /** Clones a remote Git repository to the app's private directory */ - private fun cloneToHiddenDir() { - cloneAction.launch(GitServerConfigActivity.createCloneIntent(requireContext())) - } + binding.cloneRemote.setOnClickListener { + parentFragmentManager.performTransactionWithBackStack(RepoVisibilityFragment.newInstance()) + } - private fun createRepository() { - val localDir = PasswordRepository.getRepositoryDirectory() - runCatching { - check(localDir.exists() || localDir.mkdir()) { "Failed to create directory!" } - PasswordRepository.createRepository(localDir) - if (!PasswordRepository.isInitialized) { - PasswordRepository.initialize() - } - parentFragmentManager.performTransactionWithBackStack(KeySelectionFragment.newInstance()) + binding.createLocal.setOnClickListener { + parentFragmentManager.performTransactionWithBackStack( + RepoVisibilityFragment.newInstance(shouldClone = false) + ) } - .onErr { e -> - logcat(ERROR) { e.asLog() } - if (!localDir.delete()) { - logcat { "Failed to delete local repository: $localDir" } - } - finish() - } } companion object { diff --git a/app/src/main/java/app/passwordstore/ui/onboarding/fragments/RepoVisibilityFragment.kt b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/RepoVisibilityFragment.kt new file mode 100644 index 0000000000..fbc174454a --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/onboarding/fragments/RepoVisibilityFragment.kt @@ -0,0 +1,144 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.onboarding.fragments + +import android.os.Bundle +import android.view.View +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import androidx.core.view.ViewCompat +import androidx.fragment.app.Fragment +import app.passwordstore.R +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.databinding.FragmentRepoVisibilityBinding +import app.passwordstore.ui.git.config.GitServerConfigActivity +import app.passwordstore.util.extensions.finish +import app.passwordstore.util.extensions.performTransactionWithBackStack +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.extensions.unsafeLazy +import app.passwordstore.util.extensions.viewBinding +import app.passwordstore.util.extensions.windowInsetsLambda +import app.passwordstore.util.settings.PreferenceKeys +import com.github.michaelbull.result.onErr +import com.github.michaelbull.result.runCatching +import java.io.File +import logcat.LogPriority.ERROR +import logcat.asLog +import logcat.logcat + +class RepoVisibilityFragment : Fragment(R.layout.fragment_repo_visibility) { + + private val binding by viewBinding(FragmentRepoVisibilityBinding::bind) + + private val settings by unsafeLazy { requireActivity().applicationContext.sharedPrefs } + + private val cloneAction = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == AppCompatActivity.RESULT_OK) { + if (File(PasswordRepository.getRepositoryDirectory(), ".gpg-id").isFile()) { + settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) } + finish() + } else { + // non-pass repository --> go to key selection + runCatching { + parentFragmentManager.performTransactionWithBackStack( + KeySelectionFragment.newInstance() + ) + } + .onErr { e -> logcat(ERROR) { e.asLog() } } + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + ViewCompat.setOnApplyWindowInsetsListener(view, windowInsetsLambda) + + with(binding) { + if (requireArguments().getBoolean(SHOULD_CLONE_EXTRA)) + repoVisibilityText.setText(R.string.select_repo_visibility_clone_text) + else repoVisibilityText.setText(R.string.select_repo_visibility_create_text) + + internalRepo.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + repoSharedParent.visibility = View.INVISIBLE + } + } + + sharedRepo.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + repoSharedParent.text = getString(R.string.shared_repo_path, android.os.Build.MODEL) + repoSharedParent.visibility = View.VISIBLE + } + } + + continueButton.setOnClickListener { + settings.edit { putBoolean(PreferenceKeys.PREF_SHARED_REPO, sharedRepo.isChecked) } + if (requireArguments().getBoolean(SHOULD_CLONE_EXTRA)) cloneRepository() + else createRepository() + } + } + } + + /** Clones a remote Git repository */ + private fun cloneRepository() { + val localDir = PasswordRepository.getRepositoryDirectory() + runCatching { + check(localDir.exists() || localDir.mkdirs()) { "Failed to create directory!" } + PasswordRepository.initialize() + cloneAction.launch(GitServerConfigActivity.createCloneIntent(requireContext())) + } + .onErr { e -> + logcat(ERROR) { e.asLog() } + if (!localDir.delete()) { + logcat { "Failed to delete local repository: $localDir" } + } + finish() + } + } + + /** Fresh Git repository */ + private fun createRepository() { + val localDir = PasswordRepository.getRepositoryDirectory() + runCatching { + check(localDir.exists() || localDir.mkdirs()) { "Failed to create directory!" } + PasswordRepository.createRepository(localDir) + PasswordRepository.initialize() + if (File(PasswordRepository.getRepositoryDirectory(), ".gpg-id").isFile()) { + settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) } + finish() + } else { + // non-pass repository --> go to key selection + parentFragmentManager.performTransactionWithBackStack(KeySelectionFragment.newInstance()) + } + } + .onErr { e -> + logcat(ERROR) { e.asLog() } + if (!localDir.delete()) { + logcat { "Failed to delete local repository: $localDir" } + } + finish() + } + } + + companion object { + + private const val SHOULD_CLONE_EXTRA = "SHOULD_CLONE" + + fun newInstance(shouldClone: Boolean = true): RepoVisibilityFragment { + val extras = + Bundle().also { + it.apply { + putBoolean(SHOULD_CLONE_EXTRA, shouldClone) + } + } + val fragment = RepoVisibilityFragment() + fragment.arguments = extras + return fragment + } + } +} diff --git a/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt b/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt index 10a5236f41..2c83caa1e4 100644 --- a/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt +++ b/app/src/main/java/app/passwordstore/ui/passwords/PasswordStore.kt @@ -686,7 +686,7 @@ class PasswordStore : BaseGitActivity() { PasswordRepository.getRelativePath(target.absolutePath, repoPath) } if (relativeTargetPath != null) { - model.reset() + runCatching { model.reset() } model.navigateTo(PasswordRepository.getRepositoryDirectory(), pushPreviousLocation = false) relativeTargetPath.trim('/').split('/').forEach { item -> val file = File(model.currentDir.value, item) @@ -696,9 +696,9 @@ class PasswordStore : BaseGitActivity() { } else getPasswordFragment()?.scrollToOnNextRefresh(file) } } else if (model.currentDir.value.isDirectory) { - model.forceRefresh() + runCatching { model.forceRefresh() } } else { - model.reset() + runCatching { model.reset() } } supportActionBar?.setDisplayHomeAsUpEnabled(model.canNavigateBack) updateFabSync() diff --git a/app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt index 684a09cc11..ff053033aa 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/AutofillSettings.kt @@ -8,11 +8,10 @@ package app.passwordstore.ui.settings import android.annotation.SuppressLint import android.content.Intent import android.provider.Settings +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.appcompat.widget.AppCompatTextView import androidx.core.net.toUri import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import app.passwordstore.BuildConfig import app.passwordstore.R import app.passwordstore.util.extensions.autofillManager @@ -25,23 +24,20 @@ import de.Maxr1998.modernpreferences.helpers.editText import de.Maxr1998.modernpreferences.helpers.onClick import de.Maxr1998.modernpreferences.helpers.switch import de.Maxr1998.modernpreferences.preferences.SwitchPreference +import kotlinx.coroutines.launch class AutofillSettings(private val activity: FragmentActivity) : SettingsProvider { - private val isAutofillServiceEnabled: Boolean - get() { - return activity.autofillManager?.hasEnabledAutofillServices() == true - } + val isAutofillServiceEnabled: Boolean + get() = activity.autofillManager?.hasEnabledAutofillServices() == true - private fun showAutofillDialog(pref: SwitchPreference) { - val observer = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_RESUME -> { - pref.checked = isAutofillServiceEnabled - } - else -> {} - } + var autofillSwitchPreference: SwitchPreference? = null + private val requestSetAutofillServiceAction = + activity.registerForActivityResult(StartActivityForResult()) { _ -> + autofillSwitchPreference?.run { checked = isAutofillServiceEnabled } } + + private fun showAutofillDialog() { MaterialAlertDialogBuilder(activity).run { setTitle(R.string.pref_autofill_enable_title) @SuppressLint("InflateParams") @@ -75,11 +71,9 @@ class AutofillSettings(private val activity: FragmentActivity) : SettingsProvide Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply { data = "package:${BuildConfig.APPLICATION_ID}".toUri() } - activity.startActivity(intent) + requestSetAutofillServiceAction.launch(intent) } setNegativeButton(R.string.dialog_cancel, null) - setOnDismissListener { pref.checked = isAutofillServiceEnabled } - activity.lifecycle.addObserver(observer) show() } } @@ -88,16 +82,17 @@ class AutofillSettings(private val activity: FragmentActivity) : SettingsProvide builder.apply { switch(PreferenceKeys.AUTOFILL_ENABLE) { titleRes = R.string.pref_autofill_enable_title - defaultValue = isAutofillServiceEnabled + autofillSwitchPreference = this onClick { - if (isAutofillServiceEnabled) { - activity.autofillManager?.disableAutofillServices() - } else { - showAutofillDialog(this) - } + if (checked) showAutofillDialog() else activity.autofillManager?.disableAutofillServices() false } } + switch(PreferenceKeys.AUTOFILL_ASK_TO_SAVE_PASSWORDS) { + titleRes = R.string.pref_autofill_ask_save_passwords_title + defaultValue = true + dependency = PreferenceKeys.AUTOFILL_ENABLE + } editText(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) { dependency = PreferenceKeys.AUTOFILL_ENABLE titleRes = R.string.preference_default_username_title diff --git a/app/src/main/java/app/passwordstore/ui/settings/GeneralSettings.kt b/app/src/main/java/app/passwordstore/ui/settings/GeneralSettings.kt index 7f9ba8eff7..560b411c83 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/GeneralSettings.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/GeneralSettings.kt @@ -87,7 +87,7 @@ class GeneralSettings(private val activity: FragmentActivity) : SettingsProvider val canAuthenticate = BiometricAuthenticator.canAuthenticate(activity, allowPin = true) switch(PreferenceKeys.BIOMETRIC_AUTH_2) { titleRes = R.string.pref_biometric_auth_title - defaultValue = activity.sharedPrefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH_2, false) + defaultValue = false enabled = canAuthenticate summaryRes = if (canAuthenticate) R.string.pref_biometric_auth_summary diff --git a/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt b/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt index 20cbb8c393..761441b5bc 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt @@ -9,6 +9,7 @@ import android.content.Context import android.content.Intent import android.content.pm.ShortcutManager import android.net.Uri +import android.os.Build import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.core.content.edit @@ -231,7 +232,7 @@ class RepositorySettings(private val activity: FragmentActivity) : SettingsProvi override fun provideSettings(builder: PreferenceScreen.Builder) { builder.apply { pref(PreferenceKeys.GIT_SERVER_INFO) { - titleRes = R.string.pref_edit_git_server_settings + titleRes = R.string.title_activity_git_remote onClick { configureGitServer.launch(Intent(activity, GitServerConfigActivity::class.java)) true @@ -305,9 +306,19 @@ class RepositorySettings(private val activity: FragmentActivity) : SettingsProvi summaryOnRes = R.string.pref_rebase_on_pull_summary_on defaultValue = true } + + switch(PreferenceKeys.PREF_SHARED_REPO) { + titleRes = R.string.prefs_external_repository_title + summaryRes = R.string.prefs_external_repository_summary + summaryOn = activity.getString(R.string.shared_repo_path, android.os.Build.MODEL) + disableDependents = true + defaultValue = false + } + pref(PreferenceKeys.EXPORT_PASSWORDS) { titleRes = R.string.prefs_export_passwords_title summaryRes = R.string.prefs_export_passwords_summary + dependency = PreferenceKeys.PREF_SHARED_REPO onClick { storeExportAction.launch(null) true @@ -316,6 +327,7 @@ class RepositorySettings(private val activity: FragmentActivity) : SettingsProvi pref(PreferenceKeys.IMPORT_PASSWORDS) { titleRes = R.string.prefs_import_passwords_title summaryRes = R.string.prefs_import_passwords_summary + dependency = PreferenceKeys.PREF_SHARED_REPO onClick { if (PasswordRepository.isEmpty()) { storeImportAction.launch(null) @@ -333,6 +345,7 @@ class RepositorySettings(private val activity: FragmentActivity) : SettingsProvi pref(PreferenceKeys.GIT_DELETE_REPO) { titleRes = R.string.pref_git_delete_repo_title summaryRes = R.string.pref_git_delete_repo_summary + dependency = PreferenceKeys.PREF_SHARED_REPO onClick { val repoDir = PasswordRepository.getRepositoryDirectory() MaterialAlertDialogBuilder(activity) @@ -341,11 +354,7 @@ class RepositorySettings(private val activity: FragmentActivity) : SettingsProvi .setCancelable(false) .setPositiveButton(R.string.dialog_delete) { dialogInterface, _ -> runCatching { - PasswordRepository.closeRepository() - PasswordRepository.getRepositoryDirectory().let { dir -> - dir.deleteRecursively() - dir.mkdirs() - } + PasswordRepository.deleteRepository() } .onErr { it.message?.let { message -> activity.snackbar(message = message) } } diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt index ab2a79ac2d..1745998efc 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsActivity.kt @@ -5,25 +5,32 @@ package app.passwordstore.ui.settings +import android.content.SharedPreferences import android.os.Bundle import android.view.MenuItem import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit import androidx.core.os.BundleCompat import app.passwordstore.R import app.passwordstore.databinding.ActivityPreferenceRecyclerviewBinding +import app.passwordstore.injection.prefs.SettingsPreferences import app.passwordstore.util.extensions.enableEdgeToEdgeView import app.passwordstore.util.extensions.viewBinding +import app.passwordstore.util.settings.PreferenceKeys import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import de.Maxr1998.modernpreferences.Preference import de.Maxr1998.modernpreferences.PreferencesAdapter import de.Maxr1998.modernpreferences.helpers.screen import de.Maxr1998.modernpreferences.helpers.subScreen +import javax.inject.Inject @AndroidEntryPoint class SettingsActivity : AppCompatActivity() { + @SettingsPreferences @Inject lateinit var settings: SharedPreferences + private val miscSettings = MiscSettings(this) private val autofillSettings = AutofillSettings(this) private val passwordSettings = PasswordSettings(this) @@ -36,10 +43,23 @@ class SettingsActivity : AppCompatActivity() { private val preferencesAdapter: PreferencesAdapter get() = binding.preferenceRecyclerView.adapter as PreferencesAdapter + override fun onResume() { + super.onResume() + autofillSettings.run { autofillSwitchPreference?.checked = isAutofillServiceEnabled } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdgeView(binding.root) setContentView(binding.root) + + settings.edit { + putBoolean( + PreferenceKeys.AUTOFILL_ENABLE, + autofillSettings.isAutofillServiceEnabled, + ) + } + Preference.Config.dialogBuilderFactory = { context -> MaterialAlertDialogBuilder(context) } val screen = screen(this) { diff --git a/app/src/main/java/app/passwordstore/util/autofill/Api26AutofillResponseBuilder.kt b/app/src/main/java/app/passwordstore/util/autofill/Api26AutofillResponseBuilder.kt index 5b18d24238..a987d552d4 100644 --- a/app/src/main/java/app/passwordstore/util/autofill/Api26AutofillResponseBuilder.kt +++ b/app/src/main/java/app/passwordstore/util/autofill/Api26AutofillResponseBuilder.kt @@ -11,16 +11,21 @@ import android.service.autofill.Dataset import android.service.autofill.FillCallback import android.service.autofill.FillRequest import android.service.autofill.FillResponse +import android.service.autofill.RegexValidator import android.service.autofill.SaveInfo +import android.view.autofill.AutofillId import app.passwordstore.ui.autofill.AutofillDecryptActivity import app.passwordstore.ui.autofill.AutofillFilterView import app.passwordstore.ui.autofill.AutofillPublisherChangedActivity import app.passwordstore.ui.autofill.AutofillSaveActivity +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.settings.PreferenceKeys import com.github.androidpasswordstore.autofillparser.AutofillAction import com.github.androidpasswordstore.autofillparser.FillableForm import com.github.androidpasswordstore.autofillparser.fillWith import com.github.michaelbull.result.fold import java.io.File +import java.util.regex.Pattern import logcat.LogPriority.ERROR import logcat.asLog import logcat.logcat @@ -59,22 +64,23 @@ class Api26AutofillResponseBuilder private constructor(form: FillableForm) : private fun makeMatchDataset(context: Context, file: File): Dataset? { if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null - val metadata = makeFillMatchMetadata(context, file) + val metadata = makeFillMatchMetadata(context, file, 0, 0) val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context) return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata) } private fun makeSearchDataset(context: Context): Dataset? { if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null - val metadata = makeSearchAndFillMetadata(context) + val metadata = makeSearchAndFillMetadata(context, 0, 0) val intentSender = AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin) return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata) } private fun makeGenerateDataset(context: Context): Dataset? { if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null - val metadata = makeGenerateAndFillMetadata(context) - val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin) + val metadata = makeGenerateAndFillMetadata(context, 0, 0) + val intentSender = + AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin, clientState) return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata) } @@ -111,8 +117,12 @@ class Api26AutofillResponseBuilder private constructor(form: FillableForm) : // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE // See: // https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE - private fun makeSaveInfo(): SaveInfo? { - if (!canBeSaved) return null + private fun makeSaveInfo(context: Context): SaveInfo? { + if ( + !canBeSaved || + !context.sharedPrefs.getBoolean(PreferenceKeys.AUTOFILL_ASK_TO_SAVE_PASSWORDS, true) + ) + return null check(saveFlags != null) { "saveFlags must not be null" } val idsToSave = scenario.fieldsToSave.toTypedArray() if (idsToSave.isEmpty()) return null @@ -120,7 +130,29 @@ class Api26AutofillResponseBuilder private constructor(form: FillableForm) : if (scenario.hasUsername) { saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME } + return SaveInfo.Builder(saveDataTypes, idsToSave).run { + + /** + * Suppress autofill save UI ("Save username and password to Password Store?") if at least one + * of ...//.gpg or ...///password.gpg exists in the repo + */ + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.O) { + val knownUsernames = + findKnownUsernamesFor(formOrigin.getPrettyIdentifier(context, untrusted = false)) + if ( + !knownUsernames.isEmpty() && scenario.username != null && scenario.username is AutofillId + ) { + val pattern = + Pattern.compile( + // negative (= not matching) regex + "^(?!(" + knownUsernames.map { Pattern.quote(it) }.joinToString("|") + ")$).*$" + ) + val validator = RegexValidator(scenario.username as AutofillId, pattern) + setValidator(validator) + } + } + setFlags(saveFlags) build() } @@ -152,7 +184,7 @@ class Api26AutofillResponseBuilder private constructor(form: FillableForm) : ) ) } - makeSaveInfo()?.let { setSaveInfo(it) } + makeSaveInfo(context)?.let { setSaveInfo(it) } setClientState(clientState) setIgnoredIds(*ignoredIds.toTypedArray()) build() diff --git a/app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt b/app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt index 2d9cec39e9..107a385c6a 100644 --- a/app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt +++ b/app/src/main/java/app/passwordstore/util/autofill/Api30AutofillResponseBuilder.kt @@ -13,7 +13,9 @@ import android.service.autofill.FillCallback import android.service.autofill.FillRequest import android.service.autofill.FillResponse import android.service.autofill.Presentations +import android.service.autofill.RegexValidator import android.service.autofill.SaveInfo +import android.view.autofill.AutofillId import android.view.inputmethod.InlineSuggestionsRequest import android.widget.inline.InlinePresentationSpec import androidx.annotation.RequiresApi @@ -21,11 +23,14 @@ import app.passwordstore.ui.autofill.AutofillDecryptActivity import app.passwordstore.ui.autofill.AutofillFilterView import app.passwordstore.ui.autofill.AutofillPublisherChangedActivity import app.passwordstore.ui.autofill.AutofillSaveActivity +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.settings.PreferenceKeys import com.github.androidpasswordstore.autofillparser.AutofillAction import com.github.androidpasswordstore.autofillparser.FillableForm import com.github.androidpasswordstore.autofillparser.fillWith import com.github.michaelbull.result.fold import java.io.File +import java.util.regex.Pattern import logcat.LogPriority.ERROR import logcat.asLog import logcat.logcat @@ -104,7 +109,7 @@ class Api30AutofillResponseBuilder private constructor(form: FillableForm) : val presentationsBuilder = Presentations.Builder() if (imeSpec != null) { val inlinePresentation = makeInlinePresentation(context, imeSpec, metadata) - if (inlinePresentation != null) { + if (inlinePresentation != null && metadata.suggestionCount < metadata.maxSuggestions - 1) { presentationsBuilder.setInlinePresentation(inlinePresentation) } else { presentationsBuilder.setMenuPresentation(makeRemoteView(context, metadata)) @@ -124,25 +129,56 @@ class Api30AutofillResponseBuilder private constructor(form: FillableForm) : context: Context, file: File, imeSpec: InlinePresentationSpec?, + suggestionCount: Int, + maxSuggestions: Int, ): Dataset? { if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null - val metadata = makeFillMatchMetadata(context, file) + val metadata = makeFillMatchMetadata(context, file, suggestionCount, maxSuggestions) val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context) - return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec) + return makeIntentDataset( + context, + AutofillAction.Match, + intentSender, + metadata, + imeSpec, + ) } - private fun makeSearchDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? { + private fun makeSearchDataset( + context: Context, + imeSpec: InlinePresentationSpec?, + suggestionCount: Int, + maxSuggestions: Int, + ): Dataset? { if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null - val metadata = makeSearchAndFillMetadata(context) + val metadata = makeSearchAndFillMetadata(context, suggestionCount, maxSuggestions) val intentSender = AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin) - return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata, imeSpec) + return makeIntentDataset( + context, + AutofillAction.Search, + intentSender, + metadata, + imeSpec, + ) } - private fun makeGenerateDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? { + private fun makeGenerateDataset( + context: Context, + imeSpec: InlinePresentationSpec?, + suggestionCount: Int, + maxSuggestions: Int, + ): Dataset? { if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null - val metadata = makeGenerateAndFillMetadata(context) - val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin) - return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata, imeSpec) + val metadata = makeGenerateAndFillMetadata(context, suggestionCount, maxSuggestions) + val intentSender = + AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin, clientState) + return makeIntentDataset( + context, + AutofillAction.Generate, + intentSender, + metadata, + imeSpec, + ) } private fun makePublisherChangedDataset( @@ -195,16 +231,16 @@ class Api30AutofillResponseBuilder private constructor(form: FillableForm) : } return FillResponse.Builder().run { for (file in matchedFiles) { - makeMatchDataset(context, file, nextImeSpec())?.let { + makeMatchDataset(context, file, nextImeSpec(), matchedFiles.size, maxSuggestions)?.let { datasetCount++ addDataset(it) } } - makeGenerateDataset(context, nextImeSpec())?.let { + makeGenerateDataset(context, nextImeSpec(), matchedFiles.size, maxSuggestions)?.let { datasetCount++ addDataset(it) } - makeSearchDataset(context, nextImeSpec())?.let { + makeSearchDataset(context, nextImeSpec(), matchedFiles.size, maxSuggestions)?.let { datasetCount++ addDataset(it) } @@ -215,7 +251,9 @@ class Api30AutofillResponseBuilder private constructor(form: FillableForm) : makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true)), ) ) - makeSaveInfo()?.let { setSaveInfo(it) } + makeSaveInfo(context)?.let { + setSaveInfo(it) + } setClientState(clientState) setIgnoredIds(*ignoredIds.toTypedArray()) build() @@ -225,8 +263,12 @@ class Api30AutofillResponseBuilder private constructor(form: FillableForm) : // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE // See: // https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE - private fun makeSaveInfo(): SaveInfo? { - if (!canBeSaved) return null + private fun makeSaveInfo(context: Context): SaveInfo? { + if ( + !canBeSaved || + !context.sharedPrefs.getBoolean(PreferenceKeys.AUTOFILL_ASK_TO_SAVE_PASSWORDS, true) + ) + return null check(saveFlags != null) { "saveFlags must not be null" } val idsToSave = scenario.fieldsToSave.toTypedArray() if (idsToSave.isEmpty()) return null @@ -234,7 +276,27 @@ class Api30AutofillResponseBuilder private constructor(form: FillableForm) : if (scenario.hasUsername) { saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME } + return SaveInfo.Builder(saveDataTypes, idsToSave).run { + + /** + * Suppress autofill save UI ("Save username and password to Password Store?") if at least one + * of ...//.gpg or ...///password.gpg exists in the repo + */ + val knownUsernames = + findKnownUsernamesFor(formOrigin.getPrettyIdentifier(context, untrusted = false)) + if ( + !knownUsernames.isEmpty() && scenario.username != null && scenario.username is AutofillId + ) { + val pattern = + Pattern.compile( + // negative (= not matching) regex + "^(?!(" + knownUsernames.map { Pattern.quote(it) }.joinToString("|") + ")$).*$" + ) + val validator = RegexValidator(scenario.username as AutofillId, pattern) + setValidator(validator) + } + setFlags(saveFlags) build() } 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 196de6e5b1..d36d457645 100644 --- a/app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt +++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillMatcher.kt @@ -6,9 +6,7 @@ package app.passwordstore.util.autofill import android.content.Context import android.content.SharedPreferences -import android.widget.Toast import androidx.core.content.edit -import app.passwordstore.R import com.github.androidpasswordstore.autofillparser.FormOrigin import com.github.androidpasswordstore.autofillparser.computeCertificatesHash import com.github.michaelbull.result.Err @@ -51,7 +49,33 @@ class AutofillMatcher { companion object { - private const val MAX_NUM_MATCHES = 10 + private const val MAX_NUM_MATCHES = 65536 + + /** + * Matches are stored as an ordered (most recently used first) list of absolute password file + * paths concatenated with this separator, which is unlikely to appear in a file path. + */ + private const val MATCHES_SEPARATOR = "|§+" + + private fun splitMatches(value: String): List = + value.split(MATCHES_SEPARATOR).filter { it.isNotEmpty() } + + /** + * Reads the ordered list of matches stored under [key]. Older versions stored matches as an + * unordered `Set`; such values are migrated in place to the ordered representation + * instead of being discarded. + */ + private fun readMatches(prefs: SharedPreferences, key: String): List { + return when (val value = prefs.all[key]) { + is String -> splitMatches(value) + is Set<*> -> { + val migrated = value.filterIsInstance() + prefs.edit { putString(key, migrated.joinToString(MATCHES_SEPARATOR)) } + migrated + } + else -> emptyList() + } + } private const val PREFERENCE_PREFIX_TOKEN = "token;" @@ -108,15 +132,16 @@ class AutofillMatcher { return Err(AutofillPublisherChangedException(formOrigin)) } val matchPreferences = context.matchPreferences(formOrigin) - val matchedFiles = - matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())?.map { File(it) } - ?: throw NullPointerException() + val matchedFiles = readMatches(matchPreferences, matchesKey(formOrigin)).map { File(it) } return Ok( matchedFiles - .filter { it.exists() } + .filter { it.exists() && !AutofillPasskeyExclusion.isPasskeyCredentialFile(it) } .also { validFiles -> matchPreferences.edit { - putStringSet(matchesKey(formOrigin), validFiles.map { it.absolutePath }.toSet()) + putString( + matchesKey(formOrigin), + validFiles.joinToString(MATCHES_SEPARATOR) { it.absolutePath }, + ) } } ) @@ -137,7 +162,7 @@ class AutofillMatcher { * may crash when too many datasets are offered. */ fun addMatchFor(context: Context, formOrigin: FormOrigin, file: File) { - if (!file.exists()) return + if (!file.exists() || AutofillPasskeyExclusion.isPasskeyCredentialFile(file)) return if (hasFormOriginHashChanged(context, formOrigin)) { // This should never happen since we already verified the publisher in // getMatchesFor. @@ -146,20 +171,15 @@ class AutofillMatcher { } val matchPreferences = context.matchPreferences(formOrigin) val matchedFiles = - matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())?.map { File(it) } - ?: throw NullPointerException() - val newFiles = setOf(file.absoluteFile).union(matchedFiles) - if (newFiles.size > MAX_NUM_MATCHES) { - Toast.makeText( - context, - context.getString(R.string.oreo_autofill_max_matches_reached, MAX_NUM_MATCHES), - Toast.LENGTH_LONG, - ) - .show() - return - } + readMatches(matchPreferences, matchesKey(formOrigin)) + .asSequence() + .minus(file.absolutePath) + .filter { File(it).exists() } + .take(MAX_NUM_MATCHES - 1) + .toList() + val newFiles = listOf(file.absolutePath) + matchedFiles matchPreferences.edit { - putStringSet(matchesKey(formOrigin), newFiles.map { it.absolutePath }.toSet()) + putString(matchesKey(formOrigin), newFiles.joinToString(MATCHES_SEPARATOR)) } storeFormOriginHash(context, formOrigin) logcat { "Stored match for $formOrigin" } @@ -180,13 +200,16 @@ class AutofillMatcher { for (prefs in listOf(context.autofillAppMatches, context.autofillWebMatches)) { for ((key, value) in prefs.all) { if (!key.startsWith(PREFERENCE_PREFIX_MATCHES)) continue - // We know that preferences starting with `PREFERENCE_PREFIX_MATCHES` were - // created with `putStringSet`. - @Suppress("UNCHECKED_CAST") val oldMatches = value as? Set - if (oldMatches == null) { - logcat(WARN) { "Failed to read matches for $key" } - continue - } + val oldMatches = + when (value) { + is String -> splitMatches(value) + // Legacy (unordered) Set representation, see readMatches(). + is Set<*> -> value.filterIsInstance() + else -> { + logcat(WARN) { "Failed to read matches for $key" } + continue + } + } // Delete all matches for file locations that are going to be overwritten, then // transfer matches over to the files at their new locations. val newMatches = @@ -199,8 +222,9 @@ class AutofillMatcher { logcat { "Updating match for $key: $match --> $newPath" } newPath } - .toSet() - if (newMatches != oldMatches) prefs.edit { putStringSet(key, newMatches) } + .toList() + if (newMatches != oldMatches || value !is String) + prefs.edit { putString(key, newMatches.joinToString(MATCHES_SEPARATOR)) } } } } diff --git a/app/src/main/java/app/passwordstore/util/autofill/AutofillPasskeyExclusion.kt b/app/src/main/java/app/passwordstore/util/autofill/AutofillPasskeyExclusion.kt new file mode 100644 index 0000000000..c47b1c0f22 --- /dev/null +++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillPasskeyExclusion.kt @@ -0,0 +1,42 @@ +/* + * Copyright © 2014-2026 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.util.autofill + +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.passkeys.storage.PasskeyStorageConfig +import java.io.File +import java.io.IOException + +/** + * Classic (username/password) Autofill must never offer or fill passkey credentials. + * + * Passkey credentials are stored as encrypted CBOR below a dedicated repository-relative directory + * (see [PasskeyStorageConfig.passkeyDirectory]). Decrypting such a file as a password entry would + * expose private key material as "password" content, so these files are excluded from Autofill + * suggestions and search results, and refused before decryption. + */ +object AutofillPasskeyExclusion { + + private val passkeyDirectory: String = PasskeyStorageConfig().passkeyDirectory + + /** + * Returns true if [file] lies inside the passkey credential directory of [repositoryRoot]. With + * [resolveLinks], symbolic links are resolved as well (requires file system access). + */ + fun isPasskeyCredentialFile( + file: File, + repositoryRoot: File = PasswordRepository.getRepositoryDirectory(), + resolveLinks: Boolean = true, + ): Boolean { + val passkeyRoot = File(repositoryRoot, passkeyDirectory) + if (file.absoluteFile.normalize().startsWith(passkeyRoot.absoluteFile.normalize())) return true + if (!resolveLinks) return false + return try { + file.canonicalFile.startsWith(passkeyRoot.canonicalFile) + } catch (_: IOException) { + false + } + } +} diff --git a/app/src/main/java/app/passwordstore/util/autofill/AutofillPreferences.kt b/app/src/main/java/app/passwordstore/util/autofill/AutofillPreferences.kt index 4b367efecc..9bcb59ded6 100644 --- a/app/src/main/java/app/passwordstore/util/autofill/AutofillPreferences.kt +++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillPreferences.kt @@ -50,24 +50,32 @@ object AutofillPreferences { return segments.joinToString("/") } - fun strictDomainSearch(context: Context): Boolean { - return context.sharedPrefs.getBoolean(PreferenceKeys.STRICT_DOMAIN_SEARCH, true) - } + fun strictDomainSearch(context: Context, strict: Boolean? = null): Boolean = + strict?.also { + context.sharedPrefs.edit { putBoolean(PreferenceKeys.STRICT_DOMAIN_SEARCH, strict) } + } ?: context.sharedPrefs.getBoolean(PreferenceKeys.STRICT_DOMAIN_SEARCH, true) - fun setStrictDomainSearch(context: Context, strict: Boolean) { - context.sharedPrefs.edit { putBoolean(PreferenceKeys.STRICT_DOMAIN_SEARCH, strict) } - } + fun addQuickSelectButton(context: Context, enabled: Boolean? = null): Boolean = + enabled?.also { + context.sharedPrefs.edit { putBoolean(PreferenceKeys.DOMAIN_QUICK_SELECT_ADD, enabled) } + } ?: context.sharedPrefs.getBoolean(PreferenceKeys.DOMAIN_QUICK_SELECT_ADD, true) + + fun removeQuickSelectButtons(context: Context, enabled: Boolean? = null): Boolean = + enabled?.also { + context.sharedPrefs.edit { putBoolean(PreferenceKeys.DOMAIN_QUICK_SELECT_REMOVE, enabled) } + } ?: context.sharedPrefs.getBoolean(PreferenceKeys.DOMAIN_QUICK_SELECT_REMOVE, false) fun credentialsFromStoreEntry( context: Context, file: File, entry: PasswordEntry, + origin: String?, directoryStructure: DirectoryStructure, ): Credentials { // Always give priority to a username stored in the encrypted extras val username = entry.username - ?: directoryStructure.getUsernameFor(file)?.toCharArray() + ?: directoryStructure.getUsernameFor(file, origin)?.toCharArray() ?: context.getDefaultUsername()?.toCharArray() val totp = if (entry.hasTotp()) entry.currentOtp.value else null return Credentials(username, entry.password, totp) diff --git a/app/src/main/java/app/passwordstore/util/autofill/AutofillResponseBuilder.kt b/app/src/main/java/app/passwordstore/util/autofill/AutofillResponseBuilder.kt index 0d90597065..828450d2c9 100644 --- a/app/src/main/java/app/passwordstore/util/autofill/AutofillResponseBuilder.kt +++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillResponseBuilder.kt @@ -11,11 +11,16 @@ import android.os.Bundle import android.service.autofill.Dataset import android.service.autofill.FillCallback import android.service.autofill.FillRequest +import app.passwordstore.data.repo.PasswordRepository import com.github.androidpasswordstore.autofillparser.AutofillAction import com.github.androidpasswordstore.autofillparser.AutofillScenario import com.github.androidpasswordstore.autofillparser.Credentials import com.github.androidpasswordstore.autofillparser.FillableForm import com.github.androidpasswordstore.autofillparser.fillWith +import java.nio.file.Files +import kotlin.io.path.extension +import kotlin.io.path.name +import kotlin.io.path.nameWithoutExtension import logcat.LogPriority import logcat.logcat @@ -26,6 +31,34 @@ interface AutofillResponseBuilder { fun create(form: FillableForm): AutofillResponseBuilder } + /** + * Usernames of existing entries for [origin], i.e. `...//.gpg` (file-based) or + * `...///` (directory-based). Passkey credentials are not considered. + */ + fun findKnownUsernamesFor(origin: String): List { + val repositoryRoot = PasswordRepository.getRepositoryDirectory() + return PasswordRepository.findByParentName( + startPath = repositoryRoot.absolutePath, + name = origin, + ignoreCase = true, + ) + .filterNot { + AutofillPasskeyExclusion.isPasskeyCredentialFile( + it.toFile(), + repositoryRoot, + resolveLinks = false, + ) + } + .mapNotNull { + when { + !Files.isRegularFile(it) -> it.name + it.extension == "gpg" && !it.name.startsWith('.') -> it.nameWithoutExtension + else -> null + } + } + .distinct() + } + companion object { fun makeFillInDataset( context: Context, diff --git a/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt b/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt index 422fe00852..4c3e6b99fe 100644 --- a/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt +++ b/app/src/main/java/app/passwordstore/util/autofill/AutofillViewUtils.kt @@ -23,11 +23,21 @@ import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.ui.passwords.PasswordStore import java.io.File -data class DatasetMetadata(val title: String, val subtitle: String?, @DrawableRes val iconRes: Int) +data class DatasetMetadata( + val title: String?, + val subtitle: String?, + @DrawableRes val iconRes: Int, + val suggestionCount: Int, + val maxSuggestions: Int, +) fun makeRemoteView(context: Context, metadata: DatasetMetadata): RemoteViews { return RemoteViews(context.packageName, R.layout.oreo_autofill_dataset).apply { - setTextViewText(R.id.title, metadata.title) + if (metadata.title != null) { + setTextViewText(R.id.title, metadata.title) + } else { + setViewVisibility(R.id.title, View.GONE) + } if (metadata.subtitle != null) { setTextViewText(R.id.summary, metadata.subtitle) } else { @@ -41,6 +51,8 @@ fun makeRemoteView(context: Context, metadata: DatasetMetadata): RemoteViews { } } +private var requestCode = 500000 + @SuppressLint("RestrictedApi") fun makeInlinePresentation( context: Context, @@ -54,7 +66,7 @@ fun makeInlinePresentation( val launchIntent = PendingIntent.getActivity( context, - 0, + requestCode++, Intent(context, PasswordStore::class.java), if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { PendingIntent.FLAG_MUTABLE @@ -64,12 +76,9 @@ fun makeInlinePresentation( ) val slice = InlineSuggestionUi.newContentBuilder(launchIntent).run { - setTitle(metadata.title) - if (metadata.subtitle != null) setSubtitle(metadata.subtitle) - setContentDescription( - if (metadata.subtitle != null) "${metadata.title} - ${metadata.subtitle}" - else metadata.title - ) + setTitle(metadata.title?.let { it } ?: "") + setSubtitle(metadata.subtitle?.let { it } ?: "") + setContentDescription(listOfNotNull(metadata.title, metadata.subtitle).joinToString(" - ")) setStartIcon(Icon.createWithResource(context, metadata.iconRes)) build().slice } @@ -77,38 +86,53 @@ fun makeInlinePresentation( return InlinePresentation(slice, imeSpec, false) } -fun makeFillMatchMetadata(context: Context, file: File): DatasetMetadata { +fun makeFillMatchMetadata( + context: Context, + file: File, + suggestionCount: Int, + maxSuggestions: Int, +): DatasetMetadata { val directoryStructure = AutofillPreferences.directoryStructure(context) val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory()) - val title = - directoryStructure.getIdentifierFor(relativeFile) - ?: directoryStructure.getAccountPartFor(relativeFile) - ?: throw NullPointerException() - val subtitle = directoryStructure.getAccountPartFor(relativeFile) - return DatasetMetadata(title, subtitle, R.drawable.ic_person_black_24dp) + val title = directoryStructure.getPathStringWithAtMostTwoParentsFor(relativeFile) + + return DatasetMetadata( + null, + title, + R.drawable.ic_person_black_24dp, + suggestionCount, + maxSuggestions, + ) } -fun makeSearchAndFillMetadata(context: Context) = +fun makeSearchAndFillMetadata(context: Context, suggestionCount: Int, maxSuggestions: Int) = DatasetMetadata( - context.getString(R.string.oreo_autofill_search_in_store), null, + context.getString(R.string.oreo_autofill_search_in_store), R.drawable.ic_search_black_24dp, + suggestionCount, + maxSuggestions, ) -fun makeGenerateAndFillMetadata(context: Context) = +fun makeGenerateAndFillMetadata(context: Context, suggestionCount: Int, maxSuggestions: Int) = DatasetMetadata( - context.getString(R.string.oreo_autofill_generate_password), null, + context.getString(R.string.oreo_autofill_generate_password), R.drawable.ic_autofill_new_password, + suggestionCount, + maxSuggestions, ) -fun makeEmptyMetadata() = DatasetMetadata("PLACEHOLDER", "PLACEHOLDER", R.mipmap.ic_launcher) +fun makeEmptyMetadata() = DatasetMetadata("PLACEHOLDER", "PLACEHOLDER", R.mipmap.ic_launcher, 0, 0) fun makeWarningMetadata(context: Context) = DatasetMetadata( context.getString(R.string.oreo_autofill_warning_publisher_dataset_title), context.getString(R.string.oreo_autofill_warning_publisher_dataset_summary), R.drawable.ic_warning_red_24dp, + 0, + 0, ) -fun makeHeaderMetadata(title: String) = DatasetMetadata(title, null, 0) +fun makeHeaderMetadata(title: String) = + DatasetMetadata(title, null, R.drawable.ic_launcher_monochrome, 0, 0) diff --git a/app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt b/app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt index a73263bf83..cfc7d36656 100644 --- a/app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt +++ b/app/src/main/java/app/passwordstore/util/git/GitCommandExecutor.kt @@ -63,8 +63,11 @@ class GitCommandExecutor( // the previous status will eventually be used to avoid a commit if (nbChanges > 0) { withContext(dispatcherProvider.io()) { - val name = gitSettings.authorName.ifEmpty { "root" } - val email = gitSettings.authorEmail.ifEmpty { "localhost" } + val name = gitSettings.authorName.ifEmpty { "aps" } + val email = + gitSettings.authorEmail.ifEmpty { + "aps@" + android.os.Build.MODEL.replace(' ', '_') + } val identity = PersonIdent(name, email) command.setAuthor(identity).setCommitter(identity).call() } diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt index 4c3d24f000..70c20facf9 100644 --- a/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt +++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt @@ -39,7 +39,6 @@ import java.security.PublicKey import java.security.SecureRandom import java.security.spec.ECGenParameterSpec import java.security.spec.PKCS8EncodedKeySpec -import java.security.spec.X509EncodedKeySpec import javax.crypto.SecretKey import javax.crypto.SecretKeyFactory import logcat.asLog @@ -47,7 +46,6 @@ import logcat.logcat import net.schmizz.sshj.SSHClient import net.schmizz.sshj.common.Buffer import net.schmizz.sshj.common.KeyType -import net.schmizz.sshj.common.SSHRuntimeException import net.schmizz.sshj.common.SecurityUtils import net.schmizz.sshj.userauth.keyprovider.KeyProvider import net.schmizz.sshj.userauth.password.PasswordFinder @@ -69,45 +67,12 @@ private val KeyStore.sshPublicKey fun parseSshPublicKey(sshPublicKey: String): PublicKey? { val sshKeyParts = sshPublicKey.split("""\s+""".toRegex()) if (sshKeyParts.size < 2) return null - return normalizeForSshj( - Buffer.PlainBuffer(Base64.decode(sshKeyParts[1], Base64.NO_WRAP)).readPublicKey() - ) -} - -internal fun normalizeForSshj(key: PublicKey): PublicKey { - if (KeyType.fromKey(key) != KeyType.UNKNOWN) return key - - val encoded = - key.encoded - ?: throw SSHRuntimeException( - "Cannot normalize key: encoded form is null (${key.javaClass.name})" - ) - - val bcAlgorithm = - when (key.algorithm) { - "EdDSA", - "Ed25519", - "1.3.101.112" -> "Ed25519" - else -> key.algorithm - } - - return runCatching { - KeyFactory.getInstance(bcAlgorithm, BouncyCastleProvider.PROVIDER_NAME) - .generatePublic(X509EncodedKeySpec(encoded)) - } - .getOrElse { error -> - logcat("normalizeForSshj") { error.asLog() } - throw SSHRuntimeException( - "Cannot normalize ${key.javaClass.name} (algorithm=${key.algorithm}) for SSHJ", - error, - ) - } + return Buffer.PlainBuffer(Base64.decode(sshKeyParts[1], Base64.NO_WRAP)).readPublicKey() } fun toSshPublicKey(publicKey: PublicKey): String { - val normalizedKey = normalizeForSshj(publicKey) - val rawPublicKey = Buffer.PlainBuffer().putPublicKey(normalizedKey).compactData - val keyType = KeyType.fromKey(normalizedKey) + val rawPublicKey = Buffer.PlainBuffer().putPublicKey(publicKey).compactData + val keyType = KeyType.fromKey(publicKey) return "$keyType ${Base64.encodeToString(rawPublicKey, Base64.NO_WRAP)}" } @@ -271,7 +236,10 @@ object SshKey { context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message) ) - val userId = context.sharedPrefs.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL) ?: "nn@aps" + val userId = + context.sharedPrefs.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL, null).let { + if (it.isNullOrEmpty()) "aps@" + android.os.Build.MODEL.replace(' ', '_') else it + } // At this point, we are reasonably confident that we have actually been provided a private // key and delete the old key. @@ -308,7 +276,10 @@ object SshKey { generateKeyPair() } - val userId = context.sharedPrefs.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL) ?: "nn@aps" + val userId = + context.sharedPrefs.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL, null).let { + if (it.isNullOrEmpty()) "aps@" + android.os.Build.MODEL.replace(' ', '_') else it + } // Write public key in SSH format to .ssh_key.pub. publicKeyFile.writeText(toSshPublicKey(keyPair.public) + " " + userId) @@ -341,7 +312,10 @@ object SshKey { ) } - val userId = context.sharedPrefs.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL) ?: "nn@aps" + val userId = + context.sharedPrefs.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL, null).let { + if (it.isNullOrEmpty()) "aps@" + android.os.Build.MODEL.replace(' ', '_') else it + } publicKeyFile.writeText(toSshPublicKey(keyPair.public) + " " + userId) type = Type.KeystoreWrappedEd25519 @@ -382,9 +356,11 @@ object SshKey { val publicKey = androidKeystore.sshPublicKey ?: throw NullPointerException() val privateKey = androidKeystore.sshPrivateKey ?: throw NullPointerException() - // SSHJ 0.41.1 resets the BouncyCastle registration mode when its configured provider is - // cleared. Clear the provider first, then explicitly disable BC so Android Keystore-backed - // private keys are signed by the platform provider without requiring an exportable encoding. + /** + * SSHJ 0.41.1 resets the BouncyCastle registration mode when its configured provider is + * cleared. Clear the provider first, then explicitly disable BC so Android Keystore-backed + * private keys are signed by the platform provider without requiring an exportable encoding. + */ SecurityUtils.setSecurityProvider(null) SecurityUtils.setRegisterBouncyCastle(false) @@ -416,10 +392,6 @@ object SshKey { val keySpec = PKCS8EncodedKeySpec(privateKeyEncoded) val privateKey = keyFactory.generatePrivate(keySpec) - // ensure that BC carries out cryptographic operations - SecurityUtils.setRegisterBouncyCastle(true) - SecurityUtils.setSecurityProvider(BouncyCastleProvider.PROVIDER_NAME) - client.loadKeys(KeyPair(publicKey, privateKey)) } .getOrElse { error -> diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/SshjConfig.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshjConfig.kt index defdac4d28..aa7f2f584b 100644 --- a/app/src/main/java/app/passwordstore/util/git/sshj/SshjConfig.kt +++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshjConfig.kt @@ -18,6 +18,7 @@ import logcat.logcat import net.schmizz.keepalive.KeepAliveProvider import net.schmizz.sshj.ConfigImpl import net.schmizz.sshj.common.LoggerFactory +import net.schmizz.sshj.common.SecurityUtils import net.schmizz.sshj.transport.compression.NoneCompression import net.schmizz.sshj.transport.kex.Curve25519SHA256 import net.schmizz.sshj.transport.kex.Curve25519SHA256.FactoryLibSsh @@ -50,6 +51,9 @@ fun setUpBouncyCastleForSshj() { Security.insertProviderAt(BouncyCastleProvider(), bcIndex + 1) // method wants 1-based idx } + SecurityUtils.setRegisterBouncyCastle(true) + SecurityUtils.setSecurityProvider(BouncyCastleProvider.PROVIDER_NAME) + logcat("setUpBouncyCastleForSshj") { "JCE providers: ${Security.getProviders().joinToString { "${it.name} (${it.version})" }}" } diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt index 932183ee7c..9d0d006e76 100644 --- a/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt +++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt @@ -8,8 +8,11 @@ import android.util.Base64 import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.FragmentActivity import app.passwordstore.R +import app.passwordstore.databinding.DialogSelectableMessageBinding +import app.passwordstore.ui.git.base.BaseGitActivity import app.passwordstore.util.coroutines.DispatcherProvider import app.passwordstore.util.extensions.getString +import app.passwordstore.util.extensions.unsafeLazy import app.passwordstore.util.git.operation.CredentialFinder import app.passwordstore.util.settings.AuthMode import com.github.michaelbull.result.getOrElse @@ -90,14 +93,13 @@ private fun makeTofuHostKeyVerifier( if (!hostKeyFile.exists()) { return object : HostKeyVerifier { override fun verify(hostname: String?, port: Int, key: PublicKey?): Boolean { - val normalizedKey = key?.let { normalizeForSshj(it) } val digest = runCatching { SecurityUtils.getMessageDigest("SHA-256") } .getOrElse { e -> throw SSHRuntimeException(e) } - digest.update(PlainBuffer().putPublicKey(normalizedKey).compactData) + digest.update(PlainBuffer().putPublicKey(key).compactData) val digestData = digest.digest() - val keyType = KeyType.fromKey(normalizedKey) + val keyType = KeyType.fromKey(key) val hostKeyEntry = "SHA256:${Base64.encodeToString(digestData, Base64.NO_WRAP)}" val hostKeyEntryNoPadding = "SHA256:${Base64.encodeToString(digestData, Base64.NO_WRAP or Base64.NO_PADDING)}" @@ -114,22 +116,21 @@ private fun makeTofuHostKeyVerifier( R.string.git_server_hostkey_dialog_message, hostKeyEntryNoPadding, ) - val showHostKeyDialog = - MaterialAlertDialogBuilder(callingActivity) - .setCancelable(false) - .setTitle(title) - .setMessage(message) - .setPositiveButton(R.string.git_server_hostkey_dialog_connect) { _, _ -> - hostKeyFile.writeText(hostKeyEntry) - logcat(SshjSessionFactory::class.java.simpleName) { - "Trusting host key after approval by user: $hostKeyEntryNoPadding" - } - cont.resume(true) + MaterialAlertDialogBuilder(callingActivity) + .setCancelable(false) + .setTitle(title) + .setMessage(message) + .setPositiveButton(R.string.git_server_hostkey_dialog_connect) { _, _ -> + hostKeyFile.writeText(hostKeyEntry) + logcat(SshjSessionFactory::class.java.simpleName) { + "Trusting host key after approval by user: $hostKeyEntryNoPadding" } - .setNegativeButton(R.string.git_server_hostkey_dialog_abort) { _, _ -> - cont.resume(false) - } - .show() + cont.resume(true) + } + .setNegativeButton(R.string.git_server_hostkey_dialog_abort) { _, _ -> + cont.resume(false) + } + .show() } } return hostKeyTrusted @@ -145,7 +146,7 @@ private fun makeTofuHostKeyVerifier( val delegate = FingerprintVerifier.getInstance(hostKeyEntry) return object : HostKeyVerifier { override fun verify(hostname: String?, port: Int, key: PublicKey?): Boolean { - return delegate.verify(hostname, port, key?.let { normalizeForSshj(it) }) + return delegate.verify(hostname, port, key) } override fun findExistingAlgorithms(hostname: String?, port: Int): MutableList { @@ -171,21 +172,7 @@ private class SshjSession( private lateinit var ssh: SSHClient private var currentCommand: Session? = null - private val uri = - if (uri.host.contains('@')) { - // URIish's String constructor cannot handle '@' in the user part of the URI and the URL - // constructor can't be used since Java's URL does not recognize the ssh scheme. We thus - // need to patch everything up ourselves. - logcat { "Before fixup: user=${uri.user}, host=${uri.host}" } - val userPlusHost = "${uri.user}@${uri.host}" - val realUser = userPlusHost.substringBeforeLast('@') - val realHost = userPlusHost.substringAfterLast('@') - uri.setUser(realUser).setHost(realHost).also { - logcat { "After fixup: user=${it.user}, host=${it.host}" } - } - } else { - uri - } + private val uri = fixUri(uri) fun connect(): SshjSession { ssh = SSHClient(SshjConfig()) @@ -211,7 +198,31 @@ private class SshjSession( ssh.auth(username, pubkeyAuth) } } - return this + + val gitSettings = (callingActivity as BaseGitActivity).gitSettings + return if (!ssh.getUserAuth().getBanner().isBlank() && gitSettings.showSshServerResponse) { + runBlocking(dispatcherProvider.main()) { + suspendCoroutine { cont -> + val binding by unsafeLazy { + DialogSelectableMessageBinding.inflate(callingActivity.layoutInflater) + } + binding.message.setText(ssh.getUserAuth().getBanner()) + binding.doNotShowAgain.isChecked = !gitSettings.showSshServerResponse + + MaterialAlertDialogBuilder(callingActivity) + .setView(binding.root) + .setCancelable(false) + .setTitle(R.string.git_server_response_dialog_title) + .setOnDismissListener { + gitSettings.showSshServerResponse = !binding.doNotShowAgain.isChecked + } + .setPositiveButton(R.string.dialog_ok) { _, _ -> + cont.resume(this@SshjSession) + } + .show() + } + } + } else this } override fun exec(commandName: String?, timeout: Int): Process { @@ -261,3 +272,19 @@ private class SshjProcess(private val command: Session.Command, private val time override fun getInputStream(): InputStream = command.inputStream } + +fun fixUri(uri: URIish): URIish = + if (uri.host.contains('@')) { + // URIish's String constructor cannot handle '@' in the user part of the URI and the URL + // constructor can't be used since Java's URL does not recognize the ssh scheme. We thus + // need to patch everything up ourselves. + logcat("sshj.fixUri") { "Before fixup: user=${uri.user}, host=${uri.host}" } + val userPlusHost = "${uri.user}@${uri.host}" + val realUser = userPlusHost.substringBeforeLast('@') + val realHost = userPlusHost.substringAfterLast('@') + uri.setUser(realUser).setHost(realHost).also { + logcat("sshj.fixUri") { "After fixup: user=${it.user}, host=${it.host}" } + } + } else { + uri + } diff --git a/app/src/main/java/app/passwordstore/util/services/OreoAutofillService.kt b/app/src/main/java/app/passwordstore/util/services/OreoAutofillService.kt index 78df1329b3..84d70ea4eb 100644 --- a/app/src/main/java/app/passwordstore/util/services/OreoAutofillService.kt +++ b/app/src/main/java/app/passwordstore/util/services/OreoAutofillService.kt @@ -145,6 +145,7 @@ class OreoAutofillService : AutofillService() { this, credentials = Credentials(username, password, null), formOrigin = formOrigin, + clientState = clientState, ) ) } diff --git a/app/src/main/java/app/passwordstore/util/services/QuickSearchTileService.kt b/app/src/main/java/app/passwordstore/util/services/QuickSearchTileService.kt index e2f68e2565..cbfbb9aead 100644 --- a/app/src/main/java/app/passwordstore/util/services/QuickSearchTileService.kt +++ b/app/src/main/java/app/passwordstore/util/services/QuickSearchTileService.kt @@ -8,6 +8,10 @@ import android.service.quicksettings.TileService import app.passwordstore.ui.main.LaunchActivity class QuickSearchTileService : TileService() { + companion object { + private var requestCode = 1000000 + } + override fun onClick() { super.onClick() val intent = @@ -20,7 +24,7 @@ class QuickSearchTileService : TileService() { val pendingIntent = PendingIntent.getActivity( this, - 0, + ++requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) diff --git a/app/src/main/java/app/passwordstore/util/settings/DirectoryStructure.kt b/app/src/main/java/app/passwordstore/util/settings/DirectoryStructure.kt index d4e01d5495..8565c3ca82 100644 --- a/app/src/main/java/app/passwordstore/util/settings/DirectoryStructure.kt +++ b/app/src/main/java/app/passwordstore/util/settings/DirectoryStructure.kt @@ -4,17 +4,41 @@ */ package app.passwordstore.util.settings +import app.passwordstore.data.repo.PasswordRepository +import com.github.androidpasswordstore.sublimefuzzy.Fuzzy import java.io.File import java.nio.file.Paths enum class DirectoryStructure(val value: String) { - EncryptedUsername("encrypted_username"), - FileBased("file"), - DirectoryBased("directory"); + EncryptedUsername("encrypted_username"), // work/example.org.gpg + FileBased("file"), // work/example.org/jon@doe.org.gpg + DirectoryBased("directory"); // work/example.org/jon@doe.org/password.gpg /** - * Returns the username associated to [file], following the convention of the current - * [DirectoryStructure]. + * Returns path string with at most two parent dir levels for [file] [DirectoryStructure]. + * + * Examples: + * - example.org.gpg --> example.org + * - work/example.org.gpg --> work/example.org + * - work/example.org/john@doe.org.gpg --> work/example.org/john@doe.org + * - work/example.org/john@doe.org/password.gpg --> example.org/john@doe.org/password + */ + fun getPathStringWithAtMostTwoParentsFor(file: File): String = + listOf( + file.parentFile?.parentFile?.name?.let { it + "/" }, + file.parentFile?.name?.let { it + "/" }, + file.nameWithoutExtension, + ) + .filterNotNull() + .joinToString(separator = "") + + /** + * Returns the username associated with [file] + * + * tries to deduce the directory structure by comparing parent and grandparent directory names + * with the origin identifier, first using exact, then fuzzy matching + * + * without origin identifier given, current DirectoryStructure setting is used * * Examples: * - * --> null (EncryptedUsername) @@ -22,12 +46,38 @@ enum class DirectoryStructure(val value: String) { * - work/example.org/john@doe.org/password.gpg --> john@doe.org (DirectoryBased) * - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback) */ - fun getUsernameFor(file: File): String? = - when (this) { - EncryptedUsername -> null - FileBased -> file.nameWithoutExtension - DirectoryBased -> file.parentFile?.name ?: file.nameWithoutExtension + fun getUsernameFor(file: File, origin: String? = null): String? { + val passFile = + if (file.isAbsolute()) file.relativeTo(PasswordRepository.getRepositoryDirectory()) else file + + return if (origin != null) { + val grandparent = passFile.parentFile?.parentFile?.name ?: "" + val parent = passFile.parentFile?.name ?: "" + if ( + origin.equals(passFile.nameWithoutExtension, ignoreCase = true) + ) // EncryptedUsername, with no username provided + null + else if (origin.equals(parent, ignoreCase = true)) passFile.nameWithoutExtension // FileBased + else if (origin.equals(grandparent, ignoreCase = true)) + passFile.parentFile?.name // DirectoryBased + else if (fuzzyMatches(origin, parent)) passFile.nameWithoutExtension // FileBased + else if (fuzzyMatches(origin, grandparent)) passFile.parentFile?.name // DirectoryBased + else null // no-match + } else { + when (this) { // current directory structure setting as fallback for missing origin + EncryptedUsername -> null + FileBased -> passFile.nameWithoutExtension + DirectoryBased -> passFile.parentFile?.name ?: passFile.nameWithoutExtension + } } + } + + /** + * Fuzzy match in either direction; an empty folder name (e.g. the repository root) never matches. + */ + private fun fuzzyMatches(origin: String, folderName: String): Boolean = + folderName.isNotBlank() && + (Fuzzy.fuzzyMatchSimple(origin, folderName) || Fuzzy.fuzzyMatchSimple(folderName, origin)) /** * Returns the origin identifier associated to [file], following the convention of the current 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 a74d4e1793..31f483afd6 100644 --- a/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt +++ b/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt @@ -89,6 +89,12 @@ constructor( settings.edit { putString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL, value) } } + var showSshServerResponse + get() = settings.getBoolean(PreferenceKeys.GIT_SHOW_SSH_SERVER_RESPONSE, true) + set(value) { + settings.edit { putBoolean(PreferenceKeys.GIT_SHOW_SSH_SERVER_RESPONSE, value) } + } + var useMultiplexing get() = settings.getBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, true) set(value) { diff --git a/app/src/main/java/app/passwordstore/util/settings/Migrations.kt b/app/src/main/java/app/passwordstore/util/settings/Migrations.kt index 9dc4d6bc44..9f55a7b781 100644 --- a/app/src/main/java/app/passwordstore/util/settings/Migrations.kt +++ b/app/src/main/java/app/passwordstore/util/settings/Migrations.kt @@ -28,7 +28,6 @@ import logcat.logcat private const val TAG = "Migrations" fun runMigrations( - filesDirPath: String, sharedPrefs: SharedPreferences, gitSettings: GitSettings, persistentPassphrases: SharedPreferences, @@ -39,7 +38,6 @@ fun runMigrations( migrateToHideAll(sharedPrefs) migrateToClipboardHistory(sharedPrefs) migrateToDiceware(sharedPrefs) - removeExternalStorageProperties(sharedPrefs) removeCurrentBranchValue(sharedPrefs) removePersistentCredentialCache(sharedPrefs, gitSettings, context, runTest) if (!runTest) moveToPasswordGeneratorPrefs(sharedPrefs, context) @@ -269,20 +267,6 @@ private fun migrateToDiceware(sharedPrefs: SharedPreferences) { } } -private fun removeExternalStorageProperties(prefs: SharedPreferences) { - prefs.edit { - if (prefs.contains(PreferenceKeys.GIT_EXTERNAL)) { - if (prefs.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)) { - putBoolean(PreferenceKeys.GIT_EXTERNAL_MIGRATED, true) - } - remove(PreferenceKeys.GIT_EXTERNAL) - } - if (prefs.contains(PreferenceKeys.GIT_EXTERNAL_REPO)) { - remove(PreferenceKeys.GIT_EXTERNAL_REPO) - } - } -} - private fun createEncryptedPreferences(context: Context, fileName: String): SharedPreferences { val masterKeyAlias = MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() 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 51b9ae51b7..f29ca5ee15 100644 --- a/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt +++ b/app/src/main/java/app/passwordstore/util/settings/PreferenceKeys.kt @@ -9,6 +9,7 @@ object PreferenceKeys { const val APP_THEME = "app_theme" const val AUTOFILL_ENABLE = "autofill_enable" + const val AUTOFILL_ASK_TO_SAVE_PASSWORDS = "autofill_ask_to_save_passwords" const val BIOMETRIC_AUTH = "biometric_auth" const val BIOMETRIC_AUTH_2 = "biometric_auth_delete_soon_please" @Deprecated( @@ -27,13 +28,10 @@ object PreferenceKeys { const val GIT_CONFIG = "git_config" const val GIT_CONFIG_AUTHOR_EMAIL = "git_config_user_email" const val GIT_CONFIG_AUTHOR_NAME = "git_config_user_name" + const val GIT_SHOW_SSH_SERVER_RESPONSE = "git_show_ssh_server_response" - @Deprecated(message = "We're removing support for external storage") - const val GIT_EXTERNAL = "git_external" + const val PREF_SHARED_REPO = "pref_shared_repo" - @Deprecated(message = "We're removing support for external storage") - const val GIT_EXTERNAL_REPO = "git_external_repo" - const val GIT_EXTERNAL_MIGRATED = "git_external_migrated" const val GIT_REMOTE_AUTH = "git_remote_auth" const val GIT_REMOTE_KEY_TYPE = "git_remote_key_type" @@ -60,6 +58,8 @@ object PreferenceKeys { const val DIRECTORY_STRUCTURE = "oreo_autofill_directory_structure" const val AUTOFILL_SAVE_DIRECTORY = "oreo_autofill_save_directory" const val STRICT_DOMAIN_SEARCH = "oreo_autofill_strict_domain_search" + const val DOMAIN_QUICK_SELECT_ADD = "oreo_autofill_domain_quick_select_add" + const val DOMAIN_QUICK_SELECT_REMOVE = "oreo_autofill_domain_quick_select_remove" const val PREF_KEY_PWGEN_TYPE = "pref_key_pwgen_type" const val REPOSITORY_INITIALIZED = "repository_initialized" const val REPO_CHANGED = "repo_changed" diff --git a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt index 3e8b2e5761..8853635587 100644 --- a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt +++ b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt @@ -327,7 +327,9 @@ constructor( ) { require(newDirectory.isInsideRepository()) { "Trying to access target outside the repository" } if (!newDirectory.exists()) return - require(newDirectory.isDirectory) { "Can only navigate to a directory" } + require(newDirectory.isDirectory) { + "Cannot navigate into non-directory ${newDirectory}" + } if (pushPreviousLocation) { navigationStack.addFirst(NavigationStackEntry(_currentDir.value, recyclerViewState)) } @@ -407,7 +409,7 @@ constructor( // path separator. val suffix = """(?:\.gpg|/)""" // Match any relative path with a component that is a subdomain of the filter. - return Regex(prefix + subdomain + suffix) + return Regex(prefix + subdomain + suffix, RegexOption.IGNORE_CASE) } } } diff --git a/app/src/main/res/layout/activity_git_clone.xml b/app/src/main/res/layout/activity_git_clone.xml index c31888233b..54f4c53566 100644 --- a/app/src/main/res/layout/activity_git_clone.xml +++ b/app/src/main/res/layout/activity_git_clone.xml @@ -71,6 +71,16 @@ + + + app:layout_constraintTop_toBottomOf="@id/show_server_response" /> diff --git a/app/src/main/res/layout/activity_oreo_autofill_filter.xml b/app/src/main/res/layout/activity_oreo_autofill_filter.xml index e11dcf5217..53d506b04f 100644 --- a/app/src/main/res/layout/activity_oreo_autofill_filter.xml +++ b/app/src/main/res/layout/activity_oreo_autofill_filter.xml @@ -38,15 +38,16 @@ + + @@ -88,45 +115,4 @@ - - - - - - diff --git a/app/src/main/res/layout/dialog_selectable_message.xml b/app/src/main/res/layout/dialog_selectable_message.xml new file mode 100644 index 0000000000..050a98cbae --- /dev/null +++ b/app/src/main/res/layout/dialog_selectable_message.xml @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/app/src/main/res/layout/fragment_repo_visibility.xml b/app/src/main/res/layout/fragment_repo_visibility.xml new file mode 100644 index 0000000000..051edbe69b --- /dev/null +++ b/app/src/main/res/layout/fragment_repo_visibility.xml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/oreo_autofill_dataset.xml b/app/src/main/res/layout/oreo_autofill_dataset.xml index acb835e840..5b40a28919 100644 --- a/app/src/main/res/layout/oreo_autofill_dataset.xml +++ b/app/src/main/res/layout/oreo_autofill_dataset.xml @@ -38,7 +38,7 @@ android:id="@+id/title" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:ellipsize="middle" + android:ellipsize="start" android:singleLine="true" android:textStyle="bold" tools:text="example.org" /> @@ -47,7 +47,7 @@ android:id="@+id/summary" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:ellipsize="middle" + android:ellipsize="start" android:singleLine="true" tools:text="john@doe.org" /> diff --git a/app/src/main/res/layout/oreo_autofill_filter_row.xml b/app/src/main/res/layout/oreo_autofill_filter_row.xml index 13e6f73c3a..29c723dbc8 100644 --- a/app/src/main/res/layout/oreo_autofill_filter_row.xml +++ b/app/src/main/res/layout/oreo_autofill_filter_row.xml @@ -31,7 +31,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="8dp" - android:ellipsize="middle" + android:ellipsize="start" android:singleLine="true" android:textSize="12sp" app:layout_constraintBottom_toTopOf="@id/subtitle" @@ -45,7 +45,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="8dp" - android:ellipsize="middle" + android:ellipsize="start" android:singleLine="true" android:textSize="14sp" app:layout_constraintBottom_toBottomOf="parent" diff --git a/app/src/main/res/layout/password_creation_activity.xml b/app/src/main/res/layout/password_creation_activity.xml index e223747341..529d1d68ab 100644 --- a/app/src/main/res/layout/password_creation_activity.xml +++ b/app/src/main/res/layout/password_creation_activity.xml @@ -33,7 +33,6 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:imeOptions="actionNext" - android:nextFocusForward="@id/password" android:focusable="false" android:clickable="true" tools:text="CATEGORY HERE" /> @@ -50,17 +49,14 @@ app:layout_constraintTop_toBottomOf="@id/directory_input_layout"> + android:inputType="textNoSuggestions"/> + + + + + + android:text="@string/crypto_filename_default"/> + app:layout_constraintTop_toBottomOf="@id/filename_input_layout"> + app:layout_constraintTop_toBottomOf="@id/otp_import_button" /> + diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index ee460b7528..aa54278997 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -14,7 +14,6 @@ El directori de destinació ja existeix. La versió actual només admet un sol magatzem. Voleu suprimir el directori actual del magatzem de contrasenyes?\n(%1$s) Suprimeix el directori Cancel·lar - Informació del repositori Configuració de Git Registre de commits Cloneu o creeu un nou repositori absn d\'intentar afegir una contrasenya o sincronitzar. @@ -56,7 +55,6 @@ Nom d\'usuari Contrasenya Contingut extra - Xifra el nom d\'usuari Selecciona Desa Desa i copia @@ -68,7 +66,6 @@ Copia la contrasenya Comparteix com a text Repositori - Edita la configuració del servidor Git Configuració local de Git i utilitats Importa clau SSH Genera un parell de claus SSH @@ -168,7 +165,6 @@ Icona de l\'aplicació Selecciona l\'entrada a omplir Cerca resistent al Phishing - Coincideix amb %1$s Neteja les concordances Sense resultats. Entrada de la cerca @@ -176,7 +172,6 @@ Aquesta aplicació no està actualment suportada Les contrasenyes no coincideixen Crea una entrada - Nombre màxim de coincidències (%1$d) assolit; neteja les concordances abans d\'afegir noves. L\'editor d\'aquesta aplicació ha canviat des que hi vau associar per primera vegada una entrada del magatzem de contrasenyes: És possible que l\'aplicació instal·lada actualment estigui intentant robar les vostres credencials fent-se passar per una aplicació de confiança. Proveu de desinstal·lar i tornar a instal·lar l\'aplicació des d\'una font de confiança, com ara Play Store, Amazon Appstore, F-Droid o la botiga del fabricant del telèfon. Instal·lat: %1$s diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 6908e15e42..4eb6b6fa7e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -17,7 +17,7 @@ Zielordner existiert bereits. Die aktuelle Version unterstützt nur eine einzige Datenquelle. Möchten Sie die aktuelle Datenquelle löschen?\n(%1$s) Ordner löschen Abbruch - Repository-Informationen + Remote-Repository Git-Konfiguration Commit-Log @@ -45,8 +45,10 @@ In die Zwischenablage kopiert. + Bitte setzen Sie einen Namen. Bitte setzen Sie einen Dateinamen. Sie müssen mindestens ein Passwort oder Zusatzangaben eintragen. + Bitte setzen Sie einen Benutzernamen. Es ist ein Fehler während der Git-Operation aufgetreten @@ -64,8 +66,8 @@ Repository-URL - Authentifizierungsmethode + Serverbanner anzeigen (wenn vorhanden) Benutzername @@ -76,9 +78,10 @@ Name Benutzername + Dateiname Passwort Zusätzliche Angaben - Benutzername verschlüsseln + Benutzernamen einfügen Auswählen Speichern Speichern und kopieren @@ -94,7 +97,6 @@ Repository - Git-Server-Einstellungen Authentifizierungsschlüssel Lokale Git-Konfiguration & Dienstprogramme PGP-Schlüssel verwenden @@ -129,10 +131,13 @@ Zuletzt verwendet Automatisch ausfüllen Autofill aktivieren + Fragen, ob Passwörter gespeichert werden sollen Verschiedenes Zwischenablagenverlauf umgehen Zwischenablagenverlauf (Samsung-Geräte, Gboard) mit Zufallszahlen überschreiben, um ggf. bestehende Passwörter zu entfernen. - Lösche das lokale (versteckte) Repository. + Löscht das versteckte, app-interne Repository. + Externes Repository + Das Passwort-Repository befindet sich in einem freigegebenen Ordner, auf den auch andere Apps zugreifen können. Passwörter exportieren Exportiert das Password-Repository in ein externes Verzeichnis. Passwörter werden exportiert @@ -209,6 +214,7 @@ Ja Nein Abbruch + Weiter Synchronisiere Repository Git Pull Git Push @@ -227,15 +233,14 @@ Eintrag zum Ausfüllen auswählen Phishing-resistente Suche - Übereinstimmung mit %1$s - Bestehende Übereinstimmungen löschen + Schnellauswahltaste für %1$s hinzufügen + Schnellauswahltasten entfernen Keine Ergebnisse Eintrag suchen Speichern aufgrund eines internen Fehlers fehlgeschlagen. Diese App wird derzeit nicht unterstützt. Die Passwörter stimmen nicht überein. Passwort erstellen… - Maximale Anzahl an Übereinstimmungen (%1$d) erreicht; lösche Übereinstimmungen, bevor neue hinzugefügt werden. Der Hersteller dieser App hat sich geändert, seit Sie einen Eintrag zum Passwort-Speicher mit ihr verknüpft haben: Die derzeit installierte App versucht, Ihre Anmeldeinformationen zu stehlen, indem sie vorgibt, eine vertrauenswürdige App zu sein.\n\nVersuchen Sie, die App zu deinstallieren und installieren Sie sie erneut aus einer vertrauenswürdigen Quelle wie dem Play Store, Amazon Appstore, F-Droid oder dem Shop Ihres Telefonherstellers. Installiert: %1$s @@ -297,6 +302,8 @@ %s\n\nÜberprüfen Sie die Korrektheit des Fingerabdrucks vor dem Verbinden. Verbinden Abbruch + Serverbanner + Nicht mehr anzeigen Konfiguration erfolgreich gespeichert Die angegebene Repository-URL ist ungültig. Bitte geben Sie den HTTPS-Benutzernamen in der Form https://username@example.com/… an. @@ -314,7 +321,7 @@ Neuen Schlüssel erstellen Debug-Ausgaben aktivieren (Erfordert Neustart der App). Debug-Logging - Kann die Autofill-Funktion den Benutzernamen nicht aus dem Inhalt einer Passwortdatei abrufen, wird der hier angegebene Wert verwendet. + Kann die Autofill-Funktion den Benutzernamen nicht aus dem Inhalt oder dem Kontext einer Passwortdatei ermitteln, wird der hier angegebene Wert verwendet. Standard-Benutzername Passwort Benutzerdefinierte Domains @@ -339,13 +346,16 @@ .gpg-id wurde gefunden, ist jedoch leer. .gpg-id wurde nicht gefunden. .gpg-id wurde gefunden, enthält jedoch eine kurze Hex-ID, die nicht unterstützt wird. - Dateiname darf kein \'/\' enthalten; Verzeichnis oben setzen. + Der Dateiname darf kein \'/\' enthalten; Verzeichnis oben setzen. + Der Name darf kein \'/\' enthalten; Verzeichnis oben setzen. + Der Benutzername darf kein \'/\' enthalten. Ordner PGP-Schlüssel für Ordner festlegen - Entschlüsseln fehlgeschlagen. Kein geeigneter geheimer Schlüssel gefunden. + Entschlüsseln fehlgeschlagen. Kein geeigneter geheimer Schlüssel gefunden Unbekannter Fehler beim Entschlüsseln + Automatisches Ausfüllen mit Passkey-Daten nicht möglich Unbekannter Fehler @@ -356,6 +366,11 @@ Push wurde von Remote abgelehnt, Grund: %1$s Remote hat den Push abgelehnt. Prüfen Sie die Variable receive.denyNonFastForwards in der Konfigurationsdatei des Ziel-Repositorys. Unbekannter Host: %1$s + Server nicht erreichbar + Falls sich der Server %1$s im lokalen Netz befindet, muss die Berechtigung „Geräte in der Nähe“ erteilt werden. + Überspringen + Berechtigung erteilen + Git-URL ist nicht gesetzt. Git-Operation wird ausgeführt… Es gab einen Konflikt während des Rebase-Prozesses. Ihr lokaler Branch %1$s wurde auf einen anderen Branch namens %2$s gepusht.\nNutzen Sie diesen Branch, um den Konflikt auf dem Server zu beheben Das Repository befindet sich nicht im Rebase-Prozess; es ist nicht nötig, den Zustand auf einen weiteren Branch zu pushen. @@ -366,15 +381,21 @@ Ein Ordner mit diesem Namen existiert bereits. - Los geht\'s - Wählen Sie einen \nRepository-Typ. - Wählen Sie aus, ob Sie ein lokales Repository anlegen, oder ein Remote-Repository klonen möchten. + Los geht’s + Repository-Typ + Wählen Sie aus, ob Sie ein Remote-Repository klonen oder ein lokales Repository anlegen möchten. Remote-Repository klonen Lokales Repository anlegen PGP-Schlüssel wählen Wählen Sie einen oder mehrere PGP-Schlüssel, mit denen der Store initialisiert werden soll. Schlüssel auswählen Schlüssel auswählen + Sichtbarkeit des Repositorys + Legen Sie fest, ob das Repository in das interne Verzeichnis der App (empfohlen) oder in einen freigegebenen Ordner geklont werden soll, auf den auch andere Apps zugreifen können. + Legen Sie fest, ob das Repository innerhalb des internen Verzeichnisses der App (empfohlen) oder in einem freigegebenen Ordner angelegt werden soll, auf den auch andere Apps zugreifen können. + app-intern (versteckt) + freigegebener Ordner + Möglicherweise inkorrekte URL diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index abdcd125a5..e9cb3869a3 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -18,7 +18,6 @@ El directorio de destino ya existe. La versión actual solo admite un único almacén. ¿Desea eliminar el directorio actual del almacén de contraseñas?\n(%1$s) Eliminar directorio Cancelar - Información del repositorio Configuración de Git Registro de commits @@ -78,7 +77,6 @@ Usuario Contraseña Contenido extra - Cifrar Username Seleccionar Guardar Guardar y copiar @@ -94,7 +92,6 @@ Repositorio - Editar la configuración del servidor Git Configuración y utilidades locales de Git Importar clave SSH Generar claves SSH @@ -207,15 +204,13 @@ Seleccione la entrada para completar Búsqueda resistente al phishing - Match with %1$s Borrar coincidencias existentes Sin resultados. - Entrada de búsqueda + Búsqueda de entrada Error al guardar debido a un error interno Esta aplicación no es compatible Las contraseñas no coinciden Crear entrada - Número máximo de coincidencias (%1$d) alcanzadas; Borra las coincidencias antes de agregar otras nuevas. El editor de esta aplicación ha cambiado desde que asoció por primera vez una entrada de almacén de contraseñas con ella: Es posible que la aplicación instalada actualmente esté intentando robar sus credenciales haciéndose pasar por una aplicación de confianza.\n\nIntente desinstalar y volver a instalar la aplicación desde una fuente confiable, como Play Store, Amazon Appstore, F-Droid o la tienda del fabricante de su teléfono. Instalado: %1$s diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index fedee3d4ef..d335730dcb 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -14,7 +14,6 @@ Répertoire cible déjà existant. La version actuelle ne supporte qu\'un seul gestionnaire. Voulez vous supprimer le répertoire actual du gestionnaire de mots de passe?\n(%1$s) Supprimer le répertoire Annuler - Information sur le dépôt Git Historique des commits Clonez ou créez un dépôt suivant avant d\'essayer d\'ajouter un mot de passe ou d\'effectuer une opération de synchronisation. @@ -60,7 +59,6 @@ Nom Mot de passe Extra - Chiffrer le nom d\'utilisateur Sélectionner Sauvegarder Enregistrer et copier @@ -73,7 +71,6 @@ Partager en clair Dépôt - Modifier les paramètres du serveur Git Configuration locale de Git & utilitaires Importer une clef SSH Générer une paire de clefs SSH @@ -164,7 +161,6 @@ Sélectionnez l\'entrée à remplir Recherche protégée contre le hameçonnage - Apparier avec %1$s Effacer l’appairage actuel Aucun résultat. Rechercher l\'entrée @@ -172,7 +168,6 @@ Cette application n\'est actuellement pas prise en charge Les mots de passe ne coïncident pas Créer une entrée - Nombre maximum de correspondances (%1$d) atteint; effacer les correspondances avant d\'en ajouter de nouvelles. L\'éditeur de cette application a changé depuis que vous avez appairé un mot de passe avec celle-ci: L\'application actuellement installée peut essayer de voler vos identifiants en faisant semblant d\'être une application de confiance.\n\nEssayez de désinstaller et de réinstaller l\'application à partir d\'une source fiable, comme le Play Store, l\'AppStore d\'Amazon, le F-Droid ou la boutique du fabricant de votre téléphone. Installé : %1$s diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 8abfe32de4..cde9fbd554 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -14,7 +14,6 @@ O directorio elexido xa existe. A versión actual só admite unha almacenaxe. Desexas eliminar o directorio actual de password store?\n(%1$s) Eliminar directorio Cancelar - Información do repositorio Rexistro de cambios Clona ou crea un novo repositorio aquí antes de intentar engadir un contrasinal ou realizar unha sincronización. @@ -61,7 +60,6 @@ Nome Contrasinal Contido extra - Cifrar identificador Seleccionar Gardar Gardar e copiar @@ -74,7 +72,6 @@ Compartir como texto plano Repositorio - Editar axustes do servidor git Configuración e Utilidades locais Git Importar chave SSH Crear par de chaves SSH @@ -171,7 +168,6 @@ Elixe o elemento a completar Busca resistente ao phishing - Coincidencia con %1$s Limpar coincidencias existentes Sen resultados. Buscar elemento @@ -179,7 +175,6 @@ Esta app non está soportada actualmente Non concordan os contrasinais Crear elemento - Acadouse o máximo número de coincidencias (%1$d); eliminar as coincidencias antes de engadir máis. A autoría desta app cambiou desde que rexistraches a entrada Password Store con ela: A aplicación actualmente instalada podería intentar roubar as túas credenciais pretendendo ser unha app de confianza.\n\nIntenta desinstalar e reinstalar a app desde unha fonte de confianza, como a Play Store, Amazon Appstore, F-Droid ou a tenda do fabricante do teu móbil. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 48d8ddddc9..6618c915ec 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -14,7 +14,6 @@ La directory di destinazione esiste già. La versione corrente supporta solo un negozio singolo. Vuoi eliminare la directory dell\'archivio delle password corrente?\n(%1$s) Elimina directory Annulla - Informazioni della repository Conferma registro Sei pregato di clonare o creare una nuova repository sotto prima di provare ad aggiungere una password o eseguire qualsiasi operazione di sincronizzazione. @@ -60,7 +59,6 @@ Nome Password Contenuto extra - Crittografa nome utente Seleziona Salva Salva e Copia @@ -73,7 +71,6 @@ Condividi come testo semplice Repository - Modifica impostazioni del server di Git Configurazione & utilità della configurazione di Git Importa chiave SSH Genera coppia di chiavi SSH @@ -159,7 +156,6 @@ Seleziona la voce da compilare Ricerca resistente al phishing - Abbina con %1$s Cancella abbinamenti esistenti Nessun risultato. Cerca la voce @@ -167,7 +163,6 @@ Quest\'app non è attualmente supportata Le password non corrispondono Crea voce - Numero massimo di abbinamenti (%1$d) raggiunto; cancella le corrispondenze prima di aggiungerne di nuove. L\'editore di quest\'app è cambiato da quando hai associato per la prima volta una voce di Password Store con essa: L\'app correntemente installata potrebbe provare a rubare le tue credenziali pretendendo di essere un\'app affidabile.\n\nProva a disinstallare e reinstallare l\'app da una fonte affidabile, come Play Store, Amazon, Appstore, F-Droid, o il negozio del produttore del tuo telefono. Installata: %1$s diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index 7733b121ff..cf5042293a 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -13,7 +13,6 @@ 해당 디렉토리는 이미 사용 중입니다. 아직은 하나의 저장소만 지원합니다. 현재 비밀번호 저장소 디렉토리를 삭제하시겠습니까?\n(%1$s) 디렉토리 삭제 삭제 취소 - 레포지토리 정보 커밋 로그 비밀번호를 추가하거나 동기화 하기 전에 레포지토리를 클론 하거나 새로 생성해 주세요. @@ -59,7 +58,6 @@ 이름 비밀번호 추가 내용 - 암호화 계정 이름 선택 저장 저장 및 복사 @@ -72,7 +70,6 @@ 텍스트로 공유 저장소 - Git 서버 설정 수정 로컬 Git 설정 & 툴 SSH 키 가져오기 SSH 키쌍 생성 @@ -169,7 +166,6 @@ 입력할 항목 선택 피싱 방지 검색 - %1$s와 일치 기존 일치 항목 지우기 검색 결과가 없습니다. 검색 항목 @@ -177,7 +173,6 @@ 이 앱은 현재 지원하지 않습니다 암호가 일치하지 않습니다 항목 생성 - 최대 일치 수(%1$d) 에 도달했습니다. 새 일치 항목을 추가하기 전에 일치 항목을 지웁니다. 비밀번호 저장소 항목을 처음 연결한 이후 이 앱의 게시자가 변경되었습니다: 현재 설치된 앱이 신뢰할 수 있는 앱인 것처럼 가장하여 자격 증명을 도용하려고 시도하고 있을 수 있습니다.\n\n앱을 삭제한 후 Play 스토어, Amazon 앱스토어, F-Droid 또는 휴대폰 제조업체 스토어와 같은 신뢰할 수 있는 출처에서 다시 설치해 보시기 바랍니다. 설치됨: %1$s diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 85cf3d1b88..eed232b014 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -17,7 +17,6 @@ Katalog docelowy już istnieje. Bieżąca wersja obsługuje tylko jedno repozytorium. Czy chcesz usunąć aktualny katalog z repozytorium haseł?\n(%1$s) Usuń katalog Anuluj - Konfiguracja repozytorium Dziennik commitów Zanim dodasz hasło lub rozpoczniesz synchronizację, stwórz albo sklonuj poniżej nowe repozytorium. @@ -59,7 +58,6 @@ Nazwa Hasło Treść dodatkowa - Szyfruj nazwę użytkownika Wybierz Zapisz Zapisz i skopiuj @@ -72,7 +70,6 @@ Udostępnij bez szyfrowania Repozytorium - Edytuj ustawienia serwera Git Konfiguracja lokalnego repo Git & narzędzia Importuj klucz SSH Wygeneruj klucz SSH @@ -175,7 +172,6 @@ Ta aplikacja nie jest obecnie obsługiwana Hasła się nie zgadzają Utwórz wpis - Osiągnięto maksymalną liczbę dopasowań (%1$d); wyczyść dopasowania przed dodaniem nowych. Wydawca tej aplikacji zmienił się od momentu wprowadzenia pierwszego wpisu z hasłem: Obecnie zainstalowana aplikacja może próbować ukraść twoje dane do logowania, udając, że jest zaufaną aplikacją.\n\nSpróbuj odinstalować i ponownie zainstalować aplikację z zaufanego źródła, takiego jak sklep Play Store, Amazon Appstore, F-Droid lub sklep producenta telefonu. Zainstalowano: %1$s diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index c2da2db0f3..ce988fbef6 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -14,7 +14,6 @@ O diretório de destino já existe. A versão atual suporta apenas uma única armazenagem. Você quer excluir o diretório atual de armazenamento de senhas?\n(%1$s) Excluir diretório Cancelar - Informações do repositório Log de Commits Por favor, clone ou crie um novo repositório abaixo antes de tentar adicionar uma senha ou executar qualquer operação de sincronização. @@ -61,7 +60,6 @@ Nome Senha Conteúdo extra - Criptografar nome de usuário Selecionar Salvar Salvar e Copiar @@ -74,7 +72,6 @@ Compartilhar como texto Repositório - Editar configurações do servidor Git Configuração local do Git & utilitários Importar chave SSH Gerar um par de chave SSH @@ -170,7 +167,6 @@ Selecione a entrada para preencher Busca resistente a phishing - Combinar com %1$s Limpar correspondências existentes Sem resultados. Pesquisar no armazenamento… @@ -178,7 +174,6 @@ Este app não é suportado no momento As senhas não coincidem Gerar senha… - Número máximo de correspondências (%1$d) atingidas; limpar correspondências antes de adicionar novas. O editor deste aplicativo mudou desde a primeira vez que você associou uma entrada no Password Store: O aplicativo atualmente instalado pode estar tentando roubar suas credenciais fingindo ser um aplicativo confiável.\n\nTente desinstalar e reinstalar o aplicativo de uma fonte confiável, como a Play Store, Amazon Appstore, F-Droid ou a loja do fabricante do seu telefone. Instalado: %1$s diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 41f45bf3c3..06ec54817f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -16,7 +16,6 @@ Директория уже существует. Текущая версия поддерживает только одно хранилище. Хотите удалить существующую директорию\n(%1$s) Удалить директорию Отменить - Информация о репозитории Журнал изменений Пожалуйста, клонируйте или создайте новый репозиторий перед тем, как добавлять пароль или выполнять синхронизацию. @@ -65,7 +64,6 @@ название Пароль Дополнительно - Зашифровать имя пользователя Выбрать Сохранить Сохранить и скопировать @@ -78,7 +76,6 @@ Поделиться в открытом виде Репозиторий - Изменить настройки сервера Git Конфигурация локального Git Импортировать SSH ключ Сгенерировать пару SSH ключей @@ -168,7 +165,6 @@ Выберите запись для заполнения Устойчивый к фишингу поиск - Совпадает с %1$s Очистить существующие совпадения Не найдено. Искать в хранилище @@ -176,7 +172,6 @@ Это приложение в настоящее время не поддерживается Пароли не совпадают Сгенерировать пароль - Достигнуто максимальное количество совпадений (%1$d); очистите совпадения перед тем как добавите новые. Издатель приложения изменился с тех пор как вы первый раз связали с ним запись хранилища паролей: Установленное приложение может попытаться украсть ваши учетные данные, выдавая себя за доверенное приложение\n\nПопробуйте удалить или переустановить  приложение из доверенного источника, такого как Play Store, Amazon Appstore, F-Droid или магазин приложений производителя вашего смартфона. Установлено: %1$s diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 1bf6b3e2e0..3b70a6c88c 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -18,7 +18,6 @@ Hedef dizin zaten mevcut. Mevcut sürüm yalnızca tek bir depoyu desteklemektedir. Mevcut şifre deposu dizinini silmek istiyor musunuz?\n(%1$s) Dizini sil İptal - Depo bilgileri Git yapılandırması Commit günlüğü @@ -77,7 +76,6 @@ Kullanıcı adı Şifre Ekstra içerik - Kullanıcı adını şifrele Seç Kaydet Kaydet ve Kopyala @@ -93,7 +91,6 @@ Depo - Git sunucu ayarlarını düzenle Yerel Git yapılandırması ve araçlar SSH anahtarını içe aktar SSH anahtar çifti oluştur @@ -203,7 +200,6 @@ Doldurulacak girdiyi seç Kimlik avına dayanıklı arama - %1$s ile eşleştir Mevcut eşleşmeleri temizle Sonuç yok. Girdi ara @@ -211,7 +207,6 @@ Bu uygulama şu anda desteklenmiyor Şifreler eşleşmiyor Girdi oluştur - Maksimum eşleşme sayısına (%1$d) ulaşıldı; yeni eşleşmeler eklemeden önce mevcutları temizleyin. Bu uygulamanın yayıncısı, bir Password Store girdisini onunla ilk ilişkilendirdiğinizden beri değişti: Şu anda yüklü olan uygulama, güvenilir bir uygulama gibi davranarak kimlik bilgilerinizi çalmaya çalışıyor olabilir.\n\nPlay Store, Amazon Appstore, F-Droid veya telefon üreticinizin mağazası gibi güvenilir bir kaynaktan uygulamayı kaldırıp yeniden yüklemeyi deneyin. Yüklenme tarihi: %1$s diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 3d929f49b3..e7fbceac74 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -14,7 +14,6 @@ (%1$s) 删除目录 取消 - 存储库信息 提交日志 在尝试添加密码或运行任何同步操作之前,请在下面同步或创建新存储库 @@ -60,7 +59,6 @@ 名称 密码 附加内容 - 加密用户名 选择 保存 保存并复制 @@ -73,7 +71,6 @@ 用明文分享 仓库 - 编辑 Git 服务器设置 本地Git配置 & utilities 导入 SSH 密钥 生成SSH密钥 @@ -173,7 +170,6 @@ 选择要填写的条目 防钓鱼搜索 - 与%1$s匹配 清除现有匹配 无返回值 在存储库搜索… @@ -181,7 +177,6 @@ 目前不支持此应用 密码不匹配 生成密码… - 已达到最大匹配数(%1$d) 在添加新匹配之前清除匹配 自从您首次将密码存储条目与其关联后,此应用的发布者已更改: 当前安装的应用程序可能试图通过伪装成受信任的应用程序来窃取您的凭据\" 尝试从受信任的来源卸载并重新安装应用程序,例如Play商店,亚马逊应用商店,F-Droid,或您的手机制造商的商店\" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8658484d2f..09d641a9af 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -18,7 +18,7 @@ Target directory already exists. Current version only supports a single store. Do you want to delete the current password store directory?\n(%1$s) Delete directory Cancel - Repository information + Remote repository Git configuration Commit log @@ -46,8 +46,10 @@ Copied to clipboard. + Please provide a name. Please provide a file name. You need to fill in at least a password or some extra content. + Please provide a username. An error occurred during a Git operation @@ -65,8 +67,8 @@ Repository URL - Authentication method + Show server banner (if any) Username @@ -77,9 +79,11 @@ Name Username + File name + password Password Extra content - Encrypt username + Insert username Select Save Save and Copy @@ -95,7 +99,6 @@ Repository - Git server settings Authentication key Local Git config & utilities Use PGP key @@ -130,6 +133,7 @@ Recently used Autofill Enable Autofill + Ask to save passwords Misc Passkeys Zero signature counter (recommended for synced repos) @@ -138,7 +142,9 @@ Automatically sync passkeys with Git remote when repository is configured. Scramble clipboard history Flood clipboard history (Samsung devices, Gboard) with random numbers, wiping out any passwords. - Deletes local (hidden) repository. + Deletes the hidden, app-internal repository. + External repository + The password repository is located in a shared folder that other apps can also access. Export repository Exports the password repository to an external directory. Exporting passwords @@ -221,6 +227,7 @@ Yes No Cancel + Continue Synchronise repository Pull from remote Push to remote @@ -239,15 +246,14 @@ Select entry to fill into Phishing-resistant search - Match with %1$s - Clear existing matches + Add quick select button for %1$s + Remove quick select buttons No results. Search entry Save failed due to an internal error. This app is currently not supported Passwords don\'t match Create entry - Maximum number of matches (%1$d) reached; clear matches before adding new ones. This app\'s publisher has changed since you first associated a Password Store entry with it: The currently installed app may be trying to steal your credentials by pretending to be a trusted app.\n\nTry to uninstall and reinstall the app from a trusted source, such as the Play Store, Amazon Appstore, F-Droid, or your phone manufacturer\'s store. Installed: %1$s @@ -312,6 +318,8 @@ %s\n\nVerify the correctness of the fingerprint before connecting. Connect Abort + Server banner + Do not show again Successfully saved configuration The provided repository URL is not valid. Please specify the HTTPS username in the form https://username@example.com/… @@ -329,7 +337,7 @@ Create new key Enable debug logging (requires app restart). Debug logging - If the autofill function cannot retrieve the username from the contents of a password file, the value specified here will be used. + If the autofill function cannot look up the username from the password file\'s content or context, the value specified here will be used. Default username Password Custom domains @@ -358,13 +366,16 @@ Found .gpg-id, but it is empty. No .gpg-id could be found. Found .gpg-id, but it contains a short hex ID, which is not supported. - File name must not contain \'/\', set directory above. + The file name must not contain \'/\', set directory above. + The name must not contain \'/\', set directory above. + The username must not contain \'/\'. Directory Set PGP key for directory Decryption failed. No suitable decryption key found Unknown decryption error + Autofill with passkey data not possible Unknown error @@ -375,6 +386,11 @@ Push was rejected by remote, reason: %1$s Remote rejected non-fast-forward push. Check receive.denyNonFastForwards variable in config file of destination repository. Unknown host: %1$s + Server unreachable + If server %1$s is located on the local network, the “Nearby Devices” permission must be granted. + Skip + Grant permission + Git URL is not set. Running git operation… There was a conflict when trying to rebase. Your local %1$s branch was pushed to another branch named %2$s\n.Use this branch to resolve conflict on your computer. The repository is not rebasing, no need to push to another branch. @@ -385,15 +401,21 @@ A folder by that name already exists - Let\'s Go - Select \nRepository Type - Select if you want to create a local repo or clone a remote repo. - Clone Remote Repo - Create Local Repo + Let’s Go + Repository type + Choose whether you want to clone a remote repo or create a local repo. + Clone remote repo + Create local repo Select\nPGP key(s) Select one or multiple PGP keys that you want to initialise the store with. Select key(s) Select key + Repository visibility + Specify whether you want to clone the repository to the app’s internal directory (recommended) or to a shared folder that other apps can also access. + Specify whether you want to create the repository within the app’s internal directory (recommended) or in a shared folder that other apps can also access. + app-internal (hidden) + shared folder + %1$s › Documents › PasswordStore Potentially incorrect URL diff --git a/app/src/main/res/values/themes_material3.xml b/app/src/main/res/values/themes_material3.xml index 763606cccf..64b6dbbad3 100644 --- a/app/src/main/res/values/themes_material3.xml +++ b/app/src/main/res/values/themes_material3.xml @@ -11,6 +11,7 @@ ?android:colorBackground @style/APSThemeM3.Toolbar @style/APSThemeM3.BottomSheetDialog + @style/APSThemeM3.AlertDialog + + + +