Login: add SSO support

This commit is contained in:
Benoit Marty 2019-09-13 14:41:30 +02:00 committed by Benoit Marty
parent db8ea0f5e8
commit 5fbd271b1c
12 changed files with 497 additions and 41 deletions

View File

@ -17,6 +17,7 @@
package im.vector.matrix.android.api.auth
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.auth.data.Credentials
import im.vector.matrix.android.api.auth.data.HomeServerConnectionConfig
import im.vector.matrix.android.api.auth.data.SessionParams
import im.vector.matrix.android.api.session.Session
@ -62,4 +63,9 @@ interface Authenticator {
* @return the associated session if any, or null
*/
fun getSession(sessionParams: SessionParams): Session?
/**
* Create a session after a SSO successful login
*/
fun createSessionFromSso(credentials: Credentials, homeServerConnectionConfig: HomeServerConnectionConfig): Session
}

View File

@ -112,6 +112,12 @@ internal class DefaultAuthenticator @Inject constructor(@Unauthenticated
sessionManager.getOrCreateSession(sessionParams)
}
override fun createSessionFromSso(credentials: Credentials, homeServerConnectionConfig: HomeServerConnectionConfig): Session {
val sessionParams = SessionParams(credentials, homeServerConnectionConfig)
sessionParamsStore.save(sessionParams)
return sessionManager.getOrCreateSession(sessionParams)
}
private fun buildAuthAPI(homeServerConnectionConfig: HomeServerConnectionConfig): AuthAPI {
val retrofit = retrofitFactory.create(okHttpClient, homeServerConnectionConfig.homeServerUri.toString())
return retrofit.create(AuthAPI::class.java)

View File

@ -30,4 +30,12 @@ data class InteractiveAuthenticationFlow(
@Json(name = "stages")
val stages: List<String>? = null
)
) {
companion object {
// Possible values for type
const val TYPE_LOGIN_SSO = "m.login.sso"
const val TYPE_LOGIN_TOKEN = "m.login.token"
const val TYPE_LOGIN_PASSWORD = "m.login.password"
}
}

View File

