Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions play-services-recaptcha/core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -20,13 +21,20 @@ 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"
implementation "androidx.webkit:webkit:$webkitVersion"

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 {
Expand All @@ -39,6 +47,14 @@ android {
compileSdkVersion androidCompileSdk
buildToolsVersion "$androidBuildVersionTools"

buildFeatures {
compose true
}

composeOptions {
kotlinCompilerExtensionVersion = "1.5.10"
}

defaultConfig {
versionName version
minSdkVersion androidMinSdk
Expand All @@ -51,6 +67,7 @@ android {

lintOptions {
disable 'MissingTranslation'
disable 'CoroutineCreationDuringComposition'
}

compileOptions {
Expand Down
32 changes: 27 additions & 5 deletions play-services-recaptcha/core/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
<?xml version="1.0" encoding="utf-8"?><!--
~ SPDX-FileCopyrightText: 2021 microG Project Team
~ SPDX-License-Identifier: Apache-2.0
-->
Expand All @@ -8,11 +7,34 @@

<application>
<!-- This service is in :ui process because it may spawn a web view. See https://crbug.com/558377 -->
<service android:name="org.microg.gms.recaptcha.RecaptchaService"
android:process=":ui">
<service
android:name="org.microg.gms.recaptcha.RecaptchaService"
android:process=":ui">
<intent-filter>
<action android:name="com.google.android.gms.recaptcha.service.START"/>
<action android:name="com.google.android.gms.recaptcha.service.START" />
</intent-filter>
</service>

<activity
android:name="org.microg.gms.recaptcha.RecaptchaDeepLinkActivity"
android:configChanges="orientation|screenSize"
android:enabled="true"
android:excludeFromRecents="true"
android:exported="true"
android:launchMode="singleTask"
android:taskAffinity=""
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.VIEW" />

<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" />

<data android:scheme="https" />
<data android:host="recaptcha.net" />
<data android:host="recaptcha.google.com" />
<data android:pathPrefix="/qr" />
</intent-filter>
</activity>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading