Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
95 changes: 95 additions & 0 deletions app/src/main/java/org/wikipedia/captcha/HCaptchaHelper.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package org.wikipedia.captcha

import androidx.core.net.toUri
import androidx.fragment.app.FragmentActivity
import com.hcaptcha.sdk.HCaptcha
import com.hcaptcha.sdk.HCaptchaConfig
import com.hcaptcha.sdk.HCaptchaDialogFragment
import com.hcaptcha.sdk.HCaptchaTheme
import org.wikipedia.WikipediaApp
import org.wikipedia.settings.RemoteConfig
import org.wikipedia.util.log.L
import kotlin.String

class HCaptchaHelper(
private val activity: FragmentActivity,
private val callback: Callback
) {
interface Callback {
fun onSuccess(token: String)
fun onError(e: Exception)
}

private var hCaptcha: HCaptcha? = null

private val configDefault get() = RemoteConfig.RemoteConfigHCaptcha(
Copy link
Member Author

Choose a reason for hiding this comment

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

Note: even though these parameters are served by our remote configuration, we still want to hardcode some defaults here, in case the network call to fetch remote configuration fails.

baseURL = "https://meta.wikimedia.org",
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",
sentry = false,
siteKey = "e11698d6-51ca-4980-875c-72309c6678cc"
Copy link
Collaborator

Choose a reason for hiding this comment

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

Looks like the siteKey is outdated?

Copy link
Member Author

Choose a reason for hiding this comment

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

Hmm curious, looking into this with the other team...

Copy link
Member Author

Choose a reason for hiding this comment

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

This should be fixed now on the backend.

)

private val dialogCancelableRunnable = MakeHCaptchaDialogCancelable()

fun show() {
if (hCaptcha == null) {
val config = RemoteConfig.config.androidv1?.hCaptcha ?: configDefault
hCaptcha = HCaptcha.getClient(activity)
hCaptcha?.setup(
HCaptchaConfig.builder()
.theme(if (WikipediaApp.instance.currentTheme.isDark) HCaptchaTheme.DARK else HCaptchaTheme.LIGHT)
.siteKey(config.siteKey)
.host(config.baseURL.toUri().host)
.jsSrc(config.jsSrc)
.endpoint(config.endpoint)
.assethost(config.assetHost)
.imghost(config.imgHost)
.reportapi(config.reportApi)
.sentry(config.sentry)
.loading(false)
.build()
)

hCaptcha?.addOnSuccessListener { response ->
callback.onSuccess(response.tokenResult)
}?.addOnFailureListener { e ->
L.e("hCaptcha failed: ${e.message} (${e.statusCode})")
callback.onError(e)
}?.addOnOpenListener {
L.d("hCaptcha opened")
}
}
hCaptcha?.verifyWithHCaptcha()

// This works around an issue in the hCaptcha library where the "loading" dialog, even when
// it's not visible, still allows itself to be "canceled" by touching anywhere outside its
// bounds. We work around this by explicitly finding its DialogFragment and setting it to
// be non-cancelable, and then setting it back to cancelable after a short delay, so that
// the user doesn't accidentally tap the screen while hCaptcha is loading.
activity.window.decorView.post(MakeHCaptchaDialogCancelable(false))
activity.window.decorView.postDelayed(dialogCancelableRunnable, 10000)
}

fun cleanup() {
if (!activity.isDestroyed) {
activity.window.decorView.removeCallbacks(dialogCancelableRunnable)
}
hCaptcha?.removeAllListeners()
hCaptcha?.reset()
hCaptcha = null
}

inner class MakeHCaptchaDialogCancelable(val cancelable: Boolean = true) : Runnable {
override fun run() {
if (!activity.isDestroyed) {
activity.supportFragmentManager.fragments.filterIsInstance<HCaptchaDialogFragment>().forEach {
it.isCancelable = cancelable
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package org.wikipedia.createaccount
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.text.TextWatcher
import android.util.Patterns
Expand All @@ -28,6 +27,7 @@ import org.wikipedia.analytics.eventplatform.YearInReviewEvent
import org.wikipedia.auth.AccountUtil
import org.wikipedia.captcha.CaptchaHandler
import org.wikipedia.captcha.CaptchaResult
import org.wikipedia.captcha.HCaptchaHelper
import org.wikipedia.databinding.ActivityCreateAccountBinding
import org.wikipedia.login.LoginActivity
import org.wikipedia.util.DeviceUtil
Expand All @@ -51,6 +51,18 @@ class CreateAccountActivity : BaseActivity() {
private var requestSource: String = ""
private val viewModel: CreateAccountActivityViewModel by viewModels()

private val hCaptchaHelper = HCaptchaHelper(this, object : HCaptchaHelper.Callback {
override fun onSuccess(token: String) {
showProgressBar(false)
doCreateAccount(viewModel.token.orEmpty(), hCaptchaToken = token)
}

override fun onError(e: Exception) {
showProgressBar(false)
FeedbackUtil.showMessage(this@CreateAccountActivity, e.message.orEmpty())
}
})

public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityCreateAccountBinding.inflate(layoutInflater)
Expand Down Expand Up @@ -97,6 +109,11 @@ class CreateAccountActivity : BaseActivity() {
is CreateAccountActivityViewModel.AccountInfoState.DoCreateAccount -> {
doCreateAccount(it.token)
}
is CreateAccountActivityViewModel.AccountInfoState.HandleHCaptcha -> {
showProgressBar(true)
hCaptchaHelper.cleanup()
hCaptchaHelper.show()
}
is CreateAccountActivityViewModel.AccountInfoState.HandleCaptcha -> {
captchaHandler.handleCaptcha(it.token, CaptchaResult(it.captchaId))
}
Expand Down Expand Up @@ -198,7 +215,7 @@ class CreateAccountActivity : BaseActivity() {
FeedbackUtil.makeSnackbar(this, getString(R.string.create_account_ip_block_message))
.setAction(R.string.create_account_ip_block_details) {
visitInExternalBrowser(this,
Uri.parse(getString(R.string.create_account_ip_block_help_url)))
getString(R.string.create_account_ip_block_help_url).toUri())
}
.show()
} else {
Expand All @@ -207,13 +224,20 @@ 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 = token,
captchaId = captchaHandler.captchaId(),
captchaWord = if (hCaptchaToken.isNullOrEmpty()) captchaHandler.captchaWord() else hCaptchaToken,
userName = userName,
password = password,
repeat = repeat,
email = email)
}

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

public override fun onDestroy() {
hCaptchaHelper.cleanup()
captchaHandler.dispose()
userNameTextWatcher?.let { binding.createAccountUsername.editText?.removeTextChangedListener(it) }
super.onDestroy()
Expand Down Expand Up @@ -305,7 +330,7 @@ class CreateAccountActivity : BaseActivity() {
}

private fun showProgressBar(enable: Boolean) {
binding.viewProgressBar.visibility = if (enable) View.VISIBLE else View.GONE
binding.viewProgressBar.isVisible = enable
binding.captchaContainer.captchaSubmitButton.isEnabled = !enable
binding.captchaContainer.captchaSubmitButton.setText(if (enable) R.string.dialog_create_account_checking_progress else R.string.create_account_button)
}
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
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ googlePayVersion = "19.5.0"
googleServices = "4.4.4"
gradle = "8.13.1"
hamcrest = "3.0"
hcaptcha = "4.4.0"
installreferrer = "2.2"
jsoup = "1.21.2"
junit = "4.13.2"
Expand Down Expand Up @@ -84,6 +85,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