diff --git a/.github/workflows/android-12-13-release.yml b/.github/workflows/android-12-13-release.yml
new file mode 100644
index 0000000000..57bb40f4a5
--- /dev/null
+++ b/.github/workflows/android-12-13-release.yml
@@ -0,0 +1,159 @@
+name: Android 12-13 per-commit releases
+
+on:
+ push:
+ branches:
+ - android-12-passkey-compat
+ workflow_dispatch:
+ inputs:
+ scope:
+ description: Commits to publish
+ required: true
+ default: head
+ type: choice
+ options:
+ - head
+ - branch
+
+permissions:
+ contents: write
+
+concurrency:
+ group: android-12-13-releases-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ discover-commits:
+ runs-on: ubuntu-latest
+ outputs:
+ commits: ${{ steps.commits.outputs.commits }}
+ steps:
+ - name: Checkout compatibility branch
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ fetch-depth: 0
+
+ - name: Select commits to publish
+ id: commits
+ shell: bash
+ env:
+ BEFORE_SHA: ${{ github.event.before }}
+ AFTER_SHA: ${{ github.sha }}
+ EVENT_NAME: ${{ github.event_name }}
+ MANUAL_SCOPE: ${{ inputs.scope }}
+ run: |
+ set -euo pipefail
+
+ if [[ "$EVENT_NAME" == "workflow_dispatch" && "$MANUAL_SCOPE" == "branch" ]]; then
+ git fetch --no-tags origin main:refs/remotes/origin/main
+ base=$(git merge-base origin/main "$AFTER_SHA")
+ mapfile -t shas < <(git rev-list --reverse "$base..$AFTER_SHA")
+ elif [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
+ shas=("$AFTER_SHA")
+ elif [[ "$BEFORE_SHA" == "0000000000000000000000000000000000000000" ]]; then
+ git fetch --no-tags origin main:refs/remotes/origin/main
+ base=$(git merge-base origin/main "$AFTER_SHA")
+ mapfile -t shas < <(git rev-list --reverse "$base..$AFTER_SHA")
+ else
+ mapfile -t shas < <(git rev-list --reverse "$BEFORE_SHA..$AFTER_SHA")
+ fi
+
+ if (( ${#shas[@]} == 0 )); then
+ shas=("$AFTER_SHA")
+ fi
+
+ commits=$(printf '%s\n' "${shas[@]}" | jq -R -s -c 'split("\n")[:-1]')
+ echo "commits=$commits" >> "$GITHUB_OUTPUT"
+ echo "Publishing ${#shas[@]} commit(s): $commits"
+
+ release:
+ needs: discover-commits
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ max-parallel: 1
+ matrix:
+ sha: ${{ fromJSON(needs.discover-commits.outputs.commits) }}
+ permissions:
+ contents: write
+ steps:
+ - name: Checkout commit
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ ref: ${{ matrix.sha }}
+ fetch-depth: 0
+
+ - name: Prepare release metadata
+ id: metadata
+ shell: bash
+ env:
+ COMMIT_SHA: ${{ matrix.sha }}
+ run: |
+ set -euo pipefail
+
+ short_sha=${COMMIT_SHA:0:12}
+ base_version=$(grep '^versioning-plugin.versionName=' app/version.properties | cut -d= -f2 | tr -d '[:space:]')
+ stable_version=${base_version%%-*}
+ snapshot_version="${stable_version}-android12.${short_sha}"
+ tag="android12-13-${COMMIT_SHA}"
+ subject=$(git log -1 --format=%s "$COMMIT_SHA")
+
+ sed -i \
+ "s/^versioning-plugin.versionName=.*/versioning-plugin.versionName=${snapshot_version}/" \
+ app/version.properties
+
+ {
+ echo "short_sha=$short_sha"
+ echo "version=$snapshot_version"
+ echo "tag=$tag"
+ echo "subject=$subject"
+ } >> "$GITHUB_OUTPUT"
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6
+ with:
+ distribution: temurin
+ java-version: '21'
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6
+
+ - name: Decode signing keystore
+ run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > keystore.jks
+
+ - name: Write keystore.properties
+ env:
+ KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
+ KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
+ run: |
+ {
+ echo "storeFile=keystore.jks"
+ echo "storePassword=${KEYSTORE_PASSWORD}"
+ echo "keyAlias=${KEY_ALIAS}"
+ echo "keyPassword=${KEYSTORE_PASSWORD}"
+ } > keystore.properties
+
+ - name: Build signed compatibility APK
+ run: ./gradlew :app:assembleRelease :app:collectReleaseApks --no-daemon
+
+ - name: Create per-commit compatibility release
+ uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3
+ with:
+ tag_name: ${{ steps.metadata.outputs.tag }}
+ target_commitish: ${{ matrix.sha }}
+ name: Android 12-13 ยท ${{ steps.metadata.outputs.short_sha }}
+ prerelease: true
+ make_latest: false
+ body: |
+ Signed build from the permanent `android-12-passkey-compat` maintenance branch.
+
+ - Android target: Android 12, 12L and 13 (API 31-33)
+ - Commit: `${{ matrix.sha }}`
+ - Version: `${{ steps.metadata.outputs.version }}`
+ - Change: ${{ steps.metadata.outputs.subject }}
+
+ This compatibility line remains separate from normal `v*` releases. Android's third-party
+ Credential Manager passkey-provider API starts at API 34, so the system passkey provider
+ integration remains disabled on Android 12-13.
+ files: app/outputs/APS-release-*.apk
+ fail_on_unmatched_files: true
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3181217805..2453ad974a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -141,11 +141,13 @@
+ android:theme="@style/NoBackgroundThemeM3"
+ tools:targetApi="34" />
()
+ assertEquals(Build.VERSION_CODES.S, Build.VERSION.SDK_INT)
+
+ val serviceComponent =
+ ComponentName(
+ context.packageName,
+ "${context.packageName}.passkeys.AppPasskeyCredentialProviderService",
+ )
+ val activityComponent =
+ ComponentName(
+ context.packageName,
+ "${context.packageName}.passkeys.AppPasskeyProviderActivity",
+ )
+
+ val serviceEnabled = isComponentEnabled(context.packageManager, serviceComponent)
+ val activityEnabled = isComponentEnabled(context.packageManager, activityComponent)
+
+ if (serviceEnabled != null) assertFalse(serviceEnabled)
+ if (activityEnabled != null) assertFalse(activityEnabled)
+ }
+
+ @Test
+ @Config(sdk = [Build.VERSION_CODES.UPSIDE_DOWN_CAKE])
+ fun `credential provider entry points are enabled on Android 14`() {
+ val context = ApplicationProvider.getApplicationContext()
+ assertEquals(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, Build.VERSION.SDK_INT)
+
+ val serviceComponent =
+ ComponentName(
+ context.packageName,
+ "${context.packageName}.passkeys.AppPasskeyCredentialProviderService",
+ )
+ val activityComponent =
+ ComponentName(
+ context.packageName,
+ "${context.packageName}.passkeys.AppPasskeyProviderActivity",
+ )
+
+ val serviceEnabled = isComponentEnabled(context.packageManager, serviceComponent)
+ val activityEnabled = isComponentEnabled(context.packageManager, activityComponent)
+
+ if (serviceEnabled != null) assertTrue(serviceEnabled)
+ if (activityEnabled != null) assertTrue(activityEnabled)
+ }
+}
diff --git a/crypto/pgpainless/build.gradle.kts b/crypto/pgpainless/build.gradle.kts
index 956180186a..0975e26c22 100644
--- a/crypto/pgpainless/build.gradle.kts
+++ b/crypto/pgpainless/build.gradle.kts
@@ -13,7 +13,8 @@ dependencies {
implementation(libs.thirdparty.kotlinResult)
implementation(libs.thirdparty.kotlinResult.coroutines)
implementation(libs.thirdparty.pgpainless)
- testImplementation(libs.bundles.testDependencies)
+ testImplementation(libs.testing.junit)
+ testImplementation(libs.testing.kotlintest.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.testing.testparameterinjector)
implementation(libs.thirdparty.bouncycastle.bcprov)
diff --git a/format/common/build.gradle.kts b/format/common/build.gradle.kts
index 13e38193f1..405d863f6e 100644
--- a/format/common/build.gradle.kts
+++ b/format/common/build.gradle.kts
@@ -12,7 +12,8 @@ dependencies {
implementation(libs.dagger.hilt.core)
implementation(libs.thirdparty.commons.codec)
implementation(libs.thirdparty.uri)
- testImplementation(libs.bundles.testDependencies)
+ testImplementation(libs.testing.junit)
+ testImplementation(libs.testing.kotlintest.junit)
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 5de9e29712..2089c58fdf 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -62,6 +62,7 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
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-androidx-core = "androidx.test:core:1.6.1"
testing-junit = "junit:junit:4.13.2"
testing-kotlintest-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
testing-robolectric = "org.robolectric:robolectric:4.16.1"
@@ -102,6 +103,7 @@ androidxLifecycle = [
"androidx-lifecycle-viewmodelKtx",
]
testDependencies = [
+ "testing-androidx-core",
"testing-junit",
"testing-kotlintest-junit",
]
diff --git a/passkeys/core/build.gradle.kts b/passkeys/core/build.gradle.kts
index 7f64522ca8..65115a369d 100644
--- a/passkeys/core/build.gradle.kts
+++ b/passkeys/core/build.gradle.kts
@@ -15,5 +15,6 @@ dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
- testImplementation(libs.bundles.testDependencies)
+ testImplementation(libs.testing.junit)
+ testImplementation(libs.testing.kotlintest.junit)
}