mirror of
https://github.com/SchildiChat/SchildiChat-android.git
synced 2025-03-17 19:58:57 +03:00
Login screens: Captcha step for registration
This commit is contained in:
parent
95fc20dca0
commit
dfbf448bb7
15 changed files with 358 additions and 89 deletions
|
@ -18,15 +18,58 @@ package im.vector.matrix.android.internal.auth.registration
|
|||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import im.vector.matrix.android.internal.auth.data.LoginFlowTypes
|
||||
|
||||
/**
|
||||
* Open class, parent to all possible authentication parameters
|
||||
*/
|
||||
@JsonClass(generateAdapter = true)
|
||||
open class AuthParams(
|
||||
internal data class AuthParams(
|
||||
@Json(name = "type")
|
||||
val type: String,
|
||||
|
||||
@Json(name = "session")
|
||||
val session: String
|
||||
val session: String,
|
||||
|
||||
/**
|
||||
* parameter for "m.login.recaptcha" type
|
||||
*/
|
||||
@Json(name = "response")
|
||||
val captchaResponse: String? = null,
|
||||
|
||||
/**
|
||||
* parameter for "m.login.email.identity" type
|
||||
*/
|
||||
@Json(name = "threepid_creds")
|
||||
val threePidCredentials: ThreePidCredentials? = null
|
||||
) {
|
||||
|
||||
companion object {
|
||||
fun createForCaptcha(session: String, captchaResponse: String): AuthParams {
|
||||
return AuthParams(
|
||||
type = LoginFlowTypes.RECAPTCHA,
|
||||
session = session,
|
||||
captchaResponse = captchaResponse
|
||||
)
|
||||
}
|
||||
|
||||
fun createForEmailIdentity(session: String, threePidCredentials: ThreePidCredentials): AuthParams {
|
||||
return AuthParams(
|
||||
type = LoginFlowTypes.EMAIL_IDENTITY,
|
||||
session = session,
|
||||
threePidCredentials = threePidCredentials
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
data class ThreePidCredentials(
|
||||
@Json(name = "client_secret")
|
||||
val clientSecret: String? = null,
|
||||
|
||||
@Json(name = "id_server")
|
||||
val idServer: String? = null,
|
||||
|
||||
val sid: String? = null
|
||||
)
|
||||
|
|
|
@ -1,30 +0,0 @@
|
|||
/*
|
||||
* Copyright 2018 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package im.vector.matrix.android.internal.auth.registration
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import im.vector.matrix.android.internal.auth.data.LoginFlowTypes
|
||||
|
||||
/**
|
||||
* Class to define the authentication parameters for "m.login.recaptcha" type
|
||||
*/
|
||||
@JsonClass(generateAdapter = true)
|
||||
class AuthParamsCaptcha(session: String,
|
||||
|
||||
@Json(name = "response")
|
||||
val response: String)
|
||||
: AuthParams(LoginFlowTypes.RECAPTCHA, session)
|
|
@ -1,40 +0,0 @@
|
|||
/*
|
||||
* Copyright 2018 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package im.vector.matrix.android.internal.auth.registration
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import im.vector.matrix.android.internal.auth.data.LoginFlowTypes
|
||||
|
||||
/**
|
||||
* Class to define the authentication parameters for "m.login.email.identity" type
|
||||
*/
|
||||
@JsonClass(generateAdapter = true)
|
||||
class AuthParamsEmailIdentity(session: String,
|
||||
|
||||
@Json(name = "threepid_creds")
|
||||
val threePidCredentials: ThreePidCredentials)
|
||||
: AuthParams(LoginFlowTypes.EMAIL_IDENTITY, session)
|
||||
|
||||
data class ThreePidCredentials(
|
||||
@Json(name = "client_secret")
|
||||
val clientSecret: String? = null,
|
||||
|
||||
@Json(name = "id_server")
|
||||
val idServer: String? = null,
|
||||
|
||||
val sid: String? = null
|
||||
)
|
|
@ -70,9 +70,7 @@ internal class DefaultRegistrationWizard(private val homeServerConnectionConfig:
|
|||
|
||||
return performRegistrationRequest(
|
||||
RegistrationParams(
|
||||
auth = AuthParamsCaptcha(
|
||||
session = safeSession,
|
||||
response = response)
|
||||
auth = AuthParams.createForCaptcha(safeSession, response)
|
||||
), callback)
|
||||
}
|
||||
|
||||
|
|
|
@ -53,7 +53,16 @@ data class RegistrationFlowResponse(
|
|||
* For example, the public key of reCAPTCHA stage could be given here.
|
||||
*/
|
||||
@Json(name = "params")
|
||||
var params: JsonDict? = null
|
||||
var params: JsonDict? = null,
|
||||
|
||||
/**
|
||||
* The two MatrixError fields can also be present here in case of error when validating a stage
|
||||
*/
|
||||
@Json(name = "errcode")
|
||||
var code: String? = null,
|
||||
|
||||
@Json(name = "error")
|
||||
var message: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
@ -24,7 +24,7 @@ import com.squareup.moshi.JsonClass
|
|||
* Class to pass parameters to the different registration types for /register.
|
||||
*/
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class RegistrationParams(
|
||||
internal data class RegistrationParams(
|
||||
// authentication parameters
|
||||
@Json(name = "auth")
|
||||
val auth: AuthParams? = null,
|
||||
|
|
22
vector/src/main/assets/reCaptchaPage.html
Normal file
22
vector/src/main/assets/reCaptchaPage.html
Normal file
|
@ -0,0 +1,22 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script type="text/javascript">
|
||||
var verifyCallback = function(response) {
|
||||
var iframe = document.createElement('iframe');
|
||||
iframe.setAttribute('src', 'js:' + JSON.stringify({'action': 'verifyCallback', 'response': response}));
|
||||
document.documentElement.appendChild(iframe);
|
||||
iframe.parentNode.removeChild(iframe);
|
||||
iframe = null;
|
||||
};
|
||||
|
||||
var onloadCallback = function() {
|
||||
grecaptcha.render('recaptcha_widget', { 'sitekey' : '%s', 'callback': verifyCallback });
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="recaptcha_widget"></div>
|
||||
<script src="https://www.google.com/recaptcha/api.js?onload=onloadCallback&render=explicit" async defer></script>
|
||||
</body>
|
||||
</html>
|
|
@ -114,6 +114,11 @@ interface FragmentModule {
|
|||
@FragmentKey(LoginFragment::class)
|
||||
fun bindLoginFragment(fragment: LoginFragment): Fragment
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@FragmentKey(LoginCaptchaFragment::class)
|
||||
fun bindLoginCaptchaFragment(fragment: LoginCaptchaFragment): Fragment
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@FragmentKey(LoginServerUrlFormFragment::class)
|
||||
|
|
|
@ -29,6 +29,7 @@ import im.vector.riotx.EmojiCompatFontProvider
|
|||
import im.vector.riotx.EmojiCompatWrapper
|
||||
import im.vector.riotx.VectorApplication
|
||||
import im.vector.riotx.core.pushers.PushersManager
|
||||
import im.vector.riotx.core.utils.AssetReader
|
||||
import im.vector.riotx.core.utils.DimensionConverter
|
||||
import im.vector.riotx.features.configuration.VectorConfiguration
|
||||
import im.vector.riotx.features.crypto.keysrequest.KeyRequestHandler
|
||||
|
@ -70,6 +71,8 @@ interface VectorComponent {
|
|||
|
||||
fun resources(): Resources
|
||||
|
||||
fun assetReader(): AssetReader
|
||||
|
||||
fun dimensionConverter(): DimensionConverter
|
||||
|
||||
fun vectorConfiguration(): VectorConfiguration
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* Copyright 2018 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.riotx.core.utils
|
||||
|
||||
import android.content.Context
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Read asset files
|
||||
*/
|
||||
class AssetReader @Inject constructor(private val context: Context) {
|
||||
|
||||
/* ==========================================================================================
|
||||
* CACHE
|
||||
* ========================================================================================== */
|
||||
private val cache = mutableMapOf<String, String?>()
|
||||
|
||||
/**
|
||||
* Read an asset from resource and return a String or null in case of error.
|
||||
*
|
||||
* @param assetFilename Asset filename
|
||||
* @return the content of the asset file, or null in case of error
|
||||
*/
|
||||
fun readAssetFile(assetFilename: String): String? {
|
||||
return cache.getOrPut(assetFilename, {
|
||||
return try {
|
||||
context.assets.open(assetFilename)
|
||||
.use { asset ->
|
||||
buildString {
|
||||
var ch = asset.read()
|
||||
while (ch != -1) {
|
||||
append(ch.toChar())
|
||||
ch = asset.read()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "## readAssetFile() failed")
|
||||
null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun clearCache() {
|
||||
cache.clear()
|
||||
}
|
||||
}
|
|
@ -35,7 +35,7 @@ sealed class LoginAction : VectorViewModelAction {
|
|||
data class AddEmail(val email: String) : RegisterAction()
|
||||
data class AddMsisdn(val msisdn: String) : RegisterAction()
|
||||
data class ConfirmMsisdn(val code: String) : RegisterAction()
|
||||
data class PerformCaptcha(val captcha: String /* TODO Add other params */) : RegisterAction()
|
||||
data class CaptchaDone(val captchaResponse: String) : RegisterAction()
|
||||
|
||||
// Reset actions
|
||||
open class ResetAction : LoginAction()
|
||||
|
|
|
@ -201,12 +201,14 @@ class LoginActivity : VectorBaseActivity() {
|
|||
|
||||
private fun doStage(stage: Stage) {
|
||||
when (stage) {
|
||||
is Stage.ReCaptcha -> addFragmentToBackstack(R.id.loginFragmentContainer, LoginCaptchaFragment::class.java)
|
||||
is Stage.ReCaptcha -> addFragmentToBackstack(R.id.loginFragmentContainer,
|
||||
LoginCaptchaFragment::class.java, LoginCaptchaFragmentArgument(stage.publicKey))
|
||||
is Stage.Email -> addFragmentToBackstack(R.id.loginFragmentContainer,
|
||||
LoginGenericTextInputFormFragment::class.java,
|
||||
LoginGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.SetEmail, stage.mandatory))
|
||||
is Stage.Msisdn
|
||||
-> addFragmentToBackstack(R.id.loginFragmentContainer, LoginGenericTextInputFormFragment::class.java,
|
||||
-> addFragmentToBackstack(R.id.loginFragmentContainer,
|
||||
LoginGenericTextInputFormFragment::class.java,
|
||||
LoginGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.SetMsisdn, stage.mandatory))
|
||||
is Stage.Terms
|
||||
-> TODO()
|
||||
|
|
|
@ -16,17 +16,165 @@
|
|||
|
||||
package im.vector.riotx.features.login
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.DialogInterface
|
||||
import android.net.http.SslError
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.webkit.*
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.airbnb.mvrx.args
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import im.vector.matrix.android.internal.di.MoshiProvider
|
||||
import im.vector.riotx.R
|
||||
import im.vector.riotx.core.utils.AssetReader
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
import kotlinx.android.synthetic.main.fragment_login_captcha.*
|
||||
import timber.log.Timber
|
||||
import java.net.URLDecoder
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
|
||||
@Parcelize
|
||||
data class LoginCaptchaFragmentArgument(
|
||||
val siteKey: String
|
||||
) : Parcelable
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class JavascriptResponse(
|
||||
@Json(name = "action")
|
||||
val action: String? = null,
|
||||
@Json(name = "response")
|
||||
val response: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* In this screen, the user is asked to confirm he is not a robot
|
||||
*/
|
||||
class LoginCaptchaFragment @Inject constructor() : AbstractLoginFragment() {
|
||||
class LoginCaptchaFragment @Inject constructor(private val assetReader: AssetReader) : AbstractLoginFragment() {
|
||||
|
||||
override fun getLayoutResId() = R.layout.fragment_login_captcha
|
||||
|
||||
// TODO
|
||||
private val params: LoginCaptchaFragmentArgument by args()
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
setupWebView()
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private fun setupWebView() {
|
||||
loginCaptchaWevView.settings.javaScriptEnabled = true
|
||||
|
||||
val reCaptchaPage = assetReader.readAssetFile("reCaptchaPage.html") ?: error("missing asset reCaptchaPage.html")
|
||||
|
||||
val html = Formatter().format(reCaptchaPage, params.siteKey).toString()
|
||||
val mime = "text/html"
|
||||
val encoding = "utf-8"
|
||||
|
||||
val homeServerUrl = loginViewModel.getHomeServerUrl()
|
||||
loginCaptchaWevView.loadDataWithBaseURL(homeServerUrl, html, mime, encoding, null)
|
||||
loginCaptchaWevView.requestLayout()
|
||||
|
||||
loginCaptchaWevView.webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
super.onPageFinished(view, url)
|
||||
|
||||
// TODO Hide loader
|
||||
}
|
||||
|
||||
override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) {
|
||||
Timber.d("## onReceivedSslError() : " + error.certificate)
|
||||
|
||||
if (!isAdded) {
|
||||
return
|
||||
}
|
||||
|
||||
AlertDialog.Builder(requireActivity())
|
||||
.setMessage(R.string.ssl_could_not_verify)
|
||||
.setPositiveButton(R.string.ssl_trust) { _, _ ->
|
||||
Timber.d("## onReceivedSslError() : the user trusted")
|
||||
handler.proceed()
|
||||
}
|
||||
.setNegativeButton(R.string.ssl_do_not_trust) { _, _ ->
|
||||
Timber.d("## onReceivedSslError() : the user did not trust")
|
||||
handler.cancel()
|
||||
}
|
||||
.setOnKeyListener(DialogInterface.OnKeyListener { dialog, keyCode, event ->
|
||||
if (event.action == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) {
|
||||
handler.cancel()
|
||||
Timber.d("## onReceivedSslError() : the user dismisses the trust dialog.")
|
||||
dialog.dismiss()
|
||||
return@OnKeyListener true
|
||||
}
|
||||
false
|
||||
})
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
}
|
||||
|
||||
// common error message
|
||||
private fun onError(errorMessage: String) {
|
||||
Timber.e("## onError() : $errorMessage")
|
||||
|
||||
// TODO
|
||||
// Toast.makeText(this@AccountCreationCaptchaActivity, errorMessage, Toast.LENGTH_LONG).show()
|
||||
|
||||
// on error case, close this activity
|
||||
// runOnUiThread(Runnable { finish() })
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
override fun onReceivedHttpError(view: WebView, request: WebResourceRequest, errorResponse: WebResourceResponse) {
|
||||
super.onReceivedHttpError(view, request, errorResponse)
|
||||
|
||||
if (request.url.toString().endsWith("favicon.ico")) {
|
||||
// Ignore this error
|
||||
return
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
onError(errorResponse.reasonPhrase)
|
||||
} else {
|
||||
onError(errorResponse.toString())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceivedError(view: WebView, errorCode: Int, description: String, failingUrl: String) {
|
||||
@Suppress("DEPRECATION")
|
||||
super.onReceivedError(view, errorCode, description, failingUrl)
|
||||
onError(description)
|
||||
}
|
||||
|
||||
override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
|
||||
if (url?.startsWith("js:") == true) {
|
||||
var json = url.substring(3)
|
||||
var parameters: JavascriptResponse? = null
|
||||
|
||||
try {
|
||||
// URL decode
|
||||
json = URLDecoder.decode(json, "UTF-8")
|
||||
parameters = MoshiProvider.providesMoshi().adapter(JavascriptResponse::class.java).fromJson(json)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "## shouldOverrideUrlLoading(): failed")
|
||||
}
|
||||
|
||||
val response = parameters?.response
|
||||
if (parameters?.action == "verifyCallback" && response != null) {
|
||||
loginViewModel.handle(LoginAction.CaptchaDone(response))
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
override fun resetViewModel() {
|
||||
// Nothing to do
|
||||
|
|
|
@ -99,6 +99,7 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi
|
|||
private fun handleRegisterAction(action: LoginAction.RegisterAction) {
|
||||
when (action) {
|
||||
is LoginAction.RegisterWith -> handleRegisterWith(action)
|
||||
is LoginAction.CaptchaDone -> handleCaptchaDone(action)
|
||||
// TODO Add other actions here
|
||||
}
|
||||
}
|
||||
|
@ -114,6 +115,44 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi
|
|||
override fun onSuccess(data: RegistrationResult) {
|
||||
isPasswordSent = true
|
||||
|
||||
setState {
|
||||
copy(
|
||||
asyncRegistration = Success(data)
|
||||
)
|
||||
}
|
||||
|
||||
when (data) {
|
||||
is RegistrationResult.Success -> onSessionCreated(data.session)
|
||||
is RegistrationResult.FlowResponse -> onFlowResponse(data.flowResult)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(failure: Throwable) {
|
||||
// TODO Handled JobCancellationException
|
||||
setState {
|
||||
copy(
|
||||
asyncRegistration = Fail(failure)
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun handleCaptchaDone(action: LoginAction.CaptchaDone) {
|
||||
setState {
|
||||
copy(
|
||||
asyncRegistration = Loading()
|
||||
)
|
||||
}
|
||||
|
||||
currentTask = registrationWizard?.performReCaptcha(action.captchaResponse, object : MatrixCallback<RegistrationResult> {
|
||||
override fun onSuccess(data: RegistrationResult) {
|
||||
setState {
|
||||
copy(
|
||||
asyncRegistration = Success(data)
|
||||
)
|
||||
}
|
||||
|
||||
when (data) {
|
||||
is RegistrationResult.Success -> onSessionCreated(data.session)
|
||||
is RegistrationResult.FlowResponse -> onFlowResponse(data.flowResult)
|
||||
|
@ -284,6 +323,12 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi
|
|||
|
||||
currentTask = registrationWizard?.getRegistrationFlow(object : MatrixCallback<RegistrationResult> {
|
||||
override fun onSuccess(data: RegistrationResult) {
|
||||
setState {
|
||||
copy(
|
||||
asyncRegistration = Success(data)
|
||||
)
|
||||
}
|
||||
|
||||
when (data) {
|
||||
is RegistrationResult.Success -> onSessionCreated(data.session)
|
||||
is RegistrationResult.FlowResponse -> onFlowResponse(data.flowResult)
|
||||
|
@ -305,12 +350,6 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi
|
|||
private fun onFlowResponse(flowResult: FlowResult) {
|
||||
// Notify the user
|
||||
_viewEvents.post(LoginViewEvents.RegistrationFlowResult(flowResult))
|
||||
|
||||
setState {
|
||||
copy(
|
||||
asyncRegistration = Uninitialized
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -22,11 +22,19 @@
|
|||
style="@style/LoginTopIcon"
|
||||
android:layout_gravity="center_horizontal" />
|
||||
|
||||
<TextView
|
||||
style="@style/TextAppearance.Vector.Login.Text.Small"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center_horizontal"
|
||||
android:text="@string/auth_recaptcha_message" />
|
||||
|
||||
<WebView
|
||||
android:id="@+id/loginCaptchaWevView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginTop="22dp" />
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue