Skip to content

Commit c332e5b

Browse files
committed
Recaptcha: Add QR code verification flow
1 parent 9a206ae commit c332e5b

27 files changed

Lines changed: 1661 additions & 5 deletions

play-services-recaptcha/core/build.gradle

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
apply plugin: 'com.android.library'
77
apply plugin: 'com.squareup.wire'
88
apply plugin: 'kotlin-android'
9+
apply plugin: 'org.jetbrains.kotlin.plugin.compose'
910
apply plugin: 'maven-publish'
1011
apply plugin: 'signing'
1112

@@ -20,13 +21,20 @@ dependencies {
2021
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVersion"
2122
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutineVersion"
2223

24+
implementation "androidx.appcompat:appcompat:$appcompatVersion"
2325
implementation "androidx.core:core-ktx:$coreVersion"
2426
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion"
2527
implementation "androidx.lifecycle:lifecycle-service:$lifecycleVersion"
2628
implementation "androidx.webkit:webkit:$webkitVersion"
2729

2830
implementation "com.android.volley:volley:$volleyVersion"
2931
implementation "com.squareup.wire:wire-runtime:$wireVersion"
32+
33+
implementation platform('androidx.compose:compose-bom:2024.04.00')
34+
implementation 'androidx.compose.material3:material3'
35+
implementation 'androidx.compose.ui:ui'
36+
implementation 'androidx.activity:activity-compose:1.8.2'
37+
3038
}
3139

