diff --git a/app/build.gradle b/app/build.gradle index 891074c7263..ab62cba4e2f 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..4be62238934 --- /dev/null +++ b/app/src/main/java/org/wikipedia/captcha/HCaptchaHelper.kt @@ -0,0 +1,73 @@ +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.HCaptchaTheme +import com.hcaptcha.sdk.HCaptchaTokenResponse +import org.wikipedia.WikipediaApp +import org.wikipedia.settings.RemoteConfig +import org.wikipedia.util.FeedbackUtil +import org.wikipedia.util.log.L +import kotlin.String + +class HCaptchaHelper( + private val activity: FragmentActivity, + private val callback: Callback +) { + fun interface Callback { + fun onSuccess(token: String) + } + + private var hCaptcha: HCaptcha? = null + private var tokenResponse: HCaptchaTokenResponse? = 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" + ) + + 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(true) + .build()) + + hCaptcha?.addOnSuccessListener { response -> + tokenResponse = response + callback.onSuccess(response.tokenResult) + }?.addOnFailureListener { e -> + tokenResponse = null + L.e("hCaptcha failed: ${e.message} (${e.statusCode})") + FeedbackUtil.showMessage(activity, "${e.message} (${e.statusCode})") + }?.addOnOpenListener { + L.d("hCaptcha opened") + } + } + hCaptcha?.verifyWithHCaptcha() + } + + fun cleanup() { + hCaptcha?.reset() + hCaptcha = null + } +} diff --git a/app/src/main/java/org/wikipedia/createaccount/CreateAccountActivity.kt b/app/src/main/java/org/wikipedia/createaccount/CreateAccountActivity.kt index f7e32318a02..4337f47ac10 100644 --- a/app/src/main/java/org/wikipedia/createaccount/CreateAccountActivity.kt +++ b/app/src/main/java/org/wikipedia/createaccount/CreateAccountActivity.kt @@ -27,6 +27,7 @@ import org.wikipedia.analytics.eventplatform.CreateAccountEvent 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.page.PageTitle import org.wikipedia.util.DeviceUtil @@ -49,6 +50,10 @@ class CreateAccountActivity : BaseActivity() { private var userNameTextWatcher: TextWatcher? = null private val viewModel: CreateAccountActivityViewModel by viewModels() + private val hCaptchaHelper = HCaptchaHelper(this) { token -> + doCreateAccount(viewModel.token.orEmpty(), hCaptchaToken = token) + } + public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityCreateAccountBinding.inflate(layoutInflater) @@ -92,6 +97,9 @@ class CreateAccountActivity : BaseActivity() { is CreateAccountActivityViewModel.AccountInfoState.DoCreateAccount -> { doCreateAccount(it.token) } + is CreateAccountActivityViewModel.AccountInfoState.HandleHCaptcha -> { + hCaptchaHelper.show() + } is CreateAccountActivityViewModel.AccountInfoState.HandleCaptcha -> { captchaHandler.handleCaptcha(it.token, CaptchaResult(it.captchaId)) } @@ -195,13 +203,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() { @@ -210,6 +220,7 @@ class CreateAccountActivity : BaseActivity() { } public override fun onDestroy() { + hCaptchaHelper.cleanup() captchaHandler.dispose() userNameTextWatcher?.let { binding.createAccountUsername.editText?.removeTextChangedListener(it) } super.onDestroy() 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 e7f93c64222..7867376c2e1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" @@ -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" } 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") } } }