mirror of
https://github.com/home-assistant/android
synced 2024-10-02 22:34:46 +00:00
Support MFA during Wear OS standalone login (#2131)
* Initial Wear MFA support - Added support for authentication with MFA to the Wear OS login flow - Renamed the existing Authentication* stuff to PasswordAuthentication* - For the authentication flow, specify a base class used for responses and depending on the response return the appropriate data class that extends the base class * Add MFA screens - Adjusting the existing view to add a new title and input and showing/hiding based on state created a buggy experience on the emulator where the keyboard confirm/next buttons didn't work properly any more, so the MFA input is a full new activity. Copied from the existing activity for username/password. * Fix MFA activity tools:context * Move MFA in to the existing AuthenticationActivity - Actually it is possible to get the MFA input in the same activity with a little effort, but the end result works great and doesn't use as much duplicate code * Remove MFA activity, rename PasswordAuthentication* back - Remove the MFA activity now that MFA is integrated into the other activity for authentication - Rename back PasswordAuthentication* to Authentication* (end result compared to existing app: no change) * Remove unnecessary JsonIgnoreProperties annotation
This commit is contained in:
parent
8a8772d5e7
commit
050d449ef2
|
@ -1,14 +1,16 @@
|
|||
package io.homeassistant.companion.android.common.data.authentication
|
||||
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowCreateEntry
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowInit
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowForm
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowResponse
|
||||
import java.net.URL
|
||||
|
||||
interface AuthenticationRepository {
|
||||
|
||||
suspend fun initiateLoginFlow(): LoginFlowInit
|
||||
suspend fun initiateLoginFlow(): LoginFlowForm
|
||||
|
||||
suspend fun loginAuthentication(flowId: String, username: String, password: String): LoginFlowCreateEntry
|
||||
suspend fun loginAuthentication(flowId: String, username: String, password: String): LoginFlowResponse?
|
||||
|
||||
suspend fun loginCode(flowId: String, code: String): LoginFlowResponse?
|
||||
|
||||
suspend fun registerAuthorizationCode(authorizationCode: String)
|
||||
|
||||
|
|
|
@ -1,17 +1,25 @@
|
|||
package io.homeassistant.companion.android.common.data.authentication.impl
|
||||
|
||||
import android.util.Log
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.PropertyNamingStrategies
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import io.homeassistant.companion.android.common.data.LocalStorage
|
||||
import io.homeassistant.companion.android.common.data.authentication.AuthenticationRepository
|
||||
import io.homeassistant.companion.android.common.data.authentication.AuthorizationException
|
||||
import io.homeassistant.companion.android.common.data.authentication.SessionState
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowAuthentication
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowCreateEntry
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowInit
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowForm
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowMfaCode
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowRequest
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowResponse
|
||||
import io.homeassistant.companion.android.common.data.url.UrlRepository
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.ResponseBody
|
||||
import java.net.URL
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Named
|
||||
|
@ -31,7 +39,11 @@ class AuthenticationRepositoryImpl @Inject constructor(
|
|||
private const val PREF_BIOMETRIC_ENABLED = "biometric_enabled"
|
||||
}
|
||||
|
||||
override suspend fun initiateLoginFlow(): LoginFlowInit {
|
||||
private val mapper = jacksonObjectMapper()
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
|
||||
|
||||
override suspend fun initiateLoginFlow(): LoginFlowForm {
|
||||
val url = urlRepository.getUrl()?.toHttpUrlOrNull().toString()
|
||||
return authenticationService.initializeLogin(
|
||||
url + "auth/login_flow",
|
||||
|
@ -43,16 +55,41 @@ class AuthenticationRepositoryImpl @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
override suspend fun loginAuthentication(flowId: String, username: String, password: String): LoginFlowCreateEntry {
|
||||
val url = urlRepository.getUrl()?.toHttpUrlOrNull().toString()
|
||||
return authenticationService.authenticate(
|
||||
url + AuthenticationService.AUTHENTICATE_BASE_PATH + flowId,
|
||||
override suspend fun loginAuthentication(flowId: String, username: String, password: String): LoginFlowResponse? {
|
||||
val url = urlRepository.getUrl()?.toHttpUrlOrNull()
|
||||
if (url == null) {
|
||||
Log.e(TAG, "Unable to authenticate with username/password.")
|
||||
throw AuthorizationException()
|
||||
}
|
||||
|
||||
val response = authenticationService.authenticatePassword(
|
||||
url.newBuilder().addPathSegments(AuthenticationService.AUTHENTICATE_BASE_PATH + flowId).build(),
|
||||
LoginFlowAuthentication(
|
||||
AuthenticationService.CLIENT_ID,
|
||||
username,
|
||||
password
|
||||
)
|
||||
)
|
||||
if (!response.isSuccessful || response.body() == null) throw AuthorizationException()
|
||||
return mapLoginFlowResponse(response.body()!!)
|
||||
}
|
||||
|
||||
override suspend fun loginCode(flowId: String, code: String): LoginFlowResponse? {
|
||||
val url = urlRepository.getUrl()?.toHttpUrlOrNull()
|
||||
if (url == null) {
|
||||
Log.e(TAG, "Unable to authenticate with MFA code.")
|
||||
throw AuthorizationException()
|
||||
}
|
||||
|
||||
val response = authenticationService.authenticateMfa(
|
||||
url.newBuilder().addPathSegments(AuthenticationService.AUTHENTICATE_BASE_PATH + flowId).build(),
|
||||
LoginFlowMfaCode(
|
||||
AuthenticationService.CLIENT_ID,
|
||||
code
|
||||
)
|
||||
)
|
||||
if (!response.isSuccessful || response.body() == null) throw AuthorizationException()
|
||||
return mapLoginFlowResponse(response.body()!!)
|
||||
}
|
||||
|
||||
override suspend fun registerAuthorizationCode(authorizationCode: String) {
|
||||
|
@ -129,6 +166,16 @@ class AuthenticationRepositoryImpl @Inject constructor(
|
|||
return "Bearer " + ensureValidSession().accessToken
|
||||
}
|
||||
|
||||
private fun mapLoginFlowResponse(responseBody: ResponseBody): LoginFlowResponse? {
|
||||
val responseText = responseBody.charStream().readText()
|
||||
val message: JsonNode? = mapper.readValue(responseText)
|
||||
return when (message?.get("type")?.textValue()) {
|
||||
"form" -> mapper.readValue(responseText, LoginFlowForm::class.java)
|
||||
"create_entry" -> mapper.readValue(responseText, LoginFlowCreateEntry::class.java)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun convertSession(session: Session): String {
|
||||
return ObjectMapper().writeValueAsString(
|
||||
mapOf(
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
package io.homeassistant.companion.android.common.data.authentication.impl
|
||||
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowAuthentication
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowCreateEntry
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowInit
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowForm
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowMfaCode
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowRequest
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.Token
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.Field
|
||||
|
@ -52,8 +53,11 @@ interface AuthenticationService {
|
|||
)
|
||||
|
||||
@POST
|
||||
suspend fun initializeLogin(@Url url: String, @Body body: LoginFlowRequest): LoginFlowInit
|
||||
suspend fun initializeLogin(@Url url: String, @Body body: LoginFlowRequest): LoginFlowForm
|
||||
|
||||
@POST
|
||||
suspend fun authenticate(@Url url: String, @Body body: LoginFlowAuthentication): LoginFlowCreateEntry
|
||||
suspend fun authenticatePassword(@Url url: HttpUrl, @Body body: LoginFlowAuthentication): Response<ResponseBody>
|
||||
|
||||
@POST
|
||||
suspend fun authenticateMfa(@Url url: HttpUrl, @Body body: LoginFlowMfaCode): Response<ResponseBody>
|
||||
}
|
||||
|
|
|
@ -3,12 +3,12 @@ package io.homeassistant.companion.android.common.data.authentication.impl.entit
|
|||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
data class LoginFlowCreateEntry(
|
||||
@JsonProperty("type")
|
||||
override val type: String,
|
||||
@JsonProperty("flow_id")
|
||||
override val flowId: String,
|
||||
@JsonProperty("version")
|
||||
val version: Int,
|
||||
@JsonProperty("type")
|
||||
val type: String,
|
||||
@JsonProperty("flow_id")
|
||||
val flowId: String,
|
||||
@JsonProperty("result")
|
||||
val result: String
|
||||
)
|
||||
) : LoginFlowResponse()
|
||||
|
|
|
@ -2,13 +2,13 @@ package io.homeassistant.companion.android.common.data.authentication.impl.entit
|
|||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
data class LoginFlowInit(
|
||||
data class LoginFlowForm(
|
||||
@JsonProperty("type")
|
||||
val type: String,
|
||||
override val type: String,
|
||||
@JsonProperty("flow_id")
|
||||
val flowId: String,
|
||||
override val flowId: String,
|
||||
@JsonProperty("step_id")
|
||||
val stepId: String,
|
||||
@JsonProperty("errors")
|
||||
val errors: Map<String, String>
|
||||
)
|
||||
) : LoginFlowResponse()
|
|
@ -0,0 +1,10 @@
|
|||
package io.homeassistant.companion.android.common.data.authentication.impl.entities
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
data class LoginFlowMfaCode(
|
||||
@JsonProperty("client_id")
|
||||
val clientId: String,
|
||||
@JsonProperty("code")
|
||||
val code: String
|
||||
)
|
|
@ -0,0 +1,6 @@
|
|||
package io.homeassistant.companion.android.common.data.authentication.impl.entities
|
||||
|
||||
sealed class LoginFlowResponse {
|
||||
abstract val type: String
|
||||
abstract val flowId: String
|
||||
}
|
|
@ -289,6 +289,8 @@
|
|||
<string name="message_checking">Checking Wear Devices with App</string>
|
||||
<string name="message_missing_all">The Wear app is missing on your watch, click the button below to install the app.\n\nNote: Currently the Wear OS app requires you to be enrolled in the beta for the phone app. If the button does not work then please join the beta: https://play.google.com/apps/testing/io.homeassistant.companion.android</string>
|
||||
<string name="message_some_installed">The Wear app is installed on some of your wear devices: (%1$s)\n\nClick the button below to install the app on the other devices.\n\nNote: Currently the Wear OS app requires you to be enrolled in the beta for the phone app. If the button does not work then please join the beta: https://play.google.com/apps/testing/io.homeassistant.companion.android</string>
|
||||
<string name="mfa_title">Two-factor\nAuthentication</string>
|
||||
<string name="mfa_hint">Code</string>
|
||||
<string name="missing_command_permission">Please open the Home Assistant app and send the command again in order to grant the proper permissions.</string>
|
||||
<string name="areas">Areas</string>
|
||||
<string name="more_entities">More entities</string>
|
||||
|
|
|
@ -8,7 +8,7 @@ import com.google.android.gms.wearable.DataMap
|
|||
import com.google.android.gms.wearable.DataMapItem
|
||||
import dagger.hilt.android.qualifiers.ActivityContext
|
||||
import io.homeassistant.companion.android.common.data.authentication.AuthenticationRepository
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowInit
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowForm
|
||||
import io.homeassistant.companion.android.common.data.url.UrlRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
@ -73,10 +73,10 @@ class OnboardingPresenterImpl @Inject constructor(
|
|||
|
||||
// Initiate login flow
|
||||
try {
|
||||
val flowInit: LoginFlowInit = authenticationUseCase.initiateLoginFlow()
|
||||
Log.d(TAG, "Created login flow step ${flowInit.stepId}: ${flowInit.flowId}")
|
||||
val flowForm: LoginFlowForm = authenticationUseCase.initiateLoginFlow()
|
||||
Log.d(TAG, "Created login flow step ${flowForm.stepId}: ${flowForm.flowId}")
|
||||
|
||||
view.startAuthentication(flowInit.flowId)
|
||||
view.startAuthentication(flowForm.flowId)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to initiate login flow", e)
|
||||
view.showError()
|
||||
|
|
|
@ -44,7 +44,8 @@ class AuthenticationActivity : AppCompatActivity(), AuthenticationView {
|
|||
presenter.onNextClicked(
|
||||
intent.getStringExtra("flowId")!!,
|
||||
binding.username.text.toString(),
|
||||
binding.password.text.toString()
|
||||
binding.password.text.toString(),
|
||||
binding.code.text.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -59,6 +60,16 @@ class AuthenticationActivity : AppCompatActivity(), AuthenticationView {
|
|||
startActivity(MobileAppIntegrationActivity.newInstance(this))
|
||||
}
|
||||
|
||||
override fun showMfa() {
|
||||
binding.titleLogin.visibility = View.GONE
|
||||
binding.titleMfa.visibility = View.VISIBLE
|
||||
binding.username.visibility = View.GONE
|
||||
binding.password.visibility = View.GONE
|
||||
binding.code.visibility = View.VISIBLE
|
||||
|
||||
binding.loadingView.visibility = View.GONE
|
||||
}
|
||||
|
||||
override fun showLoading() {
|
||||
binding.loadingView.visibility = View.VISIBLE
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ package io.homeassistant.companion.android.onboarding.authentication
|
|||
|
||||
interface AuthenticationPresenter {
|
||||
|
||||
fun onNextClicked(flowId: String, username: String, password: String)
|
||||
fun onNextClicked(flowId: String, username: String?, password: String?, code: String?)
|
||||
|
||||
fun onFinish()
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import android.util.Log
|
|||
import dagger.hilt.android.qualifiers.ActivityContext
|
||||
import io.homeassistant.companion.android.common.data.authentication.AuthenticationRepository
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowCreateEntry
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowForm
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
|
@ -20,20 +21,46 @@ class AuthenticationPresenterImpl @Inject constructor(
|
|||
private const val TAG = "AuthenticationPresenter"
|
||||
}
|
||||
|
||||
enum class AuthenticationPhase {
|
||||
PASSWORD, MFA
|
||||
}
|
||||
private var authenticationPhase = AuthenticationPhase.PASSWORD
|
||||
|
||||
private val view = context as AuthenticationView
|
||||
private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
|
||||
|
||||
override fun onNextClicked(flowId: String, username: String, password: String) {
|
||||
override fun onNextClicked(flowId: String, username: String?, password: String?, code: String?) {
|
||||
view.showLoading()
|
||||
Log.d(TAG, "onNextClicked")
|
||||
mainScope.launch {
|
||||
try {
|
||||
val flowCreateEntry: LoginFlowCreateEntry = authenticationUseCase.loginAuthentication(flowId, username, password)
|
||||
Log.d(TAG, "Authenticated result: ${flowCreateEntry.result}")
|
||||
authenticationUseCase.registerAuthorizationCode(flowCreateEntry.result)
|
||||
Log.d(TAG, "Finished!")
|
||||
val flowResult = when (authenticationPhase) {
|
||||
AuthenticationPhase.PASSWORD -> authenticationUseCase.loginAuthentication(flowId, username.orEmpty(), password.orEmpty())
|
||||
AuthenticationPhase.MFA -> authenticationUseCase.loginCode(flowId, code.orEmpty())
|
||||
}
|
||||
Log.d(TAG, "Authenticated result type: ${flowResult?.type}")
|
||||
when (flowResult?.type) {
|
||||
"form" -> {
|
||||
if (authenticationPhase == AuthenticationPhase.PASSWORD && (flowResult as LoginFlowForm).stepId == "mfa") {
|
||||
Log.d(TAG, "MFA required to authenticate")
|
||||
authenticationPhase = AuthenticationPhase.MFA
|
||||
view.showMfa()
|
||||
} else {
|
||||
Log.e(TAG, "Unable to authenticate")
|
||||
view.showError()
|
||||
}
|
||||
}
|
||||
"create_entry" -> {
|
||||
authenticationUseCase.registerAuthorizationCode((flowResult as LoginFlowCreateEntry).result)
|
||||
Log.d(TAG, "Finished!")
|
||||
|
||||
view.startIntegration()
|
||||
view.startIntegration()
|
||||
}
|
||||
else -> {
|
||||
Log.e(TAG, "Unable to authenticate")
|
||||
view.showError()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to authenticate", e)
|
||||
view.showError()
|
||||
|
|
|
@ -3,6 +3,8 @@ package io.homeassistant.companion.android.onboarding.authentication
|
|||
interface AuthenticationView {
|
||||
fun startIntegration()
|
||||
|
||||
fun showMfa()
|
||||
|
||||
fun showLoading()
|
||||
|
||||
fun showError()
|
||||
|
|
|
@ -4,7 +4,7 @@ import android.content.Context
|
|||
import android.util.Log
|
||||
import dagger.hilt.android.qualifiers.ActivityContext
|
||||
import io.homeassistant.companion.android.common.data.authentication.AuthenticationRepository
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowInit
|
||||
import io.homeassistant.companion.android.common.data.authentication.impl.entities.LoginFlowForm
|
||||
import io.homeassistant.companion.android.common.data.url.UrlRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
@ -33,10 +33,10 @@ class ManualSetupPresenterImpl @Inject constructor(
|
|||
|
||||
// Initiate login flow
|
||||
try {
|
||||
val flowInit: LoginFlowInit = authenticationUseCase.initiateLoginFlow()
|
||||
Log.d(TAG, "Created login flow step ${flowInit.stepId}: ${flowInit.flowId}")
|
||||
val flowForm: LoginFlowForm = authenticationUseCase.initiateLoginFlow()
|
||||
Log.d(TAG, "Created login flow step ${flowForm.stepId}: ${flowForm.flowId}")
|
||||
|
||||
view.startAuthentication(flowInit.flowId)
|
||||
view.startAuthentication(flowForm.flowId)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to initiate login flow", e)
|
||||
view.showError()
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
app:srcCompat="@drawable/ic_button_arrow_forward" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView2"
|
||||
android:id="@+id/titleLogin"
|
||||
style="@style/HeaderText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -34,6 +34,18 @@
|
|||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/titleMfa"
|
||||
style="@style/HeaderText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/mfa_title"
|
||||
android:gravity="center"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
android:visibility="gone"/>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/username"
|
||||
android:layout_width="wrap_content"
|
||||
|
@ -44,7 +56,7 @@
|
|||
android:inputType="textPersonName"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/textView2" />
|
||||
app:layout_constraintTop_toBottomOf="@+id/titleLogin" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/password"
|
||||
|
@ -56,7 +68,22 @@
|
|||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/username"
|
||||
android:imeOptions="actionDone"
|
||||
android:importantForAutofill="no" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/code"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ems="10"
|
||||
android:hint="@string/mfa_hint"
|
||||
android:inputType="number"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/titleMfa"
|
||||
android:imeOptions="actionDone"
|
||||
android:importantForAutofill="no"
|
||||
android:visibility="gone"/>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<io.homeassistant.companion.android.util.LoadingView
|
||||
|
|
Loading…
Reference in a new issue