Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
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