Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
8c2306e
Preliminary HCaptcha support.
dbrant Jan 15, 2025
20ac312
Merge branch 'main' into hcaptcha
dbrant Sep 19, 2025
b3d33da
Further.
dbrant Sep 19, 2025
ad71a6d
Yep.
dbrant Sep 19, 2025
37272e6
Close the loop.
dbrant Sep 22, 2025
fd9208a
Merge branch 'main' into hcaptcha
dbrant Sep 22, 2025
8ac738f
Merge branch 'main' into hcaptcha
dbrant Sep 22, 2025
8f5c267
Refactor into helper class.
dbrant Sep 23, 2025
3089247
Underpinnings for hCaptcha.
dbrant Sep 24, 2025
9301381
Preliminary HCaptcha support.
dbrant Jan 15, 2025
dc648de
Further.
dbrant Sep 19, 2025
c30ec25
Yep.
dbrant Sep 19, 2025
c4f594a
Close the loop.
dbrant Sep 22, 2025
2a1e4f8
Refactor into helper class.
dbrant Sep 23, 2025
e39ff4e
Merge branch 'hcaptcha' of github.com:wikimedia/apps-android-wikipedi…
dbrant Sep 24, 2025
a7fe9c2
Clean up.
dbrant Sep 24, 2025
7331a7b
Put vals into constructor.
dbrant Sep 24, 2025
c68eba4
Merge branch 'hCaptchaPre' into hcaptcha
dbrant Sep 24, 2025
4db3bec
Wire up to remote config.
dbrant Sep 24, 2025
89a30c4
Merge branch 'main' into hcaptcha
dbrant Sep 25, 2025
07f19e5
Merge branch 'main' into hcaptcha
dbrant Oct 1, 2025
aa290b7
Clean up.
dbrant Oct 1, 2025
8c0c82d
Merge branch 'main' into hcaptcha
dbrant Oct 10, 2025
75a26ea
Update a bit.
dbrant Oct 10, 2025
805c5cb
Merge branch 'main' into hcaptcha
dbrant Oct 15, 2025
5af9a02
Merge branch 'main' into hcaptcha
dbrant Oct 20, 2025
002e291
Merge branch 'main' into hcaptcha
dbrant Oct 23, 2025
a750a75
Merge branch 'main' into hcaptcha
dbrant Oct 24, 2025
18dceba
Merge branch 'main' into hcaptcha
dbrant Oct 29, 2025
2b3e3e6
Merge branch 'main' into hcaptcha
dbrant Nov 13, 2025
1c8398e
Work around cancelable behavior.
dbrant Nov 14, 2025
acc88bf
Update version.
dbrant Nov 17, 2025
c2db2de
Merge branch 'main' into hcaptcha
dbrant Nov 17, 2025
4be5f44
Merge branch 'main' into hcaptcha
dbrant Nov 19, 2025
9170c6e
Merge branch 'main' into hcaptcha
cooltey Nov 19, 2025
c66ebd2
Merge branch 'main' into hcaptcha
cooltey Nov 19, 2025
0c598a0
Merge branch 'main' into hcaptcha
Williamrai Nov 20, 2025
f30ee88
Review comments.
dbrant Nov 20, 2025
3b58a65
Merge branch 'main' into hcaptcha
Williamrai Nov 20, 2025
ad418e1
Merge branch 'main' into hcaptcha
Williamrai Nov 20, 2025
ed47c52
Merge branch 'main' into hcaptcha
cooltey Nov 21, 2025
2dea1fb
Merge branch 'main' into hcaptcha
Williamrai Nov 24, 2025
12231d0
Merge branch 'main' into hcaptcha
dbrant Nov 26, 2025
ad4e4d8
Merge branch 'main' into hcaptcha
dbrant Nov 26, 2025
041ae18
Review comments.
dbrant Dec 1, 2025
b48ae83
Merge branch 'main' into hcaptcha
dbrant Dec 1, 2025
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
1 change: 1 addition & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ dependencies {
implementation libs.drawerlayout
implementation libs.swiperefreshlayout
implementation libs.work.runtime.ktx
implementation libs.hcaptcha
implementation libs.metrics.platform

implementation libs.okhttp.tls
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputLayout
import com.hcaptcha.sdk.HCaptcha
import com.hcaptcha.sdk.HCaptchaConfig
import com.hcaptcha.sdk.HCaptchaSize
import com.hcaptcha.sdk.HCaptchaTheme
import com.hcaptcha.sdk.HCaptchaTokenResponse
import kotlinx.coroutines.launch
import org.wikipedia.R
import org.wikipedia.WikipediaApp
Expand Down Expand Up @@ -49,6 +54,9 @@ class CreateAccountActivity : BaseActivity() {
private var userNameTextWatcher: TextWatcher? = null
private val viewModel: CreateAccountActivityViewModel by viewModels()

private var hCaptcha: HCaptcha? = null
private var tokenResponse: HCaptchaTokenResponse? = null

public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityCreateAccountBinding.inflate(layoutInflater)
Expand Down Expand Up @@ -92,6 +100,9 @@ class CreateAccountActivity : BaseActivity() {
is CreateAccountActivityViewModel.AccountInfoState.DoCreateAccount -> {
doCreateAccount(it.token)
}
is CreateAccountActivityViewModel.AccountInfoState.HandleHCaptcha -> {
showHCaptcha()
}
is CreateAccountActivityViewModel.AccountInfoState.HandleCaptcha -> {
captchaHandler.handleCaptcha(it.token, CaptchaResult(it.captchaId))
}
Expand Down Expand Up @@ -163,7 +174,14 @@ class CreateAccountActivity : BaseActivity() {
finish()
}
binding.footerContainer.privacyPolicyLink.setOnClickListener {
FeedbackUtil.showPrivacyPolicy(this)



showHCaptcha()



//FeedbackUtil.showPrivacyPolicy(this)
}
binding.footerContainer.forgotPasswordLink.setOnClickListener {
visitInExternalBrowser(this, PageTitle("Special:PasswordReset", wiki).uri.toUri())
Expand All @@ -181,6 +199,54 @@ class CreateAccountActivity : BaseActivity() {
}
}

private fun showHCaptcha() {
if (hCaptcha == null) {

hCaptcha = HCaptcha.getClient(this)
hCaptcha?.setup(
HCaptchaConfig.builder()
.siteKey("f1f21d64-6384-4114-b7d0-d9d23e203b4a")
.theme(if (WikipediaApp.instance.currentTheme.isDark) HCaptchaTheme.DARK else HCaptchaTheme.LIGHT)
.host("meta.wikimedia.org")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can leave this line unset

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WebView popped up by the hCaptcha SDK requires a base URL that matches our content-security-policy, otherwise the iframe inside the WebView will refuse to load the hcaptcha content.


.jsSrc("https://assets-hcaptcha.wikimedia.org/1/api.js")
.endpoint("https://hcaptcha.wikimedia.org")
.assethost("https://assets-hcaptcha.wikimedia.org")
.imghost("https://imgs-hcaptcha.wikimedia.org")
.reportapi("https://report-hcaptcha.wikimedia.org")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(todo: all the above parameters will be served up via our new remote configuration endpoint)

.sentry(false)

//.loading(true)
//.locale("en")
//.size(HCaptchaSize.INVISIBLE)
//.hideDialog(false)
//.tokenExpiration(10)
//.diagnosticLog(true)
//.retryPredicate { config, exception ->
// exception.hCaptchaError == HCaptchaError.SESSION_TIMEOUT
//}

.build())

hCaptcha?.addOnSuccessListener { response ->
tokenResponse = response
doCreateAccount(viewModel.token.orEmpty(), hCaptchaToken = response.tokenResult)
}?.addOnFailureListener { e ->
L.e("hCaptcha failed: ${e.message} (${e.statusCode})")
tokenResponse = null
FeedbackUtil.showMessage(this, "hCaptcha failed: ${e.message} (${e.statusCode})")
}?.addOnOpenListener {
FeedbackUtil.showMessage(this, "hCaptcha shown")
}
}
hCaptcha?.verifyWithHCaptcha()
}

private fun resetHCaptcha() {
hCaptcha?.reset()
hCaptcha = null
}

private fun handleAccountCreationError(message: String) {
if (message.contains("blocked")) {
FeedbackUtil.makeSnackbar(this, getString(R.string.create_account_ip_block_message))
Expand All @@ -195,13 +261,15 @@ class CreateAccountActivity : BaseActivity() {
L.w("Account creation failed with result $message")
}

private fun doCreateAccount(token: String) {
private fun doCreateAccount(token: String, hCaptchaToken: String? = null) {
showProgressBar(true)
val email = getText(binding.createAccountEmail).ifEmpty { null }
val password = getText(binding.createAccountPasswordInput)
val repeat = getText(binding.createAccountPasswordRepeat)
val userName = getText(binding.createAccountUsername)
viewModel.doCreateAccount(token, captchaHandler.captchaId().toString(), captchaHandler.captchaWord().toString(), userName, password, repeat, email)
viewModel.doCreateAccount(token, captchaHandler.captchaId(),
if (hCaptchaToken.isNullOrEmpty()) captchaHandler.captchaWord() else hCaptchaToken,
userName, password, repeat, email)
}

public override fun onStop() {
Expand All @@ -210,6 +278,7 @@ class CreateAccountActivity : BaseActivity() {
}

public override fun onDestroy() {
resetHCaptcha()
captchaHandler.dispose()
userNameTextWatcher?.let { binding.createAccountUsername.editText?.removeTextChangedListener(it) }
super.onDestroy()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,26 +26,30 @@ class CreateAccountActivityViewModel : ViewModel() {
private val _verifyUserNameState = MutableSharedFlow<UserNameState>()
val verifyUserNameState = _verifyUserNameState.asSharedFlow()

var token: String? = null

private var verifyUserNameJob: Job? = null

fun createAccountInfo() {
viewModelScope.launch(CoroutineExceptionHandler { _, throwable ->
_createAccountInfoState.value = AccountInfoState.Error(throwable)
}) {
val response = ServiceFactory.get(WikipediaApp.instance.wikiSite).getAuthManagerInfo()
val token = response.query?.createAccountToken()
token = response.query?.createAccountToken()
val captchaId = response.query?.captchaId()
if (token.isNullOrEmpty()) {
_createAccountInfoState.value = AccountInfoState.InvalidToken
} else if (response.query?.hasHCaptchaRequest() == true) {
_createAccountInfoState.value = AccountInfoState.HandleHCaptcha(token!!)
} else if (!captchaId.isNullOrEmpty()) {
_createAccountInfoState.value = AccountInfoState.HandleCaptcha(token, captchaId)
_createAccountInfoState.value = AccountInfoState.HandleCaptcha(token!!, captchaId)
} else {
_createAccountInfoState.value = AccountInfoState.DoCreateAccount(token)
_createAccountInfoState.value = AccountInfoState.DoCreateAccount(token!!)
}
}
}

fun doCreateAccount(token: String, captchaId: String, captchaWord: String, userName: String, password: String, repeat: String, email: String?) {
fun doCreateAccount(token: String, captchaId: String?, captchaWord: String?, userName: String, password: String, repeat: String, email: String?) {
viewModelScope.launch(CoroutineExceptionHandler { _, throwable ->
_doCreateAccountState.value = CreateAccountState.Error(throwable)
}) {
Expand Down Expand Up @@ -84,7 +88,8 @@ class CreateAccountActivityViewModel : ViewModel() {

open class AccountInfoState {
data class DoCreateAccount(val token: String) : AccountInfoState()
data class HandleCaptcha(val token: String?, val captchaId: String) : AccountInfoState()
data class HandleCaptcha(val token: String, val captchaId: String) : AccountInfoState()
data class HandleHCaptcha(val token: String) : AccountInfoState()
data object InvalidToken : AccountInfoState()
data class Error(val throwable: Throwable) : AccountInfoState()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ internal class MwAuthManagerInfo {
internal class Request(val id: String? = null,
private val metadata: Map<String, String>? = null,
private val required: String? = null,
private val provider: String? = null,
val provider: String? = null,
private val account: String? = null,
val fields: Map<String, Field>? = null)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ class MwQueryResult {
return amInfo?.requests?.find { it.fields?.containsKey(key) == true }?.fields?.get(key)?.value
}

fun hasHCaptchaRequest(): Boolean {
return amInfo?.requests?.find { it.provider.orEmpty().lowercase().contains("hcaptcha") } != null
}

fun getUserResponse(userName: String): UserInfo? {
// MediaWiki user names are case sensitive, but the first letter is always capitalized.
return users?.find { StringUtil.capitalize(userName) == it.name }
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/java/org/wikipedia/settings/AboutActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ class AboutActivity : BaseActivity() {
asset = "licenses/Retrofit"
)
)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
DeviceUtil.setEdgeToEdge(this)
Expand Down Expand Up @@ -256,6 +257,12 @@ fun AboutWikipediaImage(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {






secretClickCount++
if (secretClickCount == AboutActivity.SECRET_CLICK_LIMIT) {
if (Prefs.isShowDeveloperSettingsEnabled) {
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ googlePayVersion = "19.4.0"
googleServices = "4.4.3"
gradle = "8.13.0"
hamcrest = "3.0"
hcaptcha = "4.2.3"
installreferrer = "2.2"
jsoup = "1.21.2"
junit = "4.13.2"
Expand Down Expand Up @@ -86,6 +87,7 @@ fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragm
google-services = { module = "com.google.gms:google-services", version.ref = "googleServices" }
gradle = { module = "com.android.tools.build:gradle", version.ref = "gradle" }
hamcrest = { module = "org.hamcrest:hamcrest", version.ref = "hamcrest" }
hcaptcha = { module = "com.github.hCaptcha.hcaptcha-android-sdk:sdk", version.ref = "hcaptcha" }
installreferrer = { module = "com.android.installreferrer:installreferrer", version.ref = "installreferrer" }
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
junit = { module = "junit:junit", version.ref = "junit" }
Expand Down
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ dependencyResolutionManagement {
google()
mavenCentral()
mavenLocal()
maven { setUrl("https://jitpack.io") }
}
}

Expand Down
Loading