@ -48,6 +48,7 @@ import im.vector.riotx.features.home.room.list.RoomListFragment
import im.vector.riotx.features.invite.VectorInviteView
import im.vector.riotx.features.login.LoginActivity
import im.vector.riotx.features.login.LoginFragment
import im.vector.riotx.features.login.LoginSsoFallbackFragment
import im.vector.riotx.features.media.ImageMediaViewerActivity
import im.vector.riotx.features.media.VideoMediaViewerActivity
import im.vector.riotx.features.navigation.Navigator
@ -127,6 +128,8 @@ interface ScreenComponent {
fun inject(loginFragment: LoginFragment)
fun inject(loginSsoFallbackFragment: LoginSsoFallbackFragment)
fun inject(sasVerificationIncomingFragment: SASVerificationIncomingFragment)
fun inject(quickReactionFragment: QuickReactionFragment)

View File

@ -16,9 +16,12 @@
package im.vector.riotx.features.login
import im.vector.matrix.android.api.auth.data.Credentials
sealed class LoginActions {
data class UpdateHomeServer(val homeServerUrl: String) : LoginActions()
data class Login(val login: String, val password: String) : LoginActions()
data class SsoLoginSuccess(val credentials: Credentials) : LoginActions()
}

View File

@ -18,16 +18,30 @@ package im.vector.riotx.features.login
import android.content.Context
import android.content.Intent
import androidx.fragment.app.FragmentManager
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.viewModel
import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.extensions.addFragment
import im.vector.riotx.core.extensions.addFragmentToBackstack
import im.vector.riotx.core.extensions.observeEvent
import im.vector.riotx.core.platform.VectorBaseActivity
import im.vector.riotx.features.disclaimer.showDisclaimerDialog
import im.vector.riotx.features.home.HomeActivity
import javax.inject.Inject
class LoginActivity : VectorBaseActivity() {
// Supported navigation actions for this Activity
sealed class Navigation {
object OpenSsoLoginFallback : Navigation()
object GoBack : Navigation()
}
private val loginViewModel: LoginViewModel by viewModel()
@Inject lateinit var loginViewModelFactory: LoginViewModel.Factory
@ -41,6 +55,21 @@ class LoginActivity : VectorBaseActivity() {
if (isFirstCreation()) {
addFragment(LoginFragment(), R.id.simpleFragmentContainer)
}
loginViewModel.navigationLiveData.observeEvent(this) {
when (it) {
is Navigation.OpenSsoLoginFallback -> addFragmentToBackstack(LoginSsoFallbackFragment(), R.id.simpleFragmentContainer)
is Navigation.GoBack -> supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
}
}
loginViewModel.selectSubscribe(this, LoginViewState::asyncLoginAction) {
if (it is Success) {
val intent = HomeActivity.newIntent(this)
startActivity(intent)
finish()
}
}
}

View File

@ -20,6 +20,7 @@ import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.transition.TransitionManager
import com.airbnb.mvrx.*
import com.jakewharton.rxbinding3.view.focusChanges
import com.jakewharton.rxbinding3.widget.textChanges
@ -30,12 +31,11 @@ import im.vector.riotx.core.extensions.setTextWithColoredPart
import im.vector.riotx.core.extensions.showPassword
import im.vector.riotx.core.platform.VectorBaseFragment
import im.vector.riotx.core.utils.openUrlInExternalBrowser
import im.vector.riotx.features.home.HomeActivity
import im.vector.riotx.features.homeserver.ServerUrlsRepository
import io.reactivex.Observable
import io.reactivex.functions.Function3
import io.reactivex.rxkotlin.subscribeBy
import kotlinx.android.synthetic.main.activity_login.*
import kotlinx.android.synthetic.main.fragment_login.*
import javax.inject.Inject
@ -51,7 +51,7 @@ class LoginFragment : VectorBaseFragment() {
@Inject lateinit var errorFormatter: ErrorFormatter
override fun getLayoutResId() = R.layout.activity_login
override fun getLayoutResId() = R.layout.fragment_login
override fun injectWith(injector: ScreenComponent) {
injector.inject(this)
@ -107,6 +107,12 @@ class LoginFragment : VectorBaseFragment() {
.subscribeBy { authenticateButton.isEnabled = it }
.disposeOnDestroy()
authenticateButton.setOnClickListener { authenticate() }
authenticateButtonSso.setOnClickListener { openSso() }
}
private fun openSso() {
viewModel.openSso()
}
private fun setupPasswordReveal() {
@ -128,25 +134,53 @@ class LoginFragment : VectorBaseFragment() {
}
override fun invalidate() = withState(viewModel) { state ->
TransitionManager.beginDelayedTransition(login_fragment)
when (state.asyncHomeServerLoginFlowRequest) {
is Loading -> {
is Incomplete -> {
progressBar.isVisible = true
touchArea.isVisible = true
loginField.isVisible = false
passwordContainer.isVisible = false
authenticateButton.isVisible = false
authenticateButtonSso.isVisible = false
passwordShown = false
renderPasswordField()
}
is Fail -> {
is Fail -> {
progressBar.isVisible = false
touchArea.isVisible = false
loginField.isVisible = false
passwordContainer.isVisible = false
authenticateButton.isVisible = false
authenticateButtonSso.isVisible = false
Toast.makeText(requireActivity(), "Authenticate failure: ${state.asyncHomeServerLoginFlowRequest.error}", Toast.LENGTH_LONG).show()
}
is Success -> {
is Success -> {
progressBar.isVisible = false
touchArea.isVisible = false
// Check login flow
// TODO
when (state.asyncHomeServerLoginFlowRequest()) {
LoginMode.Password -> {
loginField.isVisible = true
passwordContainer.isVisible = true
authenticateButton.isVisible = true
authenticateButtonSso.isVisible = false
}
LoginMode.Sso -> {
loginField.isVisible = false
passwordContainer.isVisible = false
authenticateButton.isVisible = false
authenticateButtonSso.isVisible = true
}
LoginMode.Unsupported -> {
loginField.isVisible = false
passwordContainer.isVisible = false
authenticateButton.isVisible = false
authenticateButtonSso.isVisible = false
Toast.makeText(requireActivity(), "None of the homeserver login mode is supported by RiotX", Toast.LENGTH_LONG).show()
}
}
}
}
@ -163,11 +197,8 @@ class LoginFragment : VectorBaseFragment() {
touchArea.isVisible = false
Toast.makeText(requireActivity(), "Authenticate failure: ${state.asyncLoginAction.error}", Toast.LENGTH_LONG).show()
}
is Success -> {
val intent = HomeActivity.newIntent(requireActivity())
startActivity(intent)
requireActivity().finish()
}
// Success is handled by the LoginActivity
is Success -> Unit
}
}
}

View File

@ -0,0 +1,303 @@
/*
* Copyright 2019 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.features.login
import android.annotation.SuppressLint
import android.content.DialogInterface
import android.graphics.Bitmap
import android.net.http.SslError
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import android.view.View
import android.webkit.SslErrorHandler
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.app.AlertDialog
import com.airbnb.mvrx.activityViewModel
import im.vector.matrix.android.api.auth.data.Credentials
import im.vector.matrix.android.internal.di.MoshiProvider
import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.platform.VectorBaseFragment
import kotlinx.android.synthetic.main.fragment_login_sso_fallback.*
import timber.log.Timber
import java.net.URLDecoder
/**
* Only login is supported for the moment
*/
class LoginSsoFallbackFragment : VectorBaseFragment() {
private val viewModel: LoginViewModel by activityViewModel()
var homeServerUrl: String = ""
enum class Mode {
MODE_LOGIN,
// Not supported in RiotX for the moment
MODE_REGISTER
}
// Mode (MODE_LOGIN or MODE_REGISTER)
private var mMode = Mode.MODE_LOGIN
override fun getLayoutResId() = R.layout.fragment_login_sso_fallback
override fun injectWith(injector: ScreenComponent) {
injector.inject(this)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupToolbar(login_sso_fallback_toolbar)
requireActivity().setTitle(R.string.login)
setupWebview()
}
@SuppressLint("SetJavaScriptEnabled")
private fun setupWebview() {
login_sso_fallback_webview.settings.javaScriptEnabled = true
// Due to https://developers.googleblog.com/2016/08/modernizing-oauth-interactions-in-native-apps.html, we hack
// the user agent to bypass the limitation of Google, as a quick fix (a proper solution will be to use the SSO SDK)
login_sso_fallback_webview.settings.userAgentString = "Mozilla/5.0 Google"
homeServerUrl = viewModel.getHomeServerUrl()
if (!homeServerUrl.endsWith("/")) {
homeServerUrl += "/"
}
// AppRTC requires third party cookies to work
val cookieManager = android.webkit.CookieManager.getInstance()
// clear the cookies must be cleared
if (cookieManager == null) {
launchWebView()
} else {
if (!cookieManager.hasCookies()) {
launchWebView()
} else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
try {
cookieManager.removeAllCookie()
} catch (e: Exception) {
Timber.e(e, " cookieManager.removeAllCookie() fails")
}
launchWebView()
} else {
try {
cookieManager.removeAllCookies { launchWebView() }
} catch (e: Exception) {
Timber.e(e, " cookieManager.removeAllCookie() fails")
launchWebView()
}
}
}
}
private fun launchWebView() {
if (mMode == Mode.MODE_LOGIN) {
login_sso_fallback_webview.loadUrl(homeServerUrl + "_matrix/static/client/login/")
} else {
// MODE_REGISTER
login_sso_fallback_webview.loadUrl(homeServerUrl + "_matrix/static/client/register/")
}
login_sso_fallback_webview.webViewClient = object : WebViewClient() {
override fun onReceivedSslError(view: WebView, handler: SslErrorHandler,
error: SslError) {
AlertDialog.Builder(requireActivity())
.setMessage(R.string.ssl_could_not_verify)
.setPositiveButton(R.string.ssl_trust) { dialog, which -> handler.proceed() }
.setNegativeButton(R.string.ssl_do_not_trust) { dialog, which -> handler.cancel() }
.setOnKeyListener(DialogInterface.OnKeyListener { dialog, keyCode, event ->
if (event.action == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) {
handler.cancel()
dialog.dismiss()
return@OnKeyListener true
}
false
})
.show()
}
override fun onReceivedError(view: WebView, errorCode: Int, description: String, failingUrl: String) {
super.onReceivedError(view, errorCode, description, failingUrl)
// on error case, close this activity
viewModel.goBack()
}
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
login_sso_fallback_toolbar.subtitle = url
}
override fun onPageFinished(view: WebView, url: String) {
// avoid infinite onPageFinished call
if (url.startsWith("http")) {
// Generic method to make a bridge between JS and the UIWebView
val mxcJavascriptSendObjectMessage = "javascript:window.sendObjectMessage = function(parameters) {" +
" var iframe = document.createElement('iframe');" +
" iframe.setAttribute('src', 'js:' + JSON.stringify(parameters));" +
" document.documentElement.appendChild(iframe);" +
" iframe.parentNode.removeChild(iframe); iframe = null;" +
" };"
view.loadUrl(mxcJavascriptSendObjectMessage)
if (mMode == Mode.MODE_LOGIN) {
// The function the fallback page calls when the login is complete
val mxcJavascriptOnRegistered = "javascript:window.matrixLogin.onLogin = function(response) {" +
" sendObjectMessage({ 'action': 'onLogin', 'credentials': response });" +
" };"
view.loadUrl(mxcJavascriptOnRegistered)
} else {
// MODE_REGISTER
// The function the fallback page calls when the registration is complete
val mxcJavascriptOnRegistered = "javascript:window.matrixRegistration.onRegistered" +
" = function(homeserverUrl, userId, accessToken) {" +
" sendObjectMessage({ 'action': 'onRegistered'," +
" 'homeServer': homeserverUrl," +
" 'userId': userId," +
" 'accessToken': accessToken });" +
" };"
view.loadUrl(mxcJavascriptOnRegistered)
}
}
}
/**
* Example of (formatted) url for MODE_LOGIN:
*
* <pre>
* js:{
* "action":"onLogin",
* "credentials":{
* "user_id":"@user:matrix.org",
* "access_token":"[ACCESS_TOKEN]",
* "home_server":"matrix.org",
* "device_id":"[DEVICE_ID]",
* "well_known":{
* "m.homeserver":{
* "base_url":"https://matrix.org/"
* }
* }
* }
* }
* </pre>
* @param view
* @param url
* @return
*/
override fun shouldOverrideUrlLoading(view: WebView, url: String?): Boolean {
if (null != url && url.startsWith("js:")) {
var json = url.substring(3)
var parameters: Map<String, Any>? = null
try {
// URL decode
json = URLDecoder.decode(json, "UTF-8")
val adapter = MoshiProvider.providesMoshi().adapter(Map::class.java)
parameters = adapter.fromJson(json) as Map<String, Any>?
} catch (e: Exception) {
Timber.e(e, "## shouldOverrideUrlLoading() : fromJson failed")
}
// succeeds to parse parameters
if (parameters != null) {
val action = parameters["action"] as String
if (mMode == Mode.MODE_LOGIN) {
try {
if (action == "onLogin") {
val credentials = parameters["credentials"] as Map<String, String>
val userId = credentials["user_id"]
val accessToken = credentials["access_token"]
val homeServer = credentials["home_server"]
val deviceId = credentials["device_id"]
// check if the parameters are defined
if (null != homeServer && null != userId && null != accessToken) {
val credentials = Credentials(
userId = userId,
accessToken = accessToken,
homeServer = homeServer,
deviceId = deviceId,
refreshToken = null
)
viewModel.handle(LoginActions.SsoLoginSuccess(credentials))
}
}
} catch (e: Exception) {
Timber.e(e, "## shouldOverrideUrlLoading() : failed")
}
} else {
// MODE_REGISTER
// check the required parameters
if (action == "onRegistered") {
// TODO The keys are very strange, this code comes from Riot-Android...
if (parameters.containsKey("homeServer")
&& parameters.containsKey("userId")
&& parameters.containsKey("accessToken")) {
// We cannot parse Credentials here because of https://github.com/matrix-org/synapse/issues/4756
// Build on object manually
val credentials = Credentials(
userId = parameters["userId"] as String,
accessToken = parameters["accessToken"] as String,
homeServer = parameters["homeServer"] as String,
// TODO We need deviceId on RiotX...
deviceId = "TODO",
refreshToken = null
)
viewModel.handle(LoginActions.SsoLoginSuccess(credentials))
}
}
}
}
return true
}
return super.shouldOverrideUrlLoading(view, url)
}
}
}
override fun onBackPressed(): Boolean {
return if (login_sso_fallback_webview.canGoBack()) {
login_sso_fallback_webview.goBack()
true
} else {
false
}
}
}

View File

@ -16,6 +16,8 @@
package im.vector.riotx.features.login
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import arrow.core.Try
import com.airbnb.mvrx.*
import com.squareup.inject.assisted.Assisted
@ -25,10 +27,12 @@ import im.vector.matrix.android.api.auth.Authenticator
import im.vector.matrix.android.api.auth.data.HomeServerConnectionConfig
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.internal.auth.data.InteractiveAuthenticationFlow
import im.vector.matrix.android.internal.auth.data.LoginFlowResponse
import im.vector.riotx.core.di.ActiveSessionHolder
import im.vector.riotx.core.extensions.configureAndStart
import im.vector.riotx.core.platform.VectorViewModel
import im.vector.riotx.core.utils.LiveEvent
import im.vector.riotx.features.notifications.PushRuleTriggerListener
class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginViewState,
@ -50,9 +54,9 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi
}
}
init {
}
private val _navigationLiveData = MutableLiveData<LiveEvent<LoginActivity.Navigation>>()
val navigationLiveData: LiveData<LiveEvent<LoginActivity.Navigation>>
get() = _navigationLiveData
var homeServerConnectionConfig: HomeServerConnectionConfig? = null
private var currentTask: Cancelable? = null
@ -62,6 +66,7 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi
when (action) {
is LoginActions.UpdateHomeServer -> handleUpdateHomeserver(action)
is LoginActions.Login -> handleLogin(action)
is LoginActions.SsoLoginSuccess -> handleSsoLoginSuccess(action)
}
}
@ -83,14 +88,7 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi
authenticator.authenticate(homeServerConnectionConfigFinal, action.login, action.password, object : MatrixCallback<Session> {
override fun onSuccess(data: Session) {
activeSessionHolder.setActiveSession(data)
data.configureAndStart(pushRuleTriggerListener)
setState {
copy(
asyncLoginAction = Success(Unit)
)
}
onSessionCreated(data)
}
override fun onFailure(failure: Throwable) {
@ -104,6 +102,24 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi
}
}
private fun onSessionCreated(session: Session) {
activeSessionHolder.setActiveSession(session)
session.configureAndStart(pushRuleTriggerListener)
setState {
copy(
asyncLoginAction = Success(Unit)
)
}
}
private fun handleSsoLoginSuccess(action: LoginActions.SsoLoginSuccess) {
val session = authenticator.createSessionFromSso(action.credentials, homeServerConnectionConfig!!)
onSessionCreated(session)
}
private fun handleUpdateHomeserver(action: LoginActions.UpdateHomeServer) {
currentTask?.cancel()
@ -120,18 +136,16 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi
// This is invalid
setState {
copy(
asyncHomeServerLoginFlowRequest = Fail(Throwable("Baf format"))
asyncHomeServerLoginFlowRequest = Fail(Throwable("Bad format"))
)
}
} else {
setState {
copy(
asyncHomeServerLoginFlowRequest = Loading()
)
}
currentTask = authenticator.getLoginFlow(homeServerConnectionConfigFinal, object : MatrixCallback<LoginFlowResponse> {
override fun onFailure(failure: Throwable) {
setState {
@ -142,28 +156,41 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi
}
override fun onSuccess(data: LoginFlowResponse) {
setState {
copy(
asyncHomeServerLoginFlowRequest = Success(data)
)
val loginMode = when {
// SSO login is taken first
data.flows.any { it.type == InteractiveAuthenticationFlow.TYPE_LOGIN_SSO } -> LoginMode.Sso
data.flows.any { it.type == InteractiveAuthenticationFlow.TYPE_LOGIN_PASSWORD } -> LoginMode.Password
else -> LoginMode.Unsupported
}
handleLoginFlowResponse(data)
setState {
copy(
asyncHomeServerLoginFlowRequest = Success(loginMode)
)
}
}
})
}
}
private fun handleLoginFlowResponse(loginFlowResponse: LoginFlowResponse) {
}
override fun onCleared() {
super.onCleared()
currentTask?.cancel()
}
fun openSso() {
// Navigate to SSO
_navigationLiveData.postValue(LiveEvent(LoginActivity.Navigation.OpenSsoLoginFallback))
}
fun goBack() {
// Navigate back
_navigationLiveData.postValue(LiveEvent(LoginActivity.Navigation.GoBack))
}
fun getHomeServerUrl(): String {
return homeServerConnectionConfig?.homeServerUri?.toString() ?: ""
}
}

View File

@ -19,9 +19,15 @@ package im.vector.riotx.features.login
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.Uninitialized
import im.vector.matrix.android.internal.auth.data.LoginFlowResponse
data class LoginViewState(
val asyncLoginAction: Async<Unit> = Uninitialized,
val asyncHomeServerLoginFlowRequest: Async<LoginFlowResponse> = Uninitialized
val asyncHomeServerLoginFlowRequest: Async<LoginMode> = Uninitialized
) : MvRxState
enum class LoginMode {
Password,
Sso,
Unsupported
}

View File

@ -1,6 +1,7 @@
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/login_fragment"
android:layout_width="match_parent"
android:layout_height="match_parent">
@ -54,6 +55,7 @@
</com.google.android.material.textfield.TextInputLayout>
<FrameLayout
android:id="@+id/passwordContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp">
@ -114,6 +116,16 @@
android:layout_marginTop="22dp"
android:text="@string/auth_login" />
<com.google.android.material.button.MaterialButton
android:id="@+id/authenticateButtonSso"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginTop="22dp"
android:text="@string/auth_login_sso"
android:visibility="gone"
tools:visibility="visible" />
</LinearLayout>
</ScrollView>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.appcompat.widget.Toolbar
android:id="@+id/login_sso_fallback_toolbar"
style="@style/VectorToolbarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:subtitle="https://www.example.org"
tools:title="@string/auth_login" />
<WebView
android:id="@+id/login_sso_fallback_webview"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>