From bc176596c4c2412cceefcabfaa735458a7b14b7f Mon Sep 17 00:00:00 2001 From: Alexander Grahn Date: Thu, 10 Sep 2026 03:05:36 +0200 Subject: [PATCH 01/38] fix access_local_network runtime permission request (#1076) Cherry-picked from upstream fe3a1993 (agrahn/Android-Password-Store#1076). Fork adaptation: kept GitOperationCoordinator serialization. The lock is taken after the local-network permission flow so an interactive permission request never blocks background passkey sync. Upstream CHANGELOG/README hunks omitted. (cherry picked from commit fe3a19933737746a896844e5d67d13af535230ee) --- .../ui/git/base/BaseGitActivity.kt | 93 ++++++++++++++++--- .../util/git/sshj/SshjSessionFactory.kt | 32 ++++--- app/src/main/res/values-de/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 4 files changed, 100 insertions(+), 29 deletions(-) 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..3b9cbb228d 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,22 @@ 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.InetAddress import javax.inject.Inject +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import logcat.asLog import logcat.logcat @@ -36,6 +48,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 +57,15 @@ import net.schmizz.sshj.userauth.UserAuthException @AndroidEntryPoint abstract class BaseGitActivity : AppCompatActivity() { + private var localNetworkAccessSetupCompletion: CompletableDeferred? = null + private var accessLocalNetworkGranted: Boolean = true + + private val requestLocalNetworkLauncher = + registerForActivityResult(RequestPermission()) { isGranted -> + accessLocalNetworkGranted = isGranted + localNetworkAccessSetupCompletion?.complete(Unit) + } + /** Enum of possible Git operations than can be run through [launchGitOperation]. */ enum class GitOp { BREAK_OUT_OF_DETACHED, @@ -67,28 +89,70 @@ 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 host = fixUri(URIish(gitSettings.url)).host - private suspend fun launchGitOperationUnlocked(operation: GitOp): Result { - if (gitSettings.url == null) { - return Err(IllegalStateException("Git url is not set!")) + // 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 + accessLocalNetworkGranted = false + + localNetworkAccessSetupCompletion = CompletableDeferred() + + runCatching { + withContext(dispatcherProvider.io()) { InetAddress.getByName(host) } + } + .onOk { + accessLocalNetworkGranted = true + localNetworkAccessSetupCompletion?.complete(Unit) + } + .onErr { e -> + if ( + e is SecurityException && + ContextCompat.checkSelfPermission(this, networkPermission) != + PackageManager.PERMISSION_GRANTED + ) + requestLocalNetworkLauncher.launch(networkPermission) + else return Err(e) // something else must have gone wrong + } } + + localNetworkAccessSetupCompletion?.await() + + return if (accessLocalNetworkGranted) lockedGitOperation(operation) + else + Err( + IllegalStateException( + resources.getString(R.string.git_local_network_unreachable_error, host) + ) + ) + } + + /** + * 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 +163,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/util/git/sshj/SshjSessionFactory.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshjSessionFactory.kt index 932183ee7c..fe695adf08 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 @@ -171,21 +171,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()) @@ -261,3 +247,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/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 6908e15e42..d3a196e8c4 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -356,6 +356,8 @@ 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 + Host %1$s nicht erreichbar. Fehlende Berechtigung für „Geräte in der Nähe“. + 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. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8658484d2f..c1c6ffb14c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -375,6 +375,8 @@ 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 + Host %1$s unreachable. Missing permission for “Nearby Devices”. + 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. From 43a83b6e0f5e5dd8ffaeb63f52e0893731f7685e Mon Sep 17 00:00:00 2001 From: Alexander Gil Date: Tue, 27 Oct 2026 20:54:02 +0100 Subject: [PATCH 02/38] chore: add opencode upstream-sync skill --- .opencode/skills/upstream-sync/SKILL.md | 95 +++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 .opencode/skills/upstream-sync/SKILL.md 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. From 547721ab1a90eedfad3b745ce6477df8cd3ee0ff Mon Sep 17 00:00:00 2001 From: Alexander Grahn Date: Thu, 24 Sep 2026 13:24:14 +0200 Subject: [PATCH 03/38] fixes related to sshj-0.41.1 update (#1112) Cherry-picked from upstream ce90e84b (agrahn/Android-Password-Store#1112). The fork already uses sshj 0.41.2, so the fix applies: the Conscrypt EdDSA normalizeForSshj workaround (#868) is dropped because sshj >= 0.41.1 handles those keys natively. The fork's existing provider-ordering comment in SshKey.kt is kept. (cherry picked from commit ce90e84ba454792fb983b3582c9bc30841cc377b) --- .../app/passwordstore/util/git/sshj/SshKey.kt | 41 ++----------------- .../util/git/sshj/SshjSessionFactory.kt | 7 ++-- 2 files changed, 6 insertions(+), 42 deletions(-) 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..04c86e5efa 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)}" } 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 fe695adf08..7a6e1cbab0 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 @@ -90,14 +90,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)}" @@ -145,7 +144,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 { From fd869c4e5daf80a7de8b71200df7a10a19dbfc90 Mon Sep 17 00:00:00 2001 From: Alexander Grahn Date: Thu, 24 Sep 2026 17:46:42 +0200 Subject: [PATCH 04/38] check local network connection on Android 17+ (#1116) infer missing runtime permission from timeout exception thrown (cherry picked from commit 35f993e59312fc29d207b9ff68d00e4cd9aefbef) --- .../ui/git/base/BaseGitActivity.kt | 48 +++++++++++-------- 1 file changed, 29 insertions(+), 19 deletions(-) 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 3b9cbb228d..e974458a9b 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 @@ -37,8 +37,10 @@ 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.InetAddress +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 @@ -98,32 +100,40 @@ abstract class BaseGitActivity : AppCompatActivity() { if (gitSettings.url == null) return Err(IllegalStateException(resources.getString(R.string.git_url_not_set_error))) - val host = fixUri(URIish(gitSettings.url)).host + 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 - accessLocalNetworkGranted = false - localNetworkAccessSetupCompletion = CompletableDeferred() + if ( + ContextCompat.checkSelfPermission(this, networkPermission) != + PackageManager.PERMISSION_GRANTED + ) { - runCatching { - withContext(dispatcherProvider.io()) { InetAddress.getByName(host) } - } - .onOk { - accessLocalNetworkGranted = true - localNetworkAccessSetupCompletion?.complete(Unit) - } - .onErr { e -> - if ( - e is SecurityException && - ContextCompat.checkSelfPermission(this, networkPermission) != - PackageManager.PERMISSION_GRANTED - ) - requestLocalNetworkLauncher.launch(networkPermission) - else return Err(e) // something else must have gone wrong + accessLocalNetworkGranted = false + + localNetworkAccessSetupCompletion = CompletableDeferred() + + runCatching { + withContext(dispatcherProvider.io()) { + val port = fixedUri.port + val socketFactory = SocketFactory.getDefault() + val socket = socketFactory.createSocket() + if (!socket.isConnected()) socket.connect(InetSocketAddress(host, port), 5000) + } } + .onOk { + accessLocalNetworkGranted = true + localNetworkAccessSetupCompletion?.complete(Unit) + } + .onErr { e -> + if (e is SocketTimeoutException) requestLocalNetworkLauncher.launch(networkPermission) + else return Err(e) // something else must have gone wrong + } + } } localNetworkAccessSetupCompletion?.await() From 430bd31e48d40bec09226d75b3336a14e06596af Mon Sep 17 00:00:00 2001 From: Alexander Grahn Date: Thu, 3 Sep 2026 14:00:38 +0200 Subject: [PATCH 05/38] build: align Bouncy Castle and coroutines versions through BOMs Adapted from upstream 2b4a5e2b (#1063) and a7adcd4e (#1115). The fork has no bcpkix dependency, so only bcprov/bcutil/bcpg are aligned; the BOM is applied in every module that consumes the versionless aliases (app, crypto:pgpainless, passkeys:core, passkeys:provider). The separate bcprov version is no longer needed. kotlinx-coroutines-android is now resolved through the coroutines BOM. (cherry picked from commit 2b4a5e2b4a7b2e0df187cd7f69c62a2aedeab206) (cherry picked from commit a7adcd4eb902e6790839fc52cc1ff95bc4fb1b65) --- app/build.gradle.kts | 3 ++- crypto/pgpainless/build.gradle.kts | 2 ++ format/common/build.gradle.kts | 1 + gradle/libs.versions.toml | 12 ++++++------ passkeys/core/build.gradle.kts | 1 + passkeys/provider/build.gradle.kts | 1 + 6 files changed, 13 insertions(+), 7 deletions(-) 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/crypto/pgpainless/build.gradle.kts b/crypto/pgpainless/build.gradle.kts index 956180186a..a7ae108390 100644 --- a/crypto/pgpainless/build.gradle.kts +++ b/crypto/pgpainless/build.gradle.kts @@ -14,8 +14,10 @@ dependencies { implementation(libs.thirdparty.kotlinResult.coroutines) implementation(libs.thirdparty.pgpainless) testImplementation(libs.bundles.testDependencies) + testImplementation(platform(libs.kotlinx.coroutines.bom)) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.testing.testparameterinjector) + implementation(platform(libs.thirdparty.bouncycastle.bom)) implementation(libs.thirdparty.bouncycastle.bcprov) implementation(libs.thirdparty.bouncycastle.bcutil) implementation(libs.thirdparty.bouncycastle.bcpg) diff --git a/format/common/build.gradle.kts b/format/common/build.gradle.kts index 13e38193f1..3c31a11bab 100644 --- a/format/common/build.gradle.kts +++ b/format/common/build.gradle.kts @@ -13,6 +13,7 @@ dependencies { implementation(libs.thirdparty.commons.codec) implementation(libs.thirdparty.uri) testImplementation(libs.bundles.testDependencies) + testImplementation(platform(libs.kotlinx.coroutines.bom)) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.testing.turbine) implementation(libs.thirdparty.logcat) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 708f5dd815..7cd50379fa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,6 @@ agp = "9.4.1" androidxActivity = "1.13.0" bouncycastle = "1.86" -bouncycastle-bcprov = "1.86" coroutines = "1.11.0" flowbinding = "1.2.0" hilt = "2.60.1" @@ -57,9 +56,9 @@ dagger-hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref dagger-hilt-core = { module = "com.google.dagger:hilt-core", version.ref = "hilt" } kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" } kotlinx-collections-immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.5.2" -kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-bom = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-bom", version.ref = "coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } -kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test" } kotlinx-datetime = "org.jetbrains.kotlinx:kotlinx-datetime:0.8.0-0.6.x-compat" kotlinx-serialization-json = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0" testing-junit = "junit:junit:4.13.2" @@ -68,9 +67,10 @@ testing-robolectric = "org.robolectric:robolectric:4.17" testing-sharedPrefsMock = "com.github.android-password-store:shared-preferences-fake:2.0.0" testing-testparameterinjector = "com.google.testparameterinjector:test-parameter-injector:1.24" testing-turbine = "app.cash.turbine:turbine:1.2.1" -thirdparty-bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle-bcprov" } -thirdparty-bouncycastle-bcutil = { module = "org.bouncycastle:bcutil-jdk18on", version.ref = "bouncycastle" } -thirdparty-bouncycastle-bcpg = { module = "org.bouncycastle:bcpg-jdk18on", version.ref = "bouncycastle" } +thirdparty-bouncycastle-bom = { module = "org.bouncycastle:bc-jdk18on-bom", version.ref = "bouncycastle" } +thirdparty-bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk18on" } +thirdparty-bouncycastle-bcutil = { module = "org.bouncycastle:bcutil-jdk18on" } +thirdparty-bouncycastle-bcpg = { module = "org.bouncycastle:bcpg-jdk18on" } thirdparty-commons_codec = "commons-codec:commons-codec:1.23.0" thirdparty-compose-lints = "com.slack.lint.compose:compose-lint-checks:1.6.1" thirdparty-fastscroll = "me.zhanghai.android.fastscroll:library:1.3.0" diff --git a/passkeys/core/build.gradle.kts b/passkeys/core/build.gradle.kts index 7f64522ca8..ba181bc3a5 100644 --- a/passkeys/core/build.gradle.kts +++ b/passkeys/core/build.gradle.kts @@ -10,6 +10,7 @@ plugins { dependencies { api(projects.crypto.common) implementation(libs.thirdparty.kotlinResult) + implementation(platform(libs.thirdparty.bouncycastle.bom)) implementation(libs.thirdparty.bouncycastle.bcprov) implementation(libs.thirdparty.logcat) implementation(libs.kotlinx.coroutines.core) diff --git a/passkeys/provider/build.gradle.kts b/passkeys/provider/build.gradle.kts index a2fadbf249..06442dea4b 100644 --- a/passkeys/provider/build.gradle.kts +++ b/passkeys/provider/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.serialization.json) + implementation(platform(libs.thirdparty.bouncycastle.bom)) implementation(libs.thirdparty.bouncycastle.bcprov) implementation(libs.thirdparty.kotlinResult) implementation(libs.thirdparty.logcat) From 70b8a2b1db6102ad1222819d3485083aa37128d1 Mon Sep 17 00:00:00 2001 From: Alexander Grahn Date: Fri, 25 Sep 2026 13:49:29 +0200 Subject: [PATCH 06/38] local network connection test on Android 17+ (#1119) Cherry-picked from upstream 1f70ce62 (agrahn/Android-Password-Store#1119). Fork adaptation: kept the GitOperationCoordinator lock (taken after the permission flow). Upstream CHANGELOG hunk omitted. (cherry picked from commit 1f70ce62) --- .../ui/git/base/BaseGitActivity.kt | 45 ++++++++++++------- app/src/main/res/values-de/strings.xml | 5 ++- app/src/main/res/values/strings.xml | 5 ++- 3 files changed, 38 insertions(+), 17 deletions(-) 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 e974458a9b..fcaeaccad8 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 @@ -60,11 +60,9 @@ import org.eclipse.jgit.transport.URIish abstract class BaseGitActivity : AppCompatActivity() { private var localNetworkAccessSetupCompletion: CompletableDeferred? = null - private var accessLocalNetworkGranted: Boolean = true private val requestLocalNetworkLauncher = registerForActivityResult(RequestPermission()) { isGranted -> - accessLocalNetworkGranted = isGranted localNetworkAccessSetupCompletion?.complete(Unit) } @@ -113,38 +111,55 @@ abstract class BaseGitActivity : AppCompatActivity() { PackageManager.PERMISSION_GRANTED ) { - accessLocalNetworkGranted = false - localNetworkAccessSetupCompletion = CompletableDeferred() runCatching { withContext(dispatcherProvider.io()) { - val port = fixedUri.port + val port = + fixedUri.port.takeUnless { it == -1 } + ?: run { + when (fixedUri.scheme) { + "http" -> 80 + "https" -> 443 + else -> 22 + } + } val socketFactory = SocketFactory.getDefault() val socket = socketFactory.createSocket() if (!socket.isConnected()) socket.connect(InetSocketAddress(host, port), 5000) } } .onOk { - accessLocalNetworkGranted = true localNetworkAccessSetupCompletion?.complete(Unit) } .onErr { e -> - if (e is SocketTimeoutException) requestLocalNetworkLauncher.launch(networkPermission) - else return Err(e) // something else must have gone wrong + 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 } } } localNetworkAccessSetupCompletion?.await() - return if (accessLocalNetworkGranted) lockedGitOperation(operation) - else - Err( - IllegalStateException( - resources.getString(R.string.git_local_network_unreachable_error, host) - ) - ) + return lockedGitOperation(operation) } /** diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d3a196e8c4..6bde56c8ff 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -356,7 +356,10 @@ 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 - Host %1$s nicht erreichbar. Fehlende Berechtigung für „Geräte in der Nähe“. + 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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c1c6ffb14c..13af208563 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -375,7 +375,10 @@ 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 - Host %1$s unreachable. Missing permission for “Nearby Devices”. + 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. From cfd720ae39aca24eab4e7e00f6743c6c7581e622 Mon Sep 17 00:00:00 2001 From: Kevin Hughes Date: Fri, 4 Sep 2026 09:16:04 +0100 Subject: [PATCH 07/38] Fix alert dialogs clipped by IME on short screens (#1065) (cherry picked from commit bfb4ff757c19e07cf66d49b7820b459f799351aa) --- app/src/main/res/values/themes_material3.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 + + + +