diff --git a/play-services-recaptcha/core/build.gradle b/play-services-recaptcha/core/build.gradle
index 08fcb0e049..5185279133 100644
--- a/play-services-recaptcha/core/build.gradle
+++ b/play-services-recaptcha/core/build.gradle
@@ -6,6 +6,7 @@
apply plugin: 'com.android.library'
apply plugin: 'com.squareup.wire'
apply plugin: 'kotlin-android'
+apply plugin: 'org.jetbrains.kotlin.plugin.compose'
apply plugin: 'maven-publish'
apply plugin: 'signing'
@@ -20,6 +21,7 @@ dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutineVersion"
+ implementation "androidx.appcompat:appcompat:$appcompatVersion"
implementation "androidx.core:core-ktx:$coreVersion"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-service:$lifecycleVersion"
@@ -27,6 +29,12 @@ dependencies {
implementation "com.android.volley:volley:$volleyVersion"
implementation "com.squareup.wire:wire-runtime:$wireVersion"
+
+ implementation platform('androidx.compose:compose-bom:2024.04.00')
+ implementation 'androidx.compose.material3:material3'
+ implementation 'androidx.compose.ui:ui'
+ implementation 'androidx.activity:activity-compose:1.8.2'
+
}
wire {
@@ -39,6 +47,14 @@ android {
compileSdkVersion androidCompileSdk
buildToolsVersion "$androidBuildVersionTools"
+ buildFeatures {
+ compose true
+ }
+
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.10"
+ }
+
defaultConfig {
versionName version
minSdkVersion androidMinSdk
@@ -51,6 +67,7 @@ android {
lintOptions {
disable 'MissingTranslation'
+ disable 'CoroutineCreationDuringComposition'
}
compileOptions {
diff --git a/play-services-recaptcha/core/src/main/AndroidManifest.xml b/play-services-recaptcha/core/src/main/AndroidManifest.xml
index d4b5be9305..01a8850779 100644
--- a/play-services-recaptcha/core/src/main/AndroidManifest.xml
+++ b/play-services-recaptcha/core/src/main/AndroidManifest.xml
@@ -1,5 +1,4 @@
-
-
@@ -8,11 +7,34 @@
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/RecaptchaDeepLinkActivity.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/RecaptchaDeepLinkActivity.kt
new file mode 100644
index 0000000000..b9ddc542fe
--- /dev/null
+++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/RecaptchaDeepLinkActivity.kt
@@ -0,0 +1,114 @@
+/*
+ * SPDX-FileCopyrightText: 2026 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.recaptcha
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.compose.setContent
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.lifecycleScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.microg.gms.profile.ProfileManager
+import org.microg.gms.recaptcha.modac.RecaptchaQrVerifier
+import org.microg.gms.recaptcha.modac.VerificationOutcome
+
+private const val TAG = "RecaptchaDeepLink"
+private const val QR_TOKEN_SEPARATOR = "/qr/"
+private const val MAX_QR_TOKEN_LENGTH = 4096
+private val ALLOWED_HOSTS = setOf("recaptcha.net", "recaptcha.google.com")
+
+class RecaptchaDeepLinkActivity : AppCompatActivity() {
+
+ private var status by mutableStateOf(VerificationStatus.CONFIRM)
+ private var qrToken: String? = null
+ private var verificationJob: Job? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ ProfileManager.ensureInitialized(this)
+ RecaptchaQrVerifier.init(this, signalUpdateScope = lifecycleScope)
+ if (!acceptDeepLink(intent)) {
+ finish()
+ return
+ }
+ setContent {
+ RecaptchaDeepLinkScreen(
+ status = status,
+ onPrimaryButtonClick = { onPrimaryButtonClick(status) },
+ onSecondaryButtonClick = ::finish,
+ )
+ }
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ verificationJob?.cancel()
+ verificationJob = null
+ qrToken = null
+ if (!acceptDeepLink(intent)) finish()
+ }
+
+ private fun acceptDeepLink(intent: Intent?): Boolean {
+ val data = intent?.data
+ val token = data?.let(::extractQrToken)
+ if (intent?.action != Intent.ACTION_VIEW || data == null || data.scheme != "https" ||
+ ALLOWED_HOSTS.none { it.equals(data.host, ignoreCase = true) } || token == null
+ ) {
+ Log.d(TAG, "Rejecting invalid reCAPTCHA QR deep link")
+ return false
+ }
+ qrToken = token
+ status = VerificationStatus.CONFIRM
+ return true
+ }
+
+ private fun onPrimaryButtonClick(status: VerificationStatus) {
+ when (status) {
+ VerificationStatus.CONFIRM -> startVerification()
+ VerificationStatus.LOADING,
+ VerificationStatus.FAILED,
+ VerificationStatus.VERIFIED -> finish()
+ }
+ }
+
+ private fun startVerification() {
+ val token = qrToken
+ if (token == null) {
+ Log.d(TAG, "Verification requested without an accepted token")
+ finish()
+ return
+ }
+ qrToken = null
+ status = VerificationStatus.LOADING
+ verificationJob?.cancel()
+ verificationJob = lifecycleScope.launch {
+ val outcome = withContext(Dispatchers.IO) {
+ RecaptchaQrVerifier.verifyToken(token)
+ }
+ status = when (outcome) {
+ VerificationOutcome.Verified -> VerificationStatus.VERIFIED
+ is VerificationOutcome.Failed -> VerificationStatus.FAILED
+ }
+ }
+ }
+
+ private fun extractQrToken(uri: Uri): String? {
+ if (!isAllowedPath(uri.path)) return null
+ val token = uri.pathSegments.drop(1).joinToString("/")
+ return token.takeIf { it.isNotEmpty() && it.length <= MAX_QR_TOKEN_LENGTH }
+ }
+
+ private fun isAllowedPath(path: String?): Boolean = path?.startsWith(QR_TOKEN_SEPARATOR) == true
+}
diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/RecaptchaDeepLinkScreen.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/RecaptchaDeepLinkScreen.kt
new file mode 100644
index 0000000000..eaf46fafd0
--- /dev/null
+++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/RecaptchaDeepLinkScreen.kt
@@ -0,0 +1,156 @@
+/*
+ * SPDX-FileCopyrightText: 2026 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.recaptcha
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.microg.gms.recaptcha.core.R
+
+private val FilledButtonColor = Color(0xFF1A73E8)
+private val LinkButtonColor = Color(0xFF1967D2)
+
+internal enum class VerificationStatus {
+ CONFIRM,
+ LOADING,
+ FAILED,
+ VERIFIED,
+}
+
+private fun VerificationStatus.titleRes(): Int = when (this) {
+ VerificationStatus.CONFIRM -> R.string.recaptcha_qr_confirm_title
+ VerificationStatus.LOADING -> R.string.recaptcha_qr_progress_title
+ VerificationStatus.FAILED -> R.string.recaptcha_qr_failed_title
+ VerificationStatus.VERIFIED -> R.string.recaptcha_qr_success_title
+}
+
+private fun VerificationStatus.descriptionRes(): Int = when (this) {
+ VerificationStatus.CONFIRM -> R.string.recaptcha_qr_confirm_description
+ VerificationStatus.LOADING -> R.string.recaptcha_qr_progress_description
+ VerificationStatus.FAILED -> R.string.recaptcha_qr_failed_description
+ VerificationStatus.VERIFIED -> R.string.recaptcha_qr_success_description
+}
+
+private fun VerificationStatus.primaryButtonRes(): Int = when (this) {
+ VerificationStatus.CONFIRM -> R.string.recaptcha_qr_confirm_button
+ VerificationStatus.LOADING -> R.string.recaptcha_qr_progress_button
+ VerificationStatus.FAILED -> R.string.recaptcha_qr_failed_button
+ VerificationStatus.VERIFIED -> R.string.recaptcha_qr_success_button
+}
+
+private fun VerificationStatus.illustrationRes(): Int = when (this) {
+ VerificationStatus.CONFIRM -> R.drawable.ic_recaptcha_qr_confirm
+ VerificationStatus.LOADING -> R.drawable.ic_recaptcha_qr_progress
+ VerificationStatus.FAILED -> R.drawable.ic_recaptcha_qr_failed
+ VerificationStatus.VERIFIED -> R.drawable.ic_recaptcha_qr_success
+}
+
+@Composable
+internal fun RecaptchaDeepLinkScreen(
+ status: VerificationStatus,
+ onPrimaryButtonClick: () -> Unit,
+ onSecondaryButtonClick: () -> Unit,
+) {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Image(
+ painter = painterResource(status.illustrationRes()),
+ contentDescription = null,
+ modifier = Modifier.size(96.dp),
+ )
+ Spacer(Modifier.height(24.dp))
+ Text(
+ text = stringResource(status.titleRes()),
+ fontSize = 20.sp,
+ fontWeight = FontWeight.Medium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Spacer(Modifier.height(16.dp))
+ Text(
+ text = stringResource(status.descriptionRes()),
+ fontSize = 16.sp,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(32.dp))
+ PrimaryButton(status, onPrimaryButtonClick)
+ if (status == VerificationStatus.CONFIRM) {
+ TextButton(onClick = onSecondaryButtonClick) {
+ Text(
+ text = stringResource(R.string.recaptcha_qr_confirm_cancel_button),
+ color = LinkButtonColor,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun PrimaryButton(status: VerificationStatus, onClick: () -> Unit) {
+ val label = stringResource(status.primaryButtonRes())
+ when (status) {
+ VerificationStatus.CONFIRM,
+ VerificationStatus.VERIFIED -> Button(
+ onClick = onClick,
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(containerColor = FilledButtonColor),
+ ) {
+ Text(label, color = Color.White)
+ }
+
+ VerificationStatus.FAILED -> OutlinedButton(
+ onClick = onClick,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(label, color = LinkButtonColor)
+ }
+
+ VerificationStatus.LOADING -> TextButton(
+ onClick = onClick,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(18.dp),
+ color = LinkButtonColor,
+ strokeWidth = 2.dp,
+ )
+ Spacer(Modifier.size(8.dp))
+ Text(label, color = LinkButtonColor)
+ }
+ }
+}
diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/DefaultRecaptchaClientWorkflow.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/DefaultRecaptchaClientWorkflow.kt
new file mode 100644
index 0000000000..dca16d6a99
--- /dev/null
+++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/DefaultRecaptchaClientWorkflow.kt
@@ -0,0 +1,255 @@
+/*
+ * SPDX-FileCopyrightText: 2026 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.recaptcha.modac
+
+import android.content.Context
+import android.os.SystemClock
+import android.util.Log
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withTimeout
+import org.microg.gms.common.Constants
+import org.microg.gms.profile.Build
+import org.microg.gms.recaptcha.modac.signals.SignalCollectionManager
+import org.microg.gms.recaptcha.modac.signals.SignalCollectionRequest
+import org.microg.gms.recaptcha.modac.storage.RecaptchaCredentialStore
+import org.microg.gms.recaptcha.qr.CachedInitCredential
+import org.microg.gms.recaptcha.qr.InitializationRequest
+import org.microg.gms.recaptcha.qr.InitializationResponse
+import org.microg.gms.recaptcha.qr.RecaptchaSignals
+import org.microg.gms.recaptcha.qr.SignalUpdateRequest
+import org.microg.gms.recaptcha.qr.VerificationRequest
+import org.microg.gms.recaptcha.qr.VerificationResponse
+
+private const val TAG = "RecaptchaQr"
+private const val SIGNAL_UPDATE_TIMEOUT_MS = 10_000L
+private const val RECAPTCHA_SDK_VERSION = "18.9.0-beta02"
+private const val RECAPTCHA_BUILD_ID = "BQDhGLUE"
+private const val CLIENT_TYPE_FIRST_PARTY = 1
+private const val CLIENT_TYPE_OTHER = 2
+private const val PERSISTENT_CREDENTIAL_KEY = "_GRECAPTCHA_KC"
+
+private data class InitializationState(
+ val response: InitializationResponse,
+ val credential: CachedInitCredential?,
+)
+
+private class VerificationDeadline(timeoutMs: Long) {
+ private val deadlineMs = SystemClock.elapsedRealtime() + timeoutMs
+
+ fun remainingMs(): Long {
+ val remaining = deadlineMs - SystemClock.elapsedRealtime()
+ if (remaining <= 0L) {
+ throw RecaptchaError(
+ RecaptchaErrorKind.NETWORK,
+ RecaptchaErrorSubKind.UNKNOWN,
+ "verification_timeout",
+ )
+ }
+ return remaining
+ }
+}
+
+internal class DefaultRecaptchaClientWorkflow(
+ context: Context,
+ private val protocolClient: RecaptchaProtocolClient,
+ private val credentialStore: RecaptchaCredentialStore,
+ private val signalUpdateScope: CoroutineScope,
+) : RecaptchaClientWorkflow {
+ private val context = context.applicationContext
+ private val initMutex = Mutex()
+ private var sessionInitialization: InitializationState? = null
+
+ override suspend fun execute(
+ request: RecaptchaExecutionRequest,
+ signalCollectionManager: SignalCollectionManager,
+ ): String {
+ val deadline = VerificationDeadline(request.timeoutMs)
+ val initialization = getOrInitialize(request, deadline)
+ val signals = signalCollectionManager.collectSignals(
+ initialization.toSignalRequest(request.action.name, deadline.remainingMs()),
+ )
+ val verificationResponse = executeChallenge(
+ request = request,
+ initialization = initialization,
+ signals = signals,
+ timeoutMs = deadline.remainingMs(),
+ )
+ postExecute(
+ verificationResponse = verificationResponse,
+ siteKey = request.siteKey,
+ action = request.action.name,
+ initialization = initialization,
+ signalCollectionManager = signalCollectionManager,
+ )
+ return verificationResponse.challengeToken
+ }
+
+ private suspend fun getOrInitialize(
+ request: RecaptchaExecutionRequest,
+ deadline: VerificationDeadline,
+ ): InitializationState {
+ val sessionResult = initMutex.withLock {
+ sessionInitialization ?: initialize(request, deadline).also { sessionInitialization = it }
+ }
+ return sessionResult.copy(credential = credentialStore.read(request.siteKey))
+ }
+
+ private suspend fun initialize(
+ request: RecaptchaExecutionRequest,
+ deadline: VerificationDeadline,
+ ): InitializationState {
+ val isFirstParty = request.packageName == Constants.GMS_PACKAGE_NAME
+ val baseRequest = InitializationRequest(
+ siteKey = request.siteKey,
+ packageName = request.packageName,
+ sdkVersion = RECAPTCHA_SDK_VERSION,
+ clientType = if (isFirstParty) CLIENT_TYPE_FIRST_PARTY else CLIENT_TYPE_OTHER,
+ requestId = request.sessionRequestId,
+ sdkInt = Build.VERSION.SDK_INT.toString(),
+ initSignal = "",
+ playServicesAvailable = isFirstParty,
+ playStoreInstalled = isPackageInstalled(Constants.VENDING_PACKAGE_NAME),
+ installerPackage = gmsInstallerPackage(),
+ buildId = RECAPTCHA_BUILD_ID,
+ )
+ val cachedCredential = credentialStore.read(request.siteKey)
+ val initializationRequest = baseRequest.copy(initSignal = cachedCredential?.credential.orEmpty())
+ val initializationResponse = protocolClient.initialize(initializationRequest, deadline.remainingMs())
+ return InitializationState(initializationResponse, cachedCredential)
+ }
+
+ private suspend fun executeChallenge(
+ request: RecaptchaExecutionRequest,
+ initialization: InitializationState,
+ signals: RecaptchaSignals,
+ timeoutMs: Long,
+ ): VerificationResponse {
+ val response = protocolClient.verify(
+ VerificationRequest(
+ nonce = initialization.response.nonce,
+ landingToken = initialization.response.landingToken,
+ siteKey = request.siteKey,
+ action = request.action.name,
+ signals = signals,
+ qrToken = request.qrToken,
+ ),
+ timeoutMs,
+ )
+ if (response.challengeToken.isEmpty()) {
+ throw RecaptchaError(
+ RecaptchaErrorKind.INTERNAL,
+ RecaptchaErrorSubKind.EMPTY_CHALLENGE_TOKEN,
+ "verification_rejected",
+ "errorCode=${response.errorCode}",
+ )
+ }
+ return response
+ }
+
+ private suspend fun postExecute(
+ verificationResponse: VerificationResponse,
+ siteKey: String,
+ action: String,
+ initialization: InitializationState,
+ signalCollectionManager: SignalCollectionManager,
+ ) {
+ val kcEntry = verificationResponse.persistentStorage.firstOrNull { it.name == PERSISTENT_CREDENTIAL_KEY }
+ ?: return
+ if (kcEntry.storedValue.isEmpty()) return
+
+ val refreshedCredential = CachedInitCredential(
+ credential = kcEntry.storedValue,
+ nonceUuid = verificationResponse.nonceUuid,
+ droidGuardNonce = verificationResponse.droidGuardNonce,
+ )
+ val stored = credentialStore.write(siteKey, refreshedCredential)
+ if (stored && refreshedCredential.credential != initialization.credential?.credential) {
+ scheduleSignalUpdate(
+ siteKey = siteKey,
+ action = action,
+ initialization = initialization,
+ credential = refreshedCredential,
+ signalCollectionManager = signalCollectionManager,
+ )
+ }
+ }
+
+ private fun scheduleSignalUpdate(
+ siteKey: String,
+ action: String,
+ initialization: InitializationState,
+ credential: CachedInitCredential,
+ signalCollectionManager: SignalCollectionManager,
+ ) {
+ signalUpdateScope.launch {
+ try {
+ withTimeout(SIGNAL_UPDATE_TIMEOUT_MS) {
+ val deadline = VerificationDeadline(SIGNAL_UPDATE_TIMEOUT_MS)
+ val signals = signalCollectionManager.refreshSignals(
+ initialization.copy(credential = credential)
+ .toSignalRequest(action, deadline.remainingMs()),
+ )
+ val response = protocolClient.updateSignals(
+ SignalUpdateRequest(
+ landingToken = initialization.response.landingToken,
+ siteKey = siteKey,
+ signals = signals,
+ ),
+ deadline.remainingMs(),
+ )
+ val replacement = CachedInitCredential(
+ credential = response.credential,
+ nonceUuid = response.nonceUuid,
+ droidGuardNonce = credential.droidGuardNonce,
+ )
+ credentialStore.replaceIfCurrent(
+ siteKey = siteKey,
+ expectedCredential = credential.credential,
+ replacement = replacement,
+ )
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Log.w(TAG, "Background signal update failed", e)
+ }
+ }
+ }
+
+ private fun isPackageInstalled(packageName: String): Boolean = try {
+ context.packageManager.getPackageInfo(packageName, 0)
+ true
+ } catch (e: Exception) {
+ false
+ }
+
+ private fun gmsInstallerPackage(): String = try {
+ if (Build.VERSION.SDK_INT >= 30) {
+ context.packageManager.getInstallSourceInfo(Constants.GMS_PACKAGE_NAME)
+ .initiatingPackageName
+ .orEmpty()
+ } else {
+ @Suppress("DEPRECATION")
+ context.packageManager.getInstallerPackageName(Constants.GMS_PACKAGE_NAME).orEmpty()
+ }
+ } catch (e: Exception) {
+ ""
+ }
+
+ private fun InitializationState.toSignalRequest(
+ action: String,
+ timeoutMs: Long,
+ ) = SignalCollectionRequest(
+ action = action,
+ initializationResponse = response,
+ cachedCredential = credential,
+ timeoutMs = timeoutMs,
+ )
+}
diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaError.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaError.kt
new file mode 100644
index 0000000000..2337e43262
--- /dev/null
+++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaError.kt
@@ -0,0 +1,34 @@
+/*
+ * SPDX-FileCopyrightText: 2026 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.recaptcha.modac
+
+internal object RecaptchaErrorKind {
+ const val INTERNAL = 1
+ const val NETWORK = 3
+ const val SERVER = 10
+}
+
+internal object RecaptchaErrorSubKind {
+ const val RUNTIME_ERROR = 8
+ const val NOT_FOUND = 17
+ const val SERVICE_UNAVAILABLE = 47
+ const val HTTP_ERROR = 48
+ const val UNKNOWN = 64
+ const val BAD_REQUEST = 91
+ const val EMPTY_CHALLENGE_TOKEN = 121
+ const val INVALID_SERVER_RESPONSE = 140
+}
+
+internal class RecaptchaError(
+ val kindCode: Int,
+ val subKindCode: Int,
+ val label: String,
+ val detail: String? = null,
+ cause: Throwable? = null,
+) : RuntimeException(
+ "RecaptchaError(kind=$kindCode, subKind=$subKindCode) [$label]${detail?.let { ": $it" }.orEmpty()}",
+ cause,
+)
diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaProtoTransport.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaProtoTransport.kt
new file mode 100644
index 0000000000..515a43c61e
--- /dev/null
+++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaProtoTransport.kt
@@ -0,0 +1,110 @@
+/*
+ * SPDX-FileCopyrightText: 2026 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.recaptcha.modac
+
+import android.content.Context
+import android.os.LocaleList
+import com.android.volley.DefaultRetryPolicy
+import com.android.volley.NetworkResponse
+import com.android.volley.ParseError
+import com.android.volley.Request
+import com.android.volley.Response
+import com.android.volley.VolleyError
+import com.android.volley.toolbox.HttpHeaderParser
+import com.android.volley.toolbox.Volley
+import java.util.Locale
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+import kotlinx.coroutines.suspendCancellableCoroutine
+import org.microg.gms.profile.Build
+import org.microg.gms.utils.singleInstanceOf
+
+private const val PROTO_CONTENT_TYPE = "application/x-protobuffer"
+private const val MAX_PROTO_RESPONSE_BYTES = 1024 * 1024
+
+internal data class RecaptchaProtoResponse(val statusCode: Int, val body: ByteArray)
+
+internal class RecaptchaTransportException(
+ val statusCode: Int?,
+ cause: Throwable,
+) : Exception("reCAPTCHA transport failed${statusCode?.let { " with HTTP $it" }.orEmpty()}", cause)
+
+internal interface RecaptchaProtoTransport {
+ suspend fun post(endpoint: String, body: ByteArray, timeoutMs: Long): RecaptchaProtoResponse
+}
+
+internal class VolleyRecaptchaProtoTransport(context: Context) : RecaptchaProtoTransport {
+ private val queue = singleInstanceOf { Volley.newRequestQueue(context.applicationContext) }
+
+ override suspend fun post(
+ endpoint: String,
+ body: ByteArray,
+ timeoutMs: Long,
+ ): RecaptchaProtoResponse = suspendCancellableCoroutine { continuation ->
+ val request = RawProtoRequest(
+ endpoint = endpoint,
+ requestBody = body,
+ onSuccess = { response ->
+ if (continuation.isActive) continuation.resume(response)
+ },
+ onError = { error ->
+ if (continuation.isActive) {
+ continuation.resumeWithException(
+ RecaptchaTransportException(error.networkResponse?.statusCode, error),
+ )
+ }
+ },
+ ).apply {
+ retryPolicy = DefaultRetryPolicy(
+ timeoutMs.coerceIn(1L, Int.MAX_VALUE.toLong()).toInt(),
+ 0,
+ 1.0f,
+ )
+ setShouldCache(false)
+ }
+ continuation.invokeOnCancellation { request.cancel() }
+ try {
+ queue.add(request)
+ } catch (e: Exception) {
+ if (continuation.isActive) continuation.resumeWithException(e)
+ }
+ }
+}
+
+private class RawProtoRequest(
+ endpoint: String,
+ private val requestBody: ByteArray,
+ private val onSuccess: (RecaptchaProtoResponse) -> Unit,
+ onError: (VolleyError) -> Unit,
+) : Request(Method.POST, endpoint, onError) {
+
+ override fun getHeaders(): Map = mapOf(
+ "Accept" to PROTO_CONTENT_TYPE,
+ "Accept-Language" to if (Build.VERSION.SDK_INT >= 24) {
+ LocaleList.getDefault().toLanguageTags()
+ } else {
+ Locale.getDefault().language
+ },
+ )
+
+ override fun getBody(): ByteArray = requestBody
+
+ override fun getBodyContentType(): String = PROTO_CONTENT_TYPE
+
+ override fun parseNetworkResponse(response: NetworkResponse): Response {
+ if (response.data.size > MAX_PROTO_RESPONSE_BYTES) {
+ return Response.error(ParseError(IllegalStateException("reCAPTCHA response exceeds size limit")))
+ }
+ return Response.success(
+ RecaptchaProtoResponse(response.statusCode, response.data),
+ HttpHeaderParser.parseCacheHeaders(response),
+ )
+ }
+
+ override fun deliverResponse(response: RecaptchaProtoResponse) {
+ onSuccess(response)
+ }
+}
diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaProtocolClient.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaProtocolClient.kt
new file mode 100644
index 0000000000..04e389e7e1
--- /dev/null
+++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaProtocolClient.kt
@@ -0,0 +1,138 @@
+/*
+ * SPDX-FileCopyrightText: 2026 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.recaptcha.modac
+
+import com.squareup.wire.ProtoAdapter
+import kotlinx.coroutines.CancellationException
+import org.microg.gms.recaptcha.qr.InitializationRequest
+import org.microg.gms.recaptcha.qr.InitializationResponse
+import org.microg.gms.recaptcha.qr.SignalUpdateRequest
+import org.microg.gms.recaptcha.qr.SignalUpdateResponse
+import org.microg.gms.recaptcha.qr.VerificationRequest
+import org.microg.gms.recaptcha.qr.VerificationResponse
+
+private const val INITIALIZATION_ENDPOINT = "https://www.recaptcha.net/recaptcha/api3/mri"
+private const val VERIFICATION_ENDPOINT = "https://www.recaptcha.net/recaptcha/api3/mrr"
+private const val SIGNAL_UPDATE_ENDPOINT = "https://www.recaptcha.net/recaptcha/api3/mrs"
+
+internal class RecaptchaProtocolClient(private val transport: RecaptchaProtoTransport) {
+
+ suspend fun initialize(request: InitializationRequest, timeoutMs: Long): InitializationResponse {
+ val response = postAndDecode(
+ stage = "initialization",
+ endpoint = INITIALIZATION_ENDPOINT,
+ body = request.encode(),
+ adapter = InitializationResponse.ADAPTER,
+ timeoutMs = timeoutMs,
+ )
+ if (response.landingToken.isEmpty()) {
+ throw RecaptchaError(
+ RecaptchaErrorKind.INTERNAL,
+ RecaptchaErrorSubKind.INVALID_SERVER_RESPONSE,
+ "init_missing_landing_token",
+ )
+ }
+ return response
+ }
+
+ suspend fun verify(request: VerificationRequest, timeoutMs: Long): VerificationResponse = postAndDecode(
+ stage = "verification",
+ endpoint = VERIFICATION_ENDPOINT,
+ body = request.encode(),
+ adapter = VerificationResponse.ADAPTER,
+ timeoutMs = timeoutMs,
+ )
+
+ suspend fun updateSignals(request: SignalUpdateRequest, timeoutMs: Long): SignalUpdateResponse {
+ val response = postAndDecode(
+ stage = "signal_update",
+ endpoint = SIGNAL_UPDATE_ENDPOINT,
+ body = request.encode(),
+ adapter = SignalUpdateResponse.ADAPTER,
+ timeoutMs = timeoutMs,
+ )
+ if (response.credential.isEmpty()) {
+ throw RecaptchaError(
+ RecaptchaErrorKind.INTERNAL,
+ RecaptchaErrorSubKind.INVALID_SERVER_RESPONSE,
+ "signal_update_missing_credential",
+ )
+ }
+ return response
+ }
+
+ private suspend fun postAndDecode(
+ stage: String,
+ endpoint: String,
+ body: ByteArray,
+ adapter: ProtoAdapter,
+ timeoutMs: Long,
+ ): T {
+ val response = postProto(stage, endpoint, body, timeoutMs)
+ return try {
+ adapter.decode(response)
+ } catch (e: Exception) {
+ throw RecaptchaError(
+ RecaptchaErrorKind.INTERNAL,
+ RecaptchaErrorSubKind.RUNTIME_ERROR,
+ "${stage}_decode_failed",
+ cause = e,
+ )
+ }
+ }
+
+ private suspend fun postProto(
+ stage: String,
+ endpoint: String,
+ body: ByteArray,
+ timeoutMs: Long,
+ ): ByteArray {
+ val response = try {
+ transport.post(endpoint, body, timeoutMs)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: RecaptchaTransportException) {
+ throw e.statusCode?.let { httpStatusError(stage, it, e) }
+ ?: RecaptchaError(
+ RecaptchaErrorKind.NETWORK,
+ RecaptchaErrorSubKind.UNKNOWN,
+ "${stage}_transport_failed",
+ cause = e,
+ )
+ } catch (e: Exception) {
+ throw RecaptchaError(
+ RecaptchaErrorKind.NETWORK,
+ RecaptchaErrorSubKind.UNKNOWN,
+ "${stage}_transport_failed",
+ cause = e,
+ )
+ }
+ if (response.statusCode != 200) {
+ throw httpStatusError(stage, response.statusCode)
+ }
+ return response.body
+ }
+
+ private fun httpStatusError(
+ stage: String,
+ statusCode: Int,
+ cause: Throwable? = null,
+ ): RecaptchaError {
+ val (kindCode, subKindCode) = when (statusCode) {
+ 400 -> RecaptchaErrorKind.NETWORK to RecaptchaErrorSubKind.BAD_REQUEST
+ 403, 503 -> RecaptchaErrorKind.SERVER to RecaptchaErrorSubKind.SERVICE_UNAVAILABLE
+ 404 -> RecaptchaErrorKind.NETWORK to RecaptchaErrorSubKind.NOT_FOUND
+ else -> RecaptchaErrorKind.NETWORK to RecaptchaErrorSubKind.HTTP_ERROR
+ }
+ return RecaptchaError(
+ kindCode = kindCode,
+ subKindCode = subKindCode,
+ label = "${stage}_http_status",
+ detail = "HTTP $statusCode",
+ cause = cause,
+ )
+ }
+}
diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaQrClient.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaQrClient.kt
new file mode 100644
index 0000000000..7f42a79025
--- /dev/null
+++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaQrClient.kt
@@ -0,0 +1,90 @@
+/*
+ * SPDX-FileCopyrightText: 2026 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.recaptcha.modac
+
+import java.util.UUID
+import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withTimeout
+import org.microg.gms.recaptcha.modac.signals.SignalCollectionManager
+
+private const val MINIMUM_VERIFY_TIMEOUT_MS = 5_000L
+private val SUPPORTED_ACTIONS = setOf("modacVerify")
+
+internal class RecaptchaSession(val packageName: String)
+
+internal class RecaptchaAction(val name: String) {
+ companion object {
+ val MODAC_VERIFY = RecaptchaAction("modacVerify")
+ }
+}
+
+internal class RecaptchaExecutionRequest(
+ val siteKey: String,
+ val packageName: String,
+ val sessionRequestId: String,
+ val action: RecaptchaAction,
+ val timeoutMs: Long,
+ val qrToken: String,
+)
+
+internal interface RecaptchaClientWorkflow {
+ suspend fun execute(
+ request: RecaptchaExecutionRequest,
+ signalCollectionManager: SignalCollectionManager,
+ ): String
+}
+
+internal class RecaptchaQrClient(
+ private val workflow: RecaptchaClientWorkflow,
+ private val siteKey: String,
+ private val session: RecaptchaSession,
+ private val signalCollectionManager: SignalCollectionManager,
+) {
+ private val verificationMutex = Mutex()
+
+ suspend fun verify(action: RecaptchaAction, qrToken: String, timeoutMs: Long): String {
+ if (action.name !in SUPPORTED_ACTIONS) {
+ throw RecaptchaError(
+ RecaptchaErrorKind.INTERNAL,
+ RecaptchaErrorSubKind.RUNTIME_ERROR,
+ "unsupported_action",
+ )
+ }
+ if (timeoutMs < MINIMUM_VERIFY_TIMEOUT_MS) {
+ throw RecaptchaError(
+ RecaptchaErrorKind.INTERNAL,
+ RecaptchaErrorSubKind.UNKNOWN,
+ "timeout_too_short",
+ )
+ }
+ return try {
+ withTimeout(timeoutMs) {
+ verificationMutex.withLock {
+ workflow.execute(
+ request = RecaptchaExecutionRequest(
+ siteKey = siteKey,
+ packageName = session.packageName,
+ sessionRequestId = UUID.randomUUID().toString(),
+ action = action,
+ timeoutMs = timeoutMs,
+ qrToken = qrToken,
+ ),
+ signalCollectionManager = signalCollectionManager,
+ )
+ }
+ }
+ } catch (e: TimeoutCancellationException) {
+ throw RecaptchaError(
+ RecaptchaErrorKind.NETWORK,
+ RecaptchaErrorSubKind.UNKNOWN,
+ "verification_timeout",
+ cause = e,
+ )
+ }
+ }
+}
diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaQrVerifier.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaQrVerifier.kt
new file mode 100644
index 0000000000..a7256be9f1
--- /dev/null
+++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaQrVerifier.kt
@@ -0,0 +1,82 @@
+/*
+ * SPDX-FileCopyrightText: 2026 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.recaptcha.modac
+
+import android.content.Context
+import android.util.Log
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import org.microg.gms.common.Constants
+import org.microg.gms.recaptcha.modac.signals.DefaultSignalCollectionManager
+import org.microg.gms.recaptcha.modac.signals.SignalCollectionManager
+import org.microg.gms.recaptcha.modac.storage.RecaptchaCredentialStore
+
+private const val TAG = "RecaptchaQr"
+private const val DEFAULT_VERIFY_TIMEOUT_MS = 25_000L
+
+internal const val MODAC_SITE_KEY = "6LfuGqkrAAAAAOcfldxrFN7IZFkus_70m0tcbHNa"
+
+internal sealed interface VerificationOutcome {
+ data object Verified : VerificationOutcome
+ data class Failed(val cause: Throwable) : VerificationOutcome
+}
+
+internal object RecaptchaQrVerifier {
+ @Volatile
+ private var client: RecaptchaQrClient? = null
+
+ @Synchronized
+ fun init(
+ context: Context,
+ signalUpdateScope: CoroutineScope,
+ siteKey: String = MODAC_SITE_KEY,
+ packageName: String = Constants.GMS_PACKAGE_NAME,
+ signalCollectionManager: SignalCollectionManager? = null,
+ ) {
+ val signalManager = signalCollectionManager ?: DefaultSignalCollectionManager(context)
+ val workflow = DefaultRecaptchaClientWorkflow(
+ context = context,
+ protocolClient = RecaptchaProtocolClient(VolleyRecaptchaProtoTransport(context)),
+ credentialStore = RecaptchaCredentialStore(context),
+ signalUpdateScope = signalUpdateScope,
+ )
+ client = RecaptchaQrClient(
+ workflow = workflow,
+ siteKey = siteKey,
+ session = RecaptchaSession(packageName),
+ signalCollectionManager = signalManager,
+ )
+ }
+
+ suspend fun verifyToken(qrToken: String): VerificationOutcome {
+ val activeClient = client ?: return VerificationOutcome.Failed(
+ RecaptchaError(
+ RecaptchaErrorKind.INTERNAL,
+ RecaptchaErrorSubKind.UNKNOWN,
+ "verifier_not_initialized",
+ ),
+ )
+ return try {
+ activeClient.verify(RecaptchaAction.MODAC_VERIFY, qrToken, DEFAULT_VERIFY_TIMEOUT_MS)
+ VerificationOutcome.Verified
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: RecaptchaError) {
+ Log.w(TAG, "Verification failed: kind=${e.kindCode}, subKind=${e.subKindCode}, label=${e.label}")
+ VerificationOutcome.Failed(e)
+ } catch (e: Exception) {
+ Log.w(TAG, "Verification failed with an unexpected error", e)
+ VerificationOutcome.Failed(
+ RecaptchaError(
+ RecaptchaErrorKind.INTERNAL,
+ RecaptchaErrorSubKind.RUNTIME_ERROR,
+ "unexpected_error",
+ cause = e,
+ ),
+ )
+ }
+ }
+}
diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/signals/DefaultSignalCollectionManager.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/signals/DefaultSignalCollectionManager.kt
new file mode 100644
index 0000000000..e6d3f0eb68
--- /dev/null
+++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/signals/DefaultSignalCollectionManager.kt
@@ -0,0 +1,49 @@
+/*
+ * SPDX-FileCopyrightText: 2026 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.recaptcha.modac.signals
+
+import android.content.Context
+import android.util.Log
+import com.google.android.gms.droidguard.DroidGuardClient
+import com.google.android.gms.tasks.await
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.withTimeout
+import org.microg.gms.recaptcha.qr.RecaptchaSignals
+
+private const val TAG = "RecaptchaQr"
+private const val DROID_GUARD_FLOW_NAME = "recaptchabase-modac"
+private const val MAX_DROID_GUARD_TIMEOUT_MS = 5_000L
+
+internal class DefaultSignalCollectionManager(context: Context) : SignalCollectionManager {
+ private val context = context.applicationContext
+
+ override suspend fun collectSignals(request: SignalCollectionRequest): RecaptchaSignals {
+ val droidGuardNonce = request.cachedCredential?.droidGuardNonce?.utf8().orEmpty()
+ val droidGuardEnabled = request.initializationResponse.droidGuardConfig?.payload?.size?.let { it > 0 } == true
+ if (!droidGuardEnabled || droidGuardNonce.isEmpty()) {
+ return SignalEnvelopeBuilder.build(action = request.action)
+ }
+ val result = try {
+ withTimeout(request.timeoutMs.coerceAtMost(MAX_DROID_GUARD_TIMEOUT_MS)) {
+ DroidGuardClient.getResults(
+ context,
+ DROID_GUARD_FLOW_NAME,
+ mapOf("token" to droidGuardNonce),
+ ).await()
+ }
+ } catch (e: TimeoutCancellationException) {
+ Log.w(TAG, "DroidGuard signal collection timed out")
+ null
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Log.w(TAG, "DroidGuard signal collection failed", e)
+ null
+ }
+ return SignalEnvelopeBuilder.build(action = request.action, droidGuardResult = result)
+ }
+}
diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/signals/SignalCollectionManager.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/signals/SignalCollectionManager.kt
new file mode 100644
index 0000000000..fb9eead3af
--- /dev/null
+++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/signals/SignalCollectionManager.kt
@@ -0,0 +1,23 @@
+/*
+ * SPDX-FileCopyrightText: 2026 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.recaptcha.modac.signals
+
+import org.microg.gms.recaptcha.qr.CachedInitCredential
+import org.microg.gms.recaptcha.qr.InitializationResponse
+import org.microg.gms.recaptcha.qr.RecaptchaSignals
+
+internal data class SignalCollectionRequest(
+ val action: String,
+ val initializationResponse: InitializationResponse,
+ val cachedCredential: CachedInitCredential?,
+ val timeoutMs: Long,
+)
+
+internal interface SignalCollectionManager {
+ suspend fun collectSignals(request: SignalCollectionRequest): RecaptchaSignals
+
+ suspend fun refreshSignals(request: SignalCollectionRequest): RecaptchaSignals = collectSignals(request)
+}
diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/signals/SignalEnvelopeBuilder.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/signals/SignalEnvelopeBuilder.kt
new file mode 100644
index 0000000000..9a4008a951
--- /dev/null
+++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/signals/SignalEnvelopeBuilder.kt
@@ -0,0 +1,43 @@
+/*
+ * SPDX-FileCopyrightText: 2026 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.recaptcha.modac.signals
+
+import okio.ByteString.Companion.toByteString
+import org.microg.gms.recaptcha.qr.DroidGuardSignals
+import org.microg.gms.recaptcha.qr.EncodedSignal
+import org.microg.gms.recaptcha.qr.EncodedSignalBundle
+import org.microg.gms.recaptcha.qr.RecaptchaSignals
+import org.microg.gms.recaptcha.qr.SignalEncryption
+import org.microg.gms.recaptcha.qr.SignalValue
+import org.microg.gms.recaptcha.qr.SignalValueList
+
+private const val DROID_GUARD_FORMAT_CODE = 33
+
+internal object SignalEnvelopeBuilder {
+
+ fun build(action: String, droidGuardResult: String? = null): RecaptchaSignals {
+ val droidGuardSignals = droidGuardResult
+ ?.takeIf { it.isNotEmpty() }
+ ?.let { buildDroidGuardSignals(action, it) }
+ return RecaptchaSignals(action = action, droidGuardSignals = droidGuardSignals)
+ }
+
+ private fun buildDroidGuardSignals(action: String, result: String): DroidGuardSignals {
+ val values = SignalValueList(
+ values = listOf(
+ SignalValue(textValue = result),
+ SignalValue(booleanValue = true),
+ ),
+ formatCode = DROID_GUARD_FORMAT_CODE,
+ )
+ val signal = EncodedSignal(
+ encryption = SignalEncryption.NONE,
+ payload = values.encode().toByteString(),
+ )
+ val bundle = EncodedSignalBundle(signals = listOf(signal), action = action)
+ return DroidGuardSignals(signals = bundle)
+ }
+}
diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/ModacKeystore.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/ModacKeystore.kt
new file mode 100644
index 0000000000..235fd9d746
--- /dev/null
+++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/ModacKeystore.kt
@@ -0,0 +1,61 @@
+/*
+ * SPDX-FileCopyrightText: 2026 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.recaptcha.modac.storage
+
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import android.util.Log
+import androidx.annotation.RequiresApi
+import org.microg.gms.profile.Build
+import java.security.KeyStore
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+
+private const val TAG = "RecaptchaQr"
+private const val KEY_ALIAS = "recck"
+private const val ANDROID_KEYSTORE = "AndroidKeyStore"
+
+internal object ModacKeystore {
+
+ @Synchronized
+ fun getOrCreateKey(): SecretKey? {
+ if (Build.VERSION.SDK_INT < 23) {
+ Log.w(TAG, "AES/GCM Keystore requires Android 6.0 or newer")
+ return null
+ }
+ return Api23Impl.getOrCreateKey()
+ }
+
+ @RequiresApi(23)
+ private object Api23Impl {
+ fun getOrCreateKey(): SecretKey? = try {
+ val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
+ (keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)
+ ?.secretKey
+ ?: generateKey()
+ } catch (e: Exception) {
+ Log.w(TAG, "AndroidKeyStore unavailable", e)
+ null
+ }
+
+ private fun generateKey(): SecretKey? = try {
+ val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
+ keyGenerator.init(
+ KeyGenParameterSpec.Builder(
+ KEY_ALIAS,
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
+ ).setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .setKeySize(256)
+ .build(),
+ )
+ keyGenerator.generateKey()
+ } catch (e: Exception) {
+ Log.w(TAG, "AndroidKeyStore key generation failed", e)
+ null
+ }
+ }
+}
diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/ModacSignerCache.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/ModacSignerCache.kt
new file mode 100644
index 0000000000..53569c9da3
--- /dev/null
+++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/ModacSignerCache.kt
@@ -0,0 +1,67 @@
+/*
+ * SPDX-FileCopyrightText: 2026 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.recaptcha.modac.storage
+
+import android.content.Context
+import android.util.AtomicFile
+import android.util.Log
+import java.io.File
+import java.io.FileOutputStream
+import java.nio.charset.StandardCharsets
+import java.security.MessageDigest
+
+private const val TAG = "RecaptchaQr"
+private const val FILE_PREFIX = "rce_"
+private val FILE_LOCK = Any()
+
+internal class ModacSignerCache(private val context: Context) {
+
+ fun withExclusiveAccess(block: () -> T): T = synchronized(FILE_LOCK, block)
+
+ fun read(key: String): String? = synchronized(FILE_LOCK) {
+ val file = cacheFile(key)
+ if (!file.exists()) return@synchronized null
+ try {
+ String(AtomicFile(file).readFully(), StandardCharsets.UTF_8)
+ } catch (e: Exception) {
+ Log.w(TAG, "Credential cache read failed", e)
+ null
+ }
+ }
+
+ fun write(key: String, value: String): Boolean = synchronized(FILE_LOCK) {
+ val atomicFile = AtomicFile(cacheFile(key))
+ var stream: FileOutputStream? = null
+ try {
+ stream = atomicFile.startWrite()
+ stream.write(value.toByteArray(StandardCharsets.UTF_8))
+ atomicFile.finishWrite(stream)
+ true
+ } catch (e: Exception) {
+ stream?.let(atomicFile::failWrite)
+ Log.w(TAG, "Credential cache write failed", e)
+ false
+ }
+ }
+
+ fun delete(key: String): Boolean = synchronized(FILE_LOCK) {
+ val file = cacheFile(key)
+ if (!file.exists()) return@synchronized true
+ try {
+ AtomicFile(file).delete()
+ true
+ } catch (e: Exception) {
+ Log.w(TAG, "Credential cache delete failed", e)
+ false
+ }
+ }
+
+ private fun cacheFile(key: String): File = File(context.cacheDir, FILE_PREFIX + key.sha256())
+
+ private fun String.sha256(): String = MessageDigest.getInstance("SHA-256")
+ .digest(toByteArray(StandardCharsets.UTF_8))
+ .joinToString(separator = "") { (it.toInt() and 0xff).toString(16).padStart(2, '0') }
+}
diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/ModacSignerCrypto.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/ModacSignerCrypto.kt
new file mode 100644
index 0000000000..b30ee9cfed
--- /dev/null
+++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/ModacSignerCrypto.kt
@@ -0,0 +1,90 @@
+/*
+ * SPDX-FileCopyrightText: 2026 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.recaptcha.modac.storage
+
+import android.util.Base64
+import android.util.Log
+import androidx.annotation.RequiresApi
+import org.microg.gms.profile.Build
+import javax.crypto.Cipher
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+
+private const val TAG = "RecaptchaQr"
+private const val GCM_TAG_BITS = 128
+private const val GCM_IV_BYTES = 12
+private const val BASE64_FLAGS = Base64.URL_SAFE
+private const val AES_TRANSFORMATION = "AES/GCM/NoPadding"
+
+internal object ModacSignerCrypto {
+
+ fun fetchAndDecrypt(key: String, cache: ModacSignerCache): ByteArray? {
+ if (Build.VERSION.SDK_INT < 23) {
+ Log.d(TAG, "Encrypted credential cache requires Android 6.0 or newer")
+ return null
+ }
+ val payload = cache.read(key) ?: return null
+ val cipherBytes = try {
+ Base64.decode(payload, BASE64_FLAGS)
+ } catch (e: IllegalArgumentException) {
+ Log.w(TAG, "Credential cache Base64 decode failed", e)
+ cache.delete(key)
+ return null
+ }
+ if (cipherBytes.size <= GCM_IV_BYTES) {
+ Log.w(TAG, "Credential cache payload is too short (${cipherBytes.size}B)")
+ cache.delete(key)
+ return null
+ }
+
+ val secretKey = ModacKeystore.getOrCreateKey() ?: run {
+ Log.w(TAG, "Credential cache AES key unavailable")
+ return null
+ }
+ return Api23Impl.decrypt(secretKey, cipherBytes).also { decrypted ->
+ if (decrypted == null) cache.delete(key)
+ }
+ }
+
+ fun encryptAndStore(key: String, cache: ModacSignerCache, plainBytes: ByteArray): Boolean {
+ if (Build.VERSION.SDK_INT < 23) {
+ Log.d(TAG, "Encrypted credential cache requires Android 6.0 or newer")
+ return false
+ }
+ val secretKey = ModacKeystore.getOrCreateKey() ?: run {
+ Log.w(TAG, "Credential cache AES key unavailable")
+ return false
+ }
+ val encoded = Api23Impl.encrypt(secretKey, plainBytes) ?: return false
+ return cache.write(key, encoded)
+ }
+
+ @RequiresApi(23)
+ private object Api23Impl {
+ fun decrypt(secretKey: SecretKey, cipherBytes: ByteArray): ByteArray? = try {
+ val cipher = Cipher.getInstance(AES_TRANSFORMATION)
+ cipher.init(
+ Cipher.DECRYPT_MODE,
+ secretKey,
+ GCMParameterSpec(GCM_TAG_BITS, cipherBytes, 0, GCM_IV_BYTES),
+ )
+ cipher.doFinal(cipherBytes, GCM_IV_BYTES, cipherBytes.size - GCM_IV_BYTES)
+ } catch (e: Exception) {
+ Log.w(TAG, "Credential cache AES/GCM decrypt failed", e)
+ null
+ }
+
+ fun encrypt(secretKey: SecretKey, plainBytes: ByteArray): String? = try {
+ val cipher = Cipher.getInstance(AES_TRANSFORMATION)
+ cipher.init(Cipher.ENCRYPT_MODE, secretKey)
+ val cipherBytes = cipher.doFinal(plainBytes)
+ Base64.encodeToString(cipher.iv + cipherBytes, BASE64_FLAGS or Base64.NO_WRAP)
+ } catch (e: Exception) {
+ Log.w(TAG, "Credential cache AES/GCM encrypt failed", e)
+ null
+ }
+ }
+}
diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/RecaptchaCredentialStore.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/RecaptchaCredentialStore.kt
new file mode 100644
index 0000000000..82c04ce08e
--- /dev/null
+++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/RecaptchaCredentialStore.kt
@@ -0,0 +1,53 @@
+/*
+ * SPDX-FileCopyrightText: 2026 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.recaptcha.modac.storage
+
+import android.content.Context
+import android.util.Log
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.microg.gms.recaptcha.qr.CachedInitCredential
+
+private const val TAG = "RecaptchaQr"
+private const val CACHE_KEY_SUFFIX = "_init"
+
+internal class RecaptchaCredentialStore(context: Context) {
+ private val cache = ModacSignerCache(context.applicationContext)
+
+ suspend fun read(siteKey: String): CachedInitCredential? = withContext(Dispatchers.IO) {
+ readCredential(cacheKey(siteKey))
+ }
+
+ suspend fun write(siteKey: String, credential: CachedInitCredential): Boolean = withContext(Dispatchers.IO) {
+ ModacSignerCrypto.encryptAndStore(cacheKey(siteKey), cache, credential.encode())
+ }
+
+ suspend fun replaceIfCurrent(
+ siteKey: String,
+ expectedCredential: String,
+ replacement: CachedInitCredential,
+ ): Boolean = withContext(Dispatchers.IO) {
+ val key = cacheKey(siteKey)
+ cache.withExclusiveAccess {
+ val current = readCredential(key, " during update") ?: return@withExclusiveAccess false
+ if (current.credential != expectedCredential) return@withExclusiveAccess false
+ ModacSignerCrypto.encryptAndStore(key, cache, replacement.encode())
+ }
+ }
+
+ private fun readCredential(key: String, logContext: String = ""): CachedInitCredential? {
+ val decrypted = ModacSignerCrypto.fetchAndDecrypt(key, cache) ?: return null
+ return try {
+ CachedInitCredential.ADAPTER.decode(decrypted)
+ } catch (e: Exception) {
+ Log.w(TAG, "Cached credential decode failed$logContext", e)
+ cache.delete(key)
+ null
+ }
+ }
+
+ private fun cacheKey(siteKey: String): String = siteKey + CACHE_KEY_SUFFIX
+}
diff --git a/play-services-recaptcha/core/src/main/proto/recaptcha_qr.proto b/play-services-recaptcha/core/src/main/proto/recaptcha_qr.proto
new file mode 100644
index 0000000000..040f188238
--- /dev/null
+++ b/play-services-recaptcha/core/src/main/proto/recaptcha_qr.proto
@@ -0,0 +1,67 @@
+/*
+ * SPDX-FileCopyrightText: 2026 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+syntax = "proto3";
+
+import "recaptcha_signals.proto";
+
+option java_package = "org.microg.gms.recaptcha.qr";
+
+message VerificationRequest {
+ string nonce = 1;
+ string landingToken = 2;
+ string siteKey = 3;
+ string action = 4;
+ RecaptchaSignals signals = 8;
+ string qrToken = 9;
+}
+
+message VerificationResponse {
+ string challengeToken = 1;
+ int32 errorCode = 4;
+ repeated PersistentStorageEntry persistentStorage = 5;
+ bytes nonceUuid = 6;
+ bytes droidGuardNonce = 9;
+}
+
+message PersistentStorageEntry {
+ string name = 1;
+ string storedValue = 2;
+}
+
+message InitializationRequest {
+ string siteKey = 1;
+ string packageName = 2;
+ string sdkVersion = 3;
+ int32 clientType = 4;
+ string requestId = 6;
+ string sdkInt = 7;
+ string initSignal = 8;
+ bool playServicesAvailable = 9;
+ bool playStoreInstalled = 10;
+ string installerPackage = 11;
+ string buildId = 12;
+}
+
+message InitializationResponse {
+ string nonce = 3;
+ string landingToken = 5;
+ reserved 11, 13 to 16;
+ DroidGuardConfig droidGuardConfig = 12;
+}
+
+message DroidGuardConfig {
+ bytes payload = 1;
+}
+
+message SignalUpdateRequest {
+ string landingToken = 2;
+ string siteKey = 3;
+ RecaptchaSignals signals = 4;
+}
+
+message SignalUpdateResponse {
+ string credential = 1;
+ bytes nonceUuid = 2;
+}
diff --git a/play-services-recaptcha/core/src/main/proto/recaptcha_signals.proto b/play-services-recaptcha/core/src/main/proto/recaptcha_signals.proto
new file mode 100644
index 0000000000..b4e5c0bb33
--- /dev/null
+++ b/play-services-recaptcha/core/src/main/proto/recaptcha_signals.proto
@@ -0,0 +1,47 @@
+/*
+ * SPDX-FileCopyrightText: 2026 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+syntax = "proto3";
+
+option java_package = "org.microg.gms.recaptcha.qr";
+
+message RecaptchaSignals {
+ string action = 1;
+ reserved 7, 10;
+ DroidGuardSignals droidGuardSignals = 11;
+}
+
+message DroidGuardSignals {
+ EncodedSignalBundle signals = 1;
+}
+
+message EncodedSignalBundle {
+ repeated EncodedSignal signals = 1;
+ string action = 2;
+}
+
+message EncodedSignal {
+ reserved 1, 7, 8;
+ optional SignalEncryption encryption = 4;
+ optional bytes payload = 6;
+}
+
+enum SignalEncryption {
+ reserved 2, 3;
+ UNKNOWN = 0;
+ NONE = 1;
+}
+
+message SignalValueList {
+ repeated SignalValue values = 1;
+ uint32 formatCode = 2;
+}
+
+message SignalValue {
+ reserved 2 to 10;
+ oneof value {
+ bool booleanValue = 1;
+ string textValue = 11;
+ }
+}
diff --git a/play-services-recaptcha/core/src/main/proto/recaptcha_storage.proto b/play-services-recaptcha/core/src/main/proto/recaptcha_storage.proto
new file mode 100644
index 0000000000..e82e34b9cd
--- /dev/null
+++ b/play-services-recaptcha/core/src/main/proto/recaptcha_storage.proto
@@ -0,0 +1,14 @@
+/*
+ * SPDX-FileCopyrightText: 2026 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+syntax = "proto3";
+
+option java_package = "org.microg.gms.recaptcha.qr";
+
+message CachedInitCredential {
+ reserved 3, 4;
+ string credential = 1;
+ bytes nonceUuid = 2;
+ bytes droidGuardNonce = 5;
+}
diff --git a/play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_confirm.xml b/play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_confirm.xml
new file mode 100644
index 0000000000..49bdf01962
--- /dev/null
+++ b/play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_confirm.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_failed.xml b/play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_failed.xml
new file mode 100644
index 0000000000..0ac62e804a
--- /dev/null
+++ b/play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_failed.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_progress.xml b/play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_progress.xml
new file mode 100644
index 0000000000..c5f6fb6111
--- /dev/null
+++ b/play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_progress.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_success.xml b/play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_success.xml
new file mode 100644
index 0000000000..535d0f8169
--- /dev/null
+++ b/play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_success.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/play-services-recaptcha/core/src/main/res/values-zh-rCN/strings.xml b/play-services-recaptcha/core/src/main/res/values-zh-rCN/strings.xml
new file mode 100644
index 0000000000..fd94534fd0
--- /dev/null
+++ b/play-services-recaptcha/core/src/main/res/values-zh-rCN/strings.xml
@@ -0,0 +1,23 @@
+
+
+
+ 确认验证
+ 有一个网站正在请求使用您的设备进行验证
+ 确认验证
+ 取消
+
+ 正在设备上验证
+ reCAPTCHA 正在此设备上验证请求,请稍候
+ 取消
+
+ 验证失败
+ 验证未能完成,请返回网站后重试
+ 关闭
+
+ 验证完成
+ 现在,您可以关闭此界面并返回到相应网页或应用了
+ 完成
+
diff --git a/play-services-recaptcha/core/src/main/res/values-zh-rTW/strings.xml b/play-services-recaptcha/core/src/main/res/values-zh-rTW/strings.xml
new file mode 100644
index 0000000000..2c2e805833
--- /dev/null
+++ b/play-services-recaptcha/core/src/main/res/values-zh-rTW/strings.xml
@@ -0,0 +1,23 @@
+
+
+
+ 確認驗證
+ 有一個網站要求使用您的裝置進行驗證
+ 確認驗證
+ 取消
+
+ 正在裝置上進行驗證
+ reCAPTCHA 正在此裝置上驗證這項要求,請稍候
+ 取消
+
+ 驗證失敗
+ 無法完成驗證,請返回網站並再試一次
+ 關閉
+
+ 驗證完成
+ 您現在可以關閉此畫面,然後返回網站或應用程式
+ 完成
+
diff --git a/play-services-recaptcha/core/src/main/res/values/strings.xml b/play-services-recaptcha/core/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..06595e4b94
--- /dev/null
+++ b/play-services-recaptcha/core/src/main/res/values/strings.xml
@@ -0,0 +1,23 @@
+
+
+
+ Confirm verification
+ A website is asking to use your device for verification.
+ Confirm verification
+ Cancel
+
+ Verifying on device
+ reCAPTCHA is verifying the request on this device. Please wait.
+ Cancel
+
+ Verification failed
+ Verification could not be completed. Return to the website and try again.
+ Close
+
+ Verification complete
+ You can now close this screen and return to the website or app.
+ Done
+