Login screens: handle loading Views and global navigation - WIP

This commit is contained in:
Benoit Marty 2019-11-14 21:20:45 +01:00
parent 3c93807fe6
commit c6b0ae63ea
15 changed files with 194 additions and 174 deletions

View file

@ -116,6 +116,12 @@ interface FragmentModule {
@FragmentKey(LoginFragment::class)
fun bindLoginFragment(fragment: LoginFragment): Fragment
// TODO Add all other Login Fragments
@Binds
@IntoMap
@FragmentKey(LoginServerUrlFormFragment::class)
fun bindLoginServerUrlFormFragment(fragment: LoginServerUrlFormFragment): Fragment
@Binds
@IntoMap
@FragmentKey(LoginSsoFallbackFragment::class)
@ -195,10 +201,4 @@ interface FragmentModule {
@IntoMap
@FragmentKey(PublicRoomsFragment::class)
fun bindPublicRoomsFragment(fragment: PublicRoomsFragment): Fragment
// TODO Add all other LoginFragment
@Binds
@IntoMap
@FragmentKey(LoginServerUrlFormFragment::class)
fun bindLoginServerUrlFormFragment(fragment: LoginServerUrlFormFragment): Fragment
}

View file

@ -41,12 +41,19 @@ class ErrorFormatter @Inject constructor(private val stringProvider: StringProvi
}
}
is Failure.ServerError -> {
if (throwable.error.code == MatrixError.M_CONSENT_NOT_GIVEN) {
// Special case for terms and conditions
stringProvider.getString(R.string.error_terms_not_accepted)
} else {
throwable.error.message.takeIf { it.isNotEmpty() }
?: throwable.error.code.takeIf { it.isNotEmpty() }
when {
throwable.error.code == MatrixError.M_CONSENT_NOT_GIVEN -> {
// Special case for terms and conditions
stringProvider.getString(R.string.error_terms_not_accepted)
}
throwable.error.code == MatrixError.FORBIDDEN
&& throwable.error.message == "Invalid password" -> {
stringProvider.getString(R.string.auth_invalid_login_param)
}
else -> {
throwable.error.message.takeIf { it.isNotEmpty() }
?: throwable.error.code.takeIf { it.isNotEmpty() }
}
}
}
else -> throwable.localizedMessage

View file

@ -18,6 +18,8 @@ package im.vector.riotx.features.login
import android.content.Context
import android.content.Intent
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.viewModel
@ -29,8 +31,12 @@ import im.vector.riotx.core.extensions.addFragmentToBackstack
import im.vector.riotx.core.platform.VectorBaseActivity
import im.vector.riotx.features.disclaimer.showDisclaimerDialog
import im.vector.riotx.features.home.HomeActivity
import kotlinx.android.synthetic.main.activity_login.*
import javax.inject.Inject
/**
* The LoginActivity manages the fragment navigation and also display the loading View
*/
class LoginActivity : VectorBaseActivity() {
private val loginViewModel: LoginViewModel by viewModel()
@ -42,16 +48,17 @@ class LoginActivity : VectorBaseActivity() {
injector.inject(this)
}
override fun getLayoutRes() = R.layout.activity_simple
override fun getLayoutRes() = R.layout.activity_login
override fun initUiAndData() {
if (isFirstCreation()) {
addFragment(R.id.simpleFragmentContainer, LoginSplashFragment::class.java)
addFragment(R.id.loginFragmentContainer, LoginSplashFragment::class.java)
}
// Get config extra
val loginConfig = intent.getParcelableExtra<LoginConfig?>(EXTRA_CONFIG)
if (loginConfig != null && isFirstCreation()) {
// TODO Check this
loginViewModel.handle(LoginAction.InitWith(loginConfig))
}
@ -59,29 +66,59 @@ class LoginActivity : VectorBaseActivity() {
loginSharedActionViewModel.observe()
.subscribe {
when (it) {
is LoginNavigation.OpenServerSelection -> addFragmentToBackstack(R.id.simpleFragmentContainer, LoginServerSelectionFragment::class.java)
is LoginNavigation.OnServerSelectionDone -> onServerSelectionDone()
is LoginNavigation.OnSignModeSelected -> onSignModeSelected(it)
is LoginNavigation.OpenSsoLoginFallback -> addFragmentToBackstack(R.id.simpleFragmentContainer, LoginSsoFallbackFragment::class.java)
is LoginNavigation.GoBack -> supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
is LoginNavigation.OpenServerSelection -> addFragmentToBackstack(R.id.loginFragmentContainer, LoginServerSelectionFragment::class.java)
is LoginNavigation.OnServerSelectionDone -> onServerSelectionDone()
is LoginNavigation.OnSignModeSelected -> onSignModeSelected(it)
is LoginNavigation.OnLoginFlowRetrieved -> onLoginFlowRetrieved(it)
is LoginNavigation.OnSsoLoginFallbackError -> onSsoLoginFallbackError(it)
}
}
.disposeOnDestroy()
loginViewModel.selectSubscribe(this, LoginViewState::asyncLoginAction) {
if (it is Success) {
val intent = HomeActivity.newIntent(this)
startActivity(intent)
finish()
}
loginViewModel
.subscribe(this) {
updateWithState(it)
}
.disposeOnDestroy()
}
private fun onLoginFlowRetrieved(onLoginFlowRetrieved: LoginNavigation.OnLoginFlowRetrieved) {
when (onLoginFlowRetrieved.loginMode) {
LoginMode.Sso -> addFragmentToBackstack(R.id.loginFragmentContainer, LoginSsoFallbackFragment::class.java)
LoginMode.Unsupported,
LoginMode.Password -> addFragmentToBackstack(R.id.loginFragmentContainer, LoginSignUpSignInSelectionFragment::class.java)
}
}
private fun updateWithState(loginViewState: LoginViewState) {
if (loginViewState.asyncLoginAction is Success) {
val intent = HomeActivity.newIntent(this)
startActivity(intent)
finish()
return
}
// Loading
loginLoading.isVisible = loginViewState.isLoading()
}
private fun onSsoLoginFallbackError(onSsoLoginFallbackError: LoginNavigation.OnSsoLoginFallbackError) {
// Pop the backstack
supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
// And inform the user
AlertDialog.Builder(this)
.setTitle(R.string.dialog_title_error)
.setMessage(getString(R.string.login_sso_error_message, onSsoLoginFallbackError.description, onSsoLoginFallbackError.errorCode))
.setPositiveButton(R.string.ok, null)
.show()
}
private fun onServerSelectionDone() = withState(loginViewModel) {
when (it.serverType) {
ServerType.MatrixOrg -> addFragmentToBackstack(R.id.simpleFragmentContainer, LoginSignUpSignInSelectionFragment::class.java)
ServerType.MatrixOrg -> Unit // In this case, we wait for the login flow
ServerType.Modular,
ServerType.Other -> addFragmentToBackstack(R.id.simpleFragmentContainer, LoginServerUrlFormFragment::class.java)
ServerType.Other -> addFragmentToBackstack(R.id.loginFragmentContainer, LoginServerUrlFormFragment::class.java)
}
}
@ -89,8 +126,8 @@ class LoginActivity : VectorBaseActivity() {
// We cannot use the state, it is not ready...
when (mode.signMode) {
SignMode.Unknown -> error("Sign mode has to be set before calling this method")
SignMode.SignUp -> Unit // TODO addFragmentToBackstack(R.id.simpleFragmentContainer, SignUpFragment::class.java)
SignMode.SignIn -> addFragmentToBackstack(R.id.simpleFragmentContainer, LoginFragment::class.java)
SignMode.SignUp -> Unit // TODO addFragmentToBackstack(R.id.loginFragmentContainer, SignUpFragment::class.java)
SignMode.SignIn -> addFragmentToBackstack(R.id.loginFragmentContainer, LoginFragment::class.java)
}
}

View file

@ -18,12 +18,14 @@ package im.vector.riotx.features.login
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.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.withState
import com.jakewharton.rxbinding3.widget.textChanges
import im.vector.riotx.R
import im.vector.riotx.core.error.ErrorFormatter
import im.vector.riotx.core.extensions.showPassword
import io.reactivex.Observable
import io.reactivex.functions.BiFunction
@ -35,7 +37,9 @@ import javax.inject.Inject
* In this screen, the user is asked for login and password to sign in to a homeserver.
* He also can reset his password
*/
class LoginFragment @Inject constructor() : AbstractLoginFragment() {
class LoginFragment @Inject constructor(
private val errorFormatter: ErrorFormatter
) : AbstractLoginFragment() {
// TODO Move to viewState?
private var passwordShown = false
@ -70,10 +74,10 @@ class LoginFragment @Inject constructor() : AbstractLoginFragment() {
authenticateButton.setOnClickListener { authenticate() }
}
// TODO Move to server selection screen
private fun openSso() {
loginSharedActionViewModel.post(LoginNavigation.OpenSsoLoginFallback)
}
// // TODO Move to server selection screen
// private fun openSso() {
// loginSharedActionViewModel.post(LoginNavigation.OpenSsoLoginFallback)
// }
private fun setupPasswordReveal() {
passwordShown = false
@ -106,65 +110,16 @@ class LoginFragment @Inject constructor() : AbstractLoginFragment() {
override fun invalidate() = withState(viewModel) { state ->
TransitionManager.beginDelayedTransition(login_fragment)
when (state.asyncHomeServerLoginFlowRequest) {
is Incomplete -> {
progressBar.isVisible = true
touchArea.isVisible = true
loginField.isVisible = false
passwordContainer.isVisible = false
authenticateButton.isVisible = false
passwordShown = false
renderPasswordField()
}
is Fail -> {
progressBar.isVisible = false
touchArea.isVisible = false
loginField.isVisible = false
passwordContainer.isVisible = false
authenticateButton.isVisible = false
Toast.makeText(requireActivity(), "Authenticate failure: ${state.asyncHomeServerLoginFlowRequest.error}", Toast.LENGTH_LONG).show()
}
is Success -> {
progressBar.isVisible = false
touchArea.isVisible = false
when (state.asyncHomeServerLoginFlowRequest()) {
LoginMode.Password -> {
loginField.isVisible = true
passwordContainer.isVisible = true
authenticateButton.isVisible = true
if (loginField.text.isNullOrBlank() && passwordField.text.isNullOrBlank()) {
// Jump focus to login
loginField.requestFocus()
}
}
LoginMode.Sso -> {
loginField.isVisible = false
passwordContainer.isVisible = false
authenticateButton.isVisible = false
}
LoginMode.Unsupported -> {
loginField.isVisible = false
passwordContainer.isVisible = false
authenticateButton.isVisible = false
Toast.makeText(requireActivity(), "None of the homeserver login mode is supported by RiotX", Toast.LENGTH_LONG).show()
}
}
}
}
when (state.asyncLoginAction) {
is Loading -> {
progressBar.isVisible = true
touchArea.isVisible = true
// Ensure password is hidden
passwordShown = false
renderPasswordField()
}
is Fail -> {
progressBar.isVisible = false
touchArea.isVisible = false
Toast.makeText(requireActivity(), "Authenticate failure: ${state.asyncLoginAction.error}", Toast.LENGTH_LONG).show()
// TODO Handle error text properly
// TODO Reset error when text is changed
passwordFieldTil.error = errorFormatter.toHumanReadable(state.asyncLoginAction.error)
}
// Success is handled by the LoginActivity
is Success -> Unit

View file

@ -22,7 +22,8 @@ import im.vector.riotx.core.platform.VectorSharedAction
sealed class LoginNavigation : VectorSharedAction {
object OpenServerSelection : LoginNavigation()
object OnServerSelectionDone : LoginNavigation()
data class OnLoginFlowRetrieved(val loginMode: LoginMode) : LoginNavigation()
data class OnSignModeSelected(val signMode: SignMode) : LoginNavigation()
object OpenSsoLoginFallback : LoginNavigation()
object GoBack : LoginNavigation()
//object OpenSsoLoginFallback : LoginNavigation()
data class OnSsoLoginFallbackError(val errorCode: Int, val description: String, val failingUrl: String) : LoginNavigation()
}

View file

@ -19,6 +19,8 @@ package im.vector.riotx.features.login
import android.os.Bundle
import android.view.View
import butterknife.OnClick
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.withState
import im.vector.riotx.R
import im.vector.riotx.core.utils.openUrlInExternalBrowser
@ -73,8 +75,13 @@ class LoginServerSelectionFragment @Inject constructor() : AbstractLoginFragment
}
@OnClick(R.id.loginServerSubmit)
fun submit() {
loginSharedActionViewModel.post(LoginNavigation.OnServerSelectionDone)
fun submit() = withState(viewModel) {
if (it.serverType == ServerType.MatrixOrg) {
// Request login flow here
viewModel.handle(LoginAction.UpdateHomeServer(getString(R.string.matrix_org_server_url)))
} else {
loginSharedActionViewModel.post(LoginNavigation.OnServerSelectionDone)
}
}
override fun resetViewModel() {
@ -83,5 +90,15 @@ class LoginServerSelectionFragment @Inject constructor() : AbstractLoginFragment
override fun invalidate() = withState(viewModel) {
updateSelectedChoice(it.serverType)
when (it.asyncHomeServerLoginFlowRequest) {
is Fail -> {
// TODO Display error in a dialog?
}
is Success -> {
// The home server url is valid
loginSharedActionViewModel.post(LoginNavigation.OnLoginFlowRetrieved(it.asyncHomeServerLoginFlowRequest.invoke()))
}
}
}
}

View file

@ -22,7 +22,9 @@ import android.view.View
import android.view.inputmethod.EditorInfo
import androidx.core.view.isVisible
import butterknife.OnClick
import com.airbnb.mvrx.*
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.withState
import com.jakewharton.rxbinding3.widget.textChanges
import im.vector.riotx.R
import im.vector.riotx.core.error.ErrorFormatter
@ -115,22 +117,13 @@ class LoginServerUrlFormFragment @Inject constructor(
}
when (state.asyncHomeServerLoginFlowRequest) {
is Uninitialized -> {
progressBar.isVisible = false
touchArea.isVisible = false
}
is Loading -> {
progressBar.isVisible = true
touchArea.isVisible = true
}
is Fail -> {
progressBar.isVisible = false
touchArea.isVisible = false
is Fail -> {
// TODO Error text is not correct
loginServerUrlFormHomeServerUrlTil.error = errorFormatter.toHumanReadable(state.asyncHomeServerLoginFlowRequest.error)
}
is Success -> {
// The home server is valid, the next screen will be opened by the Activity
is Success -> {
// The home server url is valid
loginSharedActionViewModel.post(LoginNavigation.OnLoginFlowRetrieved(state.asyncHomeServerLoginFlowRequest.invoke()))
}
}
}

View file

@ -55,13 +55,13 @@ class LoginSignUpSignInSelectionFragment @Inject constructor() : AbstractLoginFr
@OnClick(R.id.loginSignupSigninSignUp)
fun signUp() {
viewModel.handle(LoginAction.UpdateSignMode(SignMode.SignUp))
loginSharedActionViewModel.post(LoginNavigation.OnSignModeSelected)
loginSharedActionViewModel.post(LoginNavigation.OnSignModeSelected(SignMode.SignUp))
}
@OnClick(R.id.loginSignupSigninSignIn)
fun signIn() {
viewModel.handle(LoginAction.UpdateSignMode(SignMode.SignIn))
loginSharedActionViewModel.post(LoginNavigation.OnSignModeSelected)
loginSharedActionViewModel.post(LoginNavigation.OnSignModeSelected(SignMode.SignIn))
}
override fun resetViewModel() {

View file

@ -30,13 +30,10 @@ 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.api.util.JsonDict
import im.vector.matrix.android.internal.di.MoshiProvider
import im.vector.riotx.R
import im.vector.riotx.core.platform.OnBackPressed
import im.vector.riotx.core.platform.VectorBaseFragment
import kotlinx.android.synthetic.main.fragment_login_sso_fallback.*
import timber.log.Timber
import java.net.URLDecoder
@ -44,14 +41,10 @@ import javax.inject.Inject
/**
* Only login is supported for the moment
* TODO Migrate to new flow
*/
class LoginSsoFallbackFragment @Inject constructor() : VectorBaseFragment(), OnBackPressed {
class LoginSsoFallbackFragment @Inject constructor() : AbstractLoginFragment() {
private lateinit var loginSharedActionViewModel: LoginSharedActionViewModel
private val viewModel: LoginViewModel by activityViewModel()
var homeServerUrl: String = ""
private var homeServerUrl: String = ""
enum class Mode {
MODE_LOGIN,
@ -71,7 +64,6 @@ class LoginSsoFallbackFragment @Inject constructor() : VectorBaseFragment(), OnB
login_sso_fallback_toolbar.title = getString(R.string.login)
setupWebview()
loginSharedActionViewModel = activityViewModelProvider.get(LoginSharedActionViewModel::class.java)
}
@SuppressLint("SetJavaScriptEnabled")
@ -146,7 +138,7 @@ class LoginSsoFallbackFragment @Inject constructor() : VectorBaseFragment(), OnB
super.onReceivedError(view, errorCode, description, failingUrl)
// on error case, close this fragment
loginSharedActionViewModel.post(LoginNavigation.GoBack)
loginSharedActionViewModel.post(LoginNavigation.OnSsoLoginFallbackError(errorCode, description, failingUrl))
}
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
@ -294,12 +286,16 @@ class LoginSsoFallbackFragment @Inject constructor() : VectorBaseFragment(), OnB
}
}
override fun resetViewModel() {
// Nothing to do
}
override fun onBackPressed(): Boolean {
return if (login_sso_fallback_webview.canGoBack()) {
login_sso_fallback_webview.goBack()
true
} else {
false
super.onBackPressed()
}
}
}

View file

@ -78,14 +78,14 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi
currentTask = null
when (action) {
LoginAction.ResetLogin -> {
LoginAction.ResetLogin -> {
setState {
copy(
asyncLoginAction = Uninitialized
)
}
}
LoginAction.ResetHomeServerUrl -> {
LoginAction.ResetHomeServerUrl -> {
setState {
copy(
asyncHomeServerLoginFlowRequest = Uninitialized
@ -99,10 +99,11 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi
)
}
}
LoginAction.ResetSignMode -> {
LoginAction.ResetSignMode -> {
setState {
copy(
signMode = SignMode.Unknown
signMode = SignMode.Unknown,
asyncHomeServerLoginFlowRequest = Uninitialized
)
}
}

View file

@ -17,6 +17,7 @@
package im.vector.riotx.features.login
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.Uninitialized
@ -25,7 +26,14 @@ data class LoginViewState(
val signMode: SignMode = SignMode.Unknown,
val asyncLoginAction: Async<Unit> = Uninitialized,
val asyncHomeServerLoginFlowRequest: Async<LoginMode> = Uninitialized
) : MvRxState
) : MvRxState {
fun isLoading(): Boolean {
// TODO Add other async here
return asyncLoginAction is Loading
|| asyncHomeServerLoginFlowRequest is Loading
}
}
enum class LoginMode {
Password,

View file

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout 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:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/loginFragmentContainer"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<androidx.constraintlayout.widget.Group
android:id="@+id/loginLoading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:constraint_referenced_ids="loginProgressBar,loginTouchArea"
tools:visibility="visible" />
<View
android:id="@+id/loginTouchArea"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="?vctr_waiting_background_color"
android:clickable="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ProgressBar
android:id="@+id/loginProgressBar"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_margin="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -66,11 +66,13 @@
android:layout_marginTop="16dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/passwordFieldTil"
style="@style/VectorTextInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/auth_password_placeholder"
app:errorEnabled="true">
app:errorEnabled="true"
app:errorIconDrawable="@null">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/passwordField"
@ -129,28 +131,5 @@
</androidx.core.widget.NestedScrollView>
<View
android:id="@+id/touchArea"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="?vctr_waiting_background_color"
android:clickable="true"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_margin="8dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -98,28 +98,5 @@
</androidx.core.widget.NestedScrollView>
<View
android:id="@+id/touchArea"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="?vctr_waiting_background_color"
android:clickable="true"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_margin="8dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -52,4 +52,6 @@
<string name="login_server_url_form_modular_notice">Enter the address of the Modular Riot or Server you want to use</string>
<string name="login_server_url_form_other_notice">Enter the address of a server or a Riot you want to connect to</string>
<string name="login_sso_error_message">An error occurred when loading the page: %1$s (%2$d)</string>
</resources>