diff --git a/app/build.gradle b/app/build.gradle index 673c0202845..f857188c39e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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 diff --git a/app/src/main/java/org/wikipedia/captcha/HCaptchaHelper.kt b/app/src/main/java/org/wikipedia/captcha/HCaptchaHelper.kt new file mode 100644 index 00000000000..f8b013bc5b1 --- /dev/null +++ b/app/src/main/java/org/wikipedia/captcha/HCaptchaHelper.kt @@ -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( + 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" + ) + + 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().forEach { + it.isCancelable = cancelable + } + } + } + } +} diff --git a/app/src/main/java/org/wikipedia/createaccount/CreateAccountActivity.kt b/app/src/main/java/org/wikipedia/createaccount/CreateAccountActivity.kt index 287db2f6b0f..6f9d72d6363 100644 --- a/app/src/main/java/org/wikipedia/createaccount/CreateAccountActivity.kt +++ b/app/src/main/java/org/wikipedia/createaccount/CreateAccountActivity.kt @@ -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 @@ -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 @@ -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) @@ -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)) } @@ -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 { @@ -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() { @@ -222,6 +246,7 @@ class CreateAccountActivity : BaseActivity() { } public override fun onDestroy() { + hCaptchaHelper.cleanup() captchaHandler.dispose() userNameTextWatcher?.let { binding.createAccountUsername.editText?.removeTextChangedListener(it) } super.onDestroy() @@ -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) } diff --git a/app/src/main/java/org/wikipedia/createaccount/CreateAccountActivityViewModel.kt b/app/src/main/java/org/wikipedia/createaccount/CreateAccountActivityViewModel.kt index 9a98ed5b718..b6f63c2e0f0 100644 --- a/app/src/main/java/org/wikipedia/createaccount/CreateAccountActivityViewModel.kt +++ b/app/src/main/java/org/wikipedia/createaccount/CreateAccountActivityViewModel.kt @@ -26,6 +26,8 @@ class CreateAccountActivityViewModel : ViewModel() { private val _verifyUserNameState = MutableSharedFlow() val verifyUserNameState = _verifyUserNameState.asSharedFlow() + var token: String? = null + private var verifyUserNameJob: Job? = null fun createAccountInfo() { @@ -33,19 +35,21 @@ class CreateAccountActivityViewModel : ViewModel() { _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) }) { @@ -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() } diff --git a/app/src/main/java/org/wikipedia/dataclient/mwapi/MwAuthManagerInfo.kt b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwAuthManagerInfo.kt index 24faf1ab0a0..d75599747ba 100644 --- a/app/src/main/java/org/wikipedia/dataclient/mwapi/MwAuthManagerInfo.kt +++ b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwAuthManagerInfo.kt @@ -11,7 +11,7 @@ internal class MwAuthManagerInfo { internal class Request(val id: String? = null, private val metadata: Map? = null, private val required: String? = null, - private val provider: String? = null, + val provider: String? = null, private val account: String? = null, val fields: Map? = null) diff --git a/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryResult.kt b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryResult.kt index 428134591f7..59e3791c245 100644 --- a/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryResult.kt +++ b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryResult.kt @@ -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 } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 640cf263ad9..66994411db4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" @@ -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" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 1a6a4962cae..78d1cb4c4e5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,6 +11,7 @@ dependencyResolutionManagement { google() mavenCentral() mavenLocal() + maven { setUrl("https://jitpack.io") } } }