3240
wire {
@@ -39,6 +47,14 @@ android {
3947
compileSdkVersion androidCompileSdk
4048
buildToolsVersion "$androidBuildVersionTools"
4149

50+
buildFeatures {
51+
compose true
52+
}
53+
54+
composeOptions {
55+
kotlinCompilerExtensionVersion = "1.5.10"
56+
}
57+
4258
defaultConfig {
4359
versionName version
4460
minSdkVersion androidMinSdk
Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
<?xml version="1.0" encoding="utf-8"?>
2-
<!--
1+
<?xml version="1.0" encoding="utf-8"?><!--
32
~ SPDX-FileCopyrightText: 2021 microG Project Team
43
~ SPDX-License-Identifier: Apache-2.0
54
-->
@@ -8,11 +7,34 @@
87

98
<application>
109
<!-- This service is in :ui process because it may spawn a web view. See https://crbug.com/558377 -->
11-
<service android:name="org.microg.gms.recaptcha.RecaptchaService"
12-
android:process=":ui">
10+
<service
11+
android:name="org.microg.gms.recaptcha.RecaptchaService"
12+
android:process=":ui">
1313
<intent-filter>
14-
<action android:name="com.google.android.gms.recaptcha.service.START"/>
14+
<action android:name="com.google.android.gms.recaptcha.service.START" />
1515
</intent-filter>
1616
</service>
17+
18+
<activity
19+
android:name="org.microg.gms.recaptcha.RecaptchaDeepLinkActivity"
20+
android:configChanges="orientation|screenSize"
21+
android:enabled="true"
22+
android:excludeFromRecents="true"
23+
android:exported="true"
24+
android:launchMode="singleTask"
25+
android:taskAffinity=""
26+
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar">
27+
<intent-filter>
28+
<action android:name="android.intent.action.VIEW" />
29+
30+
<category android:name="android.intent.category.BROWSABLE" />
31+
<category android:name="android.intent.category.DEFAULT" />
32+
33+
<data android:scheme="https" />
34+
<data android:host="recaptcha.net" />
35+
<data android:host="recaptcha.google.com" />
36+
<data android:pathPrefix="/qr" />
37+
</intent-filter>
38+
</activity>
1739
</application>
1840
</manifest>
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026 microG Project Team
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.microg.gms.recaptcha
7+
8+
import android.content.Intent
9+
import android.net.Uri
10+
import android.os.Bundle
11+
import android.util.Log
12+
import androidx.activity.compose.setContent
13+
import androidx.appcompat.app.AppCompatActivity
14+
import androidx.compose.runtime.getValue
15+
import androidx.compose.runtime.mutableStateOf
16+
import androidx.compose.runtime.setValue
17+
import androidx.lifecycle.lifecycleScope
18+
import kotlinx.coroutines.Dispatchers
19+
import kotlinx.coroutines.Job
20+
import kotlinx.coroutines.launch
21+
import kotlinx.coroutines.withContext
22+
import org.microg.gms.profile.ProfileManager
23+
import org.microg.gms.recaptcha.modac.RecaptchaQrVerifier
24+
import org.microg.gms.recaptcha.modac.VerificationOutcome
25+
26+
private const val TAG = "RecaptchaDeepLink"
27+
private const val QR_TOKEN_SEPARATOR = "/qr/"
28+
private const val MAX_QR_TOKEN_LENGTH = 4096
29+
private val ALLOWED_HOSTS = setOf("recaptcha.net", "recaptcha.google.com")
30+
31+
class RecaptchaDeepLinkActivity : AppCompatActivity() {
32+
33+
private var status by mutableStateOf(VerificationStatus.CONFIRM)
34+
private var qrToken: String? = null
35+
private var verificationJob: Job? = null
36+
37+
override fun onCreate(savedInstanceState: Bundle?) {
38+
super.onCreate(savedInstanceState)
39+
ProfileManager.ensureInitialized(this)
40+
RecaptchaQrVerifier.init(this, signalUpdateScope = lifecycleScope)
41+
if (!acceptDeepLink(intent)) {
42+
finish()
43+
return
44+
}
45+
setContent {
46+
RecaptchaDeepLinkScreen(
47+
status = status,
48+
onPrimaryButtonClick = { onPrimaryButtonClick(status) },
49+
onSecondaryButtonClick = ::finish,
50+
)
51+
}
52+
}
53+
54+
override fun onNewIntent(intent: Intent) {
55+
super.onNewIntent(intent)
56+
setIntent(intent)
57+
verificationJob?.cancel()
58+
verificationJob = null
59+
qrToken = null
60+
if (!acceptDeepLink(intent)) finish()
61+
}
62+
63+
private fun acceptDeepLink(intent: Intent?): Boolean {
64+
val data = intent?.data
65+
val token = data?.let(::extractQrToken)
66+
if (intent?.action != Intent.ACTION_VIEW || data == null || data.scheme != "https" ||
67+
ALLOWED_HOSTS.none { it.equals(data.host, ignoreCase = true) } || token == null
68+
) {
69+
Log.d(TAG, "Rejecting invalid reCAPTCHA QR deep link")
70+
return false
71+
}
72+
qrToken = token
73+
status = VerificationStatus.CONFIRM
74+
return true
75+
}
76+
77+
private fun onPrimaryButtonClick(status: VerificationStatus) {
78+
when (status) {
79+
VerificationStatus.CONFIRM -> startVerification()
80+
VerificationStatus.LOADING,
81+
VerificationStatus.FAILED,
82+
VerificationStatus.VERIFIED -> finish()
83+
}
84+
}
85+
86+
private fun startVerification() {
87+
val token = qrToken
88+
if (token == null) {
89+
Log.d(TAG, "Verification requested without an accepted token")
90+
finish()
91+
return
92+
}
93+
qrToken = null
94+
status = VerificationStatus.LOADING
95+
verificationJob?.cancel()
96+
verificationJob = lifecycleScope.launch {
97+
val outcome = withContext(Dispatchers.IO) {
98+
RecaptchaQrVerifier.verifyToken(token)
99+
}
100+
status = when (outcome) {
101+
VerificationOutcome.Verified -> VerificationStatus.VERIFIED
102+
is VerificationOutcome.Failed -> VerificationStatus.FAILED
103+
}
104+
}
105+
}
106+
107+
private fun extractQrToken(uri: Uri): String? {
108+
if (!isAllowedPath(uri.path)) return null
109+
val token = uri.pathSegments.drop(1).joinToString("/")
110+
return token.takeIf { it.isNotEmpty() && it.length <= MAX_QR_TOKEN_LENGTH }
111+
}
112+
113+
private fun isAllowedPath(path: String?): Boolean = path?.startsWith(QR_TOKEN_SEPARATOR) == true
114+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026 microG Project Team
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.microg.gms.recaptcha
7+
8+
import androidx.compose.foundation.Image
9+
import androidx.compose.foundation.layout.Arrangement
10+
import androidx.compose.foundation.layout.Column
11+
import androidx.compose.foundation.layout.Spacer
12+
import androidx.compose.foundation.layout.fillMaxSize
13+
import androidx.compose.foundation.layout.fillMaxWidth
14+
import androidx.compose.foundation.layout.height
15+
import androidx.compose.foundation.layout.padding
16+
import androidx.compose.foundation.layout.size
17+
import androidx.compose.material3.Button
18+
import androidx.compose.material3.ButtonDefaults
19+
import androidx.compose.material3.CircularProgressIndicator
20+
import androidx.compose.material3.MaterialTheme
21+
import androidx.compose.material3.OutlinedButton
22+
import androidx.compose.material3.Surface
23+
import androidx.compose.material3.Text
24+
import androidx.compose.material3.TextButton
25+
import androidx.compose.runtime.Composable
26+
import androidx.compose.ui.Alignment
27+
import androidx.compose.ui.Modifier
28+
import androidx.compose.ui.graphics.Color
29+
import androidx.compose.ui.res.painterResource
30+
import androidx.compose.ui.res.stringResource
31+
import androidx.compose.ui.text.font.FontWeight
32+
import androidx.compose.ui.text.style.TextAlign
33+
import androidx.compose.ui.unit.dp
34+
import androidx.compose.ui.unit.sp
35+
import org.microg.gms.recaptcha.core.R
36+
37+
private val FilledButtonColor = Color(0xFF1A73E8)
38+
private val LinkButtonColor = Color(0xFF1967D2)
39+
40+
internal enum class VerificationStatus {
41+
CONFIRM,
42+
LOADING,
43+
FAILED,
44+
VERIFIED,
45+
}
46+
47+
private fun VerificationStatus.titleRes(): Int = when (this) {
48+
VerificationStatus.CONFIRM -> R.string.recaptcha_qr_confirm_title
49+
VerificationStatus.LOADING -> R.string.recaptcha_qr_progress_title
50+
VerificationStatus.FAILED -> R.string.recaptcha_qr_failed_title
51+
VerificationStatus.VERIFIED -> R.string.recaptcha_qr_success_title
52+
}
53+
54+
private fun VerificationStatus.descriptionRes(): Int = when (this) {
55+
VerificationStatus.CONFIRM -> R.string.recaptcha_qr_confirm_description
56+
VerificationStatus.LOADING -> R.string.recaptcha_qr_progress_description
57+
VerificationStatus.FAILED -> R.string.recaptcha_qr_failed_description
58+
VerificationStatus.VERIFIED -> R.string.recaptcha_qr_success_description
59+
}
60+
61+
private fun VerificationStatus.primaryButtonRes(): Int = when (this) {
62+
VerificationStatus.CONFIRM -> R.string.recaptcha_qr_confirm_button
63+
VerificationStatus.LOADING -> R.string.recaptcha_qr_progress_button
64+
VerificationStatus.FAILED -> R.string.recaptcha_qr_failed_button
65+
VerificationStatus.VERIFIED -> R.string.recaptcha_qr_success_button
66+
}
67+
68+
private fun VerificationStatus.illustrationRes(): Int = when (this) {
69+
VerificationStatus.CONFIRM -> R.drawable.ic_recaptcha_qr_confirm
70+
VerificationStatus.LOADING -> R.drawable.ic_recaptcha_qr_progress
71+
VerificationStatus.FAILED -> R.drawable.ic_recaptcha_qr_failed
72+
VerificationStatus.VERIFIED -> R.drawable.ic_recaptcha_qr_success
73+
}
74+
75+
@Composable
76+
internal fun RecaptchaDeepLinkScreen(
77+
status: VerificationStatus,
78+
onPrimaryButtonClick: () -> Unit,
79+
onSecondaryButtonClick: () -> Unit,
80+
) {
81+
Surface(modifier = Modifier.fillMaxSize()) {
82+
Column(
83+
modifier = Modifier
84+
.fillMaxSize()
85+
.padding(24.dp),
86+
horizontalAlignment = Alignment.CenterHorizontally,
87+
verticalArrangement = Arrangement.Center,
88+
) {
89+
Image(
90+
painter = painterResource(status.illustrationRes()),
91+
contentDescription = null,
92+
modifier = Modifier.size(96.dp),
93+
)
94+
Spacer(Modifier.height(24.dp))
95+
Text(
96+
text = stringResource(status.titleRes()),
97+
fontSize = 20.sp,
98+
fontWeight = FontWeight.Medium,
99+
textAlign = TextAlign.Center,
100+
color = MaterialTheme.colorScheme.onSurface,
101+
)
102+
Spacer(Modifier.height(16.dp))
103+
Text(
104+
text = stringResource(status.descriptionRes()),
105+
fontSize = 16.sp,
106+
textAlign = TextAlign.Center,
107+
color = MaterialTheme.colorScheme.onSurfaceVariant,
108+
)
109+
Spacer(Modifier.height(32.dp))
110+
PrimaryButton(status, onPrimaryButtonClick)
111+
if (status == VerificationStatus.CONFIRM) {
112+
TextButton(onClick = onSecondaryButtonClick) {
113+
Text(
114+
text = stringResource(R.string.recaptcha_qr_confirm_cancel_button),
115+
color = LinkButtonColor,
116+
)
117+
}
118+
}
119+
}
120+
}
121+
}
122+
123+
@Composable
124+
private fun PrimaryButton(status: VerificationStatus, onClick: () -> Unit) {
125+
val label = stringResource(status.primaryButtonRes())
126+
when (status) {
127+
VerificationStatus.CONFIRM,
128+
VerificationStatus.VERIFIED -> Button(
129+
onClick = onClick,
130+
modifier = Modifier.fillMaxWidth(),
131+
colors = ButtonDefaults.buttonColors(containerColor = FilledButtonColor),
132+
) {
133+
Text(label, color = Color.White)
134+
}
135+
136+
VerificationStatus.FAILED -> OutlinedButton(
137+
onClick = onClick,
138+
modifier = Modifier.fillMaxWidth(),
139+
) {
140+
Text(label, color = LinkButtonColor)
141+
}
142+
143+
VerificationStatus.LOADING -> TextButton(
144+
onClick = onClick,
145+
modifier = Modifier.fillMaxWidth(),
146+
) {
147+
CircularProgressIndicator(
148+
modifier = Modifier.size(18.dp),
149+
color = LinkButtonColor,
150+
strokeWidth = 2.dp,
151+
)
152+
Spacer(Modifier.size(8.dp))
153+
Text(label, color = LinkButtonColor)
154+
}
155+
}
156+
}

0 commit comments

Comments
 (0)