mirror of
https://github.com/element-hq/element-android
synced 2024-11-24 10:25:35 +03:00
Merge pull request #5325 from vector-im/feature/eric/registration-feature-flag
#5307 Adds ForceLoginFallback feature flag to Login and Registration
This commit is contained in:
commit
27905064e0
11 changed files with 137 additions and 72 deletions
1
changelog.d/5325.feature
Normal file
1
changelog.d/5325.feature
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Adds forceLoginFallback feature flag and usages to FTUE login and registration
|
|
@ -53,7 +53,7 @@ class DebugFeaturesStateFactory @Inject constructor(
|
||||||
label = "FTUE Personalize profile",
|
label = "FTUE Personalize profile",
|
||||||
key = DebugFeatureKeys.onboardingPersonalize,
|
key = DebugFeatureKeys.onboardingPersonalize,
|
||||||
factory = VectorFeatures::isOnboardingPersonalizeEnabled
|
factory = VectorFeatures::isOnboardingPersonalizeEnabled
|
||||||
)
|
),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -43,9 +43,13 @@ class DebugPrivateSettingsFragment : VectorBaseFragment<FragmentDebugPrivateSett
|
||||||
views.forceDialPadTabDisplay.setOnCheckedChangeListener { _, isChecked ->
|
views.forceDialPadTabDisplay.setOnCheckedChangeListener { _, isChecked ->
|
||||||
viewModel.handle(DebugPrivateSettingsViewActions.SetDialPadVisibility(isChecked))
|
viewModel.handle(DebugPrivateSettingsViewActions.SetDialPadVisibility(isChecked))
|
||||||
}
|
}
|
||||||
|
views.forceLoginFallback.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
viewModel.handle(DebugPrivateSettingsViewActions.SetForceLoginFallbackEnabled(isChecked))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun invalidate() = withState(viewModel) {
|
override fun invalidate() = withState(viewModel) {
|
||||||
views.forceDialPadTabDisplay.isChecked = it.dialPadVisible
|
views.forceDialPadTabDisplay.isChecked = it.dialPadVisible
|
||||||
|
views.forceLoginFallback.isChecked = it.forceLoginFallback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,4 +20,5 @@ import im.vector.app.core.platform.VectorViewModelAction
|
||||||
|
|
||||||
sealed class DebugPrivateSettingsViewActions : VectorViewModelAction {
|
sealed class DebugPrivateSettingsViewActions : VectorViewModelAction {
|
||||||
data class SetDialPadVisibility(val force: Boolean) : DebugPrivateSettingsViewActions()
|
data class SetDialPadVisibility(val force: Boolean) : DebugPrivateSettingsViewActions()
|
||||||
|
data class SetForceLoginFallbackEnabled(val force: Boolean) : DebugPrivateSettingsViewActions()
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,15 +45,18 @@ class DebugPrivateSettingsViewModel @AssistedInject constructor(
|
||||||
|
|
||||||
private fun observeVectorDataStore() {
|
private fun observeVectorDataStore() {
|
||||||
vectorDataStore.forceDialPadDisplayFlow.setOnEach {
|
vectorDataStore.forceDialPadDisplayFlow.setOnEach {
|
||||||
copy(
|
copy(dialPadVisible = it)
|
||||||
dialPadVisible = it
|
}
|
||||||
)
|
|
||||||
|
vectorDataStore.forceLoginFallbackFlow.setOnEach {
|
||||||
|
copy(forceLoginFallback = it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handle(action: DebugPrivateSettingsViewActions) {
|
override fun handle(action: DebugPrivateSettingsViewActions) {
|
||||||
when (action) {
|
when (action) {
|
||||||
is DebugPrivateSettingsViewActions.SetDialPadVisibility -> handleSetDialPadVisibility(action)
|
is DebugPrivateSettingsViewActions.SetDialPadVisibility -> handleSetDialPadVisibility(action)
|
||||||
|
is DebugPrivateSettingsViewActions.SetForceLoginFallbackEnabled -> handleSetForceLoginFallbackEnabled(action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -62,4 +65,10 @@ class DebugPrivateSettingsViewModel @AssistedInject constructor(
|
||||||
vectorDataStore.setForceDialPadDisplay(action.force)
|
vectorDataStore.setForceDialPadDisplay(action.force)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleSetForceLoginFallbackEnabled(action: DebugPrivateSettingsViewActions.SetForceLoginFallbackEnabled) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
vectorDataStore.setForceLoginFallbackFlow(action.force)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,5 +19,6 @@ package im.vector.app.features.debug.settings
|
||||||
import com.airbnb.mvrx.MavericksState
|
import com.airbnb.mvrx.MavericksState
|
||||||
|
|
||||||
data class DebugPrivateSettingsViewState(
|
data class DebugPrivateSettingsViewState(
|
||||||
val dialPadVisible: Boolean = false
|
val dialPadVisible: Boolean = false,
|
||||||
|
val forceLoginFallback: Boolean = false,
|
||||||
) : MavericksState
|
) : MavericksState
|
||||||
|
|
|
@ -25,6 +25,12 @@
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Force DialPad tab display" />
|
android:text="Force DialPad tab display" />
|
||||||
|
|
||||||
|
<CheckBox
|
||||||
|
android:id="@+id/forceLoginFallback"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Force login and registration fallback" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
|
@ -46,6 +46,7 @@ import im.vector.app.features.login.LoginMode
|
||||||
import im.vector.app.features.login.ReAuthHelper
|
import im.vector.app.features.login.ReAuthHelper
|
||||||
import im.vector.app.features.login.ServerType
|
import im.vector.app.features.login.ServerType
|
||||||
import im.vector.app.features.login.SignMode
|
import im.vector.app.features.login.SignMode
|
||||||
|
import im.vector.app.features.settings.VectorDataStore
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.matrix.android.sdk.api.MatrixPatterns.getDomain
|
import org.matrix.android.sdk.api.MatrixPatterns.getDomain
|
||||||
|
@ -78,7 +79,8 @@ class OnboardingViewModel @AssistedInject constructor(
|
||||||
private val stringProvider: StringProvider,
|
private val stringProvider: StringProvider,
|
||||||
private val homeServerHistoryService: HomeServerHistoryService,
|
private val homeServerHistoryService: HomeServerHistoryService,
|
||||||
private val vectorFeatures: VectorFeatures,
|
private val vectorFeatures: VectorFeatures,
|
||||||
private val analyticsTracker: AnalyticsTracker
|
private val analyticsTracker: AnalyticsTracker,
|
||||||
|
private val vectorDataStore: VectorDataStore,
|
||||||
) : VectorViewModel<OnboardingViewState, OnboardingAction, OnboardingViewEvents>(initialState) {
|
) : VectorViewModel<OnboardingViewState, OnboardingAction, OnboardingViewEvents>(initialState) {
|
||||||
|
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
|
@ -90,6 +92,7 @@ class OnboardingViewModel @AssistedInject constructor(
|
||||||
|
|
||||||
init {
|
init {
|
||||||
getKnownCustomHomeServersUrls()
|
getKnownCustomHomeServersUrls()
|
||||||
|
observeDataStore()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getKnownCustomHomeServersUrls() {
|
private fun getKnownCustomHomeServersUrls() {
|
||||||
|
@ -98,6 +101,12 @@ class OnboardingViewModel @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun observeDataStore() = viewModelScope.launch {
|
||||||
|
vectorDataStore.forceLoginFallbackFlow.setOnEach { isForceLoginFallbackEnabled ->
|
||||||
|
copy(isForceLoginFallbackEnabled = isForceLoginFallbackEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Store the last action, to redo it after user has trusted the untrusted certificate
|
// Store the last action, to redo it after user has trusted the untrusted certificate
|
||||||
private var lastAction: OnboardingAction? = null
|
private var lastAction: OnboardingAction? = null
|
||||||
private var currentHomeServerConnectionConfig: HomeServerConnectionConfig? = null
|
private var currentHomeServerConnectionConfig: HomeServerConnectionConfig? = null
|
||||||
|
|
|
@ -62,7 +62,8 @@ data class OnboardingViewState(
|
||||||
// Supported types for the login. We cannot use a sealed class for LoginType because it is not serializable
|
// Supported types for the login. We cannot use a sealed class for LoginType because it is not serializable
|
||||||
@PersistState
|
@PersistState
|
||||||
val loginModeSupportedTypes: List<String> = emptyList(),
|
val loginModeSupportedTypes: List<String> = emptyList(),
|
||||||
val knownCustomHomeServersUrls: List<String> = emptyList()
|
val knownCustomHomeServersUrls: List<String> = emptyList(),
|
||||||
|
val isForceLoginFallbackEnabled: Boolean = false,
|
||||||
) : MavericksState {
|
) : MavericksState {
|
||||||
|
|
||||||
fun isLoading(): Boolean {
|
fun isLoading(): Boolean {
|
||||||
|
|
|
@ -75,6 +75,8 @@ class FtueAuthVariant(
|
||||||
private val popEnterAnim = R.anim.no_anim
|
private val popEnterAnim = R.anim.no_anim
|
||||||
private val popExitAnim = R.anim.exit_fade_out
|
private val popExitAnim = R.anim.exit_fade_out
|
||||||
|
|
||||||
|
private var isForceLoginFallbackEnabled = false
|
||||||
|
|
||||||
private val topFragment: Fragment?
|
private val topFragment: Fragment?
|
||||||
get() = supportFragmentManager.findFragmentById(views.loginFragmentContainer.id)
|
get() = supportFragmentManager.findFragmentById(views.loginFragmentContainer.id)
|
||||||
|
|
||||||
|
@ -109,10 +111,6 @@ class FtueAuthVariant(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setIsLoading(isLoading: Boolean) {
|
|
||||||
// do nothing
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addFirstFragment() {
|
private fun addFirstFragment() {
|
||||||
val splashFragment = when (vectorFeatures.isOnboardingSplashCarouselEnabled()) {
|
val splashFragment = when (vectorFeatures.isOnboardingSplashCarouselEnabled()) {
|
||||||
true -> FtueAuthSplashCarouselFragment::class.java
|
true -> FtueAuthSplashCarouselFragment::class.java
|
||||||
|
@ -121,11 +119,25 @@ class FtueAuthVariant(
|
||||||
activity.addFragment(views.loginFragmentContainer, splashFragment)
|
activity.addFragment(views.loginFragmentContainer, splashFragment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateWithState(viewState: OnboardingViewState) {
|
||||||
|
isForceLoginFallbackEnabled = viewState.isForceLoginFallbackEnabled
|
||||||
|
views.loginLoading.isVisible = shouldShowLoading(viewState)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shouldShowLoading(viewState: OnboardingViewState) =
|
||||||
|
if (vectorFeatures.isOnboardingPersonalizeEnabled()) {
|
||||||
|
viewState.isLoading()
|
||||||
|
} else {
|
||||||
|
// Keep loading when during success because of the delay when switching to the next Activity
|
||||||
|
viewState.isLoading() || viewState.isAuthTaskCompleted()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setIsLoading(isLoading: Boolean) = Unit
|
||||||
|
|
||||||
private fun handleOnboardingViewEvents(viewEvents: OnboardingViewEvents) {
|
private fun handleOnboardingViewEvents(viewEvents: OnboardingViewEvents) {
|
||||||
when (viewEvents) {
|
when (viewEvents) {
|
||||||
is OnboardingViewEvents.RegistrationFlowResult -> {
|
is OnboardingViewEvents.RegistrationFlowResult -> {
|
||||||
// Check that all flows are supported by the application
|
if (registrationShouldFallback(viewEvents)) {
|
||||||
if (viewEvents.flowResult.missingStages.any { !it.isSupported() }) {
|
|
||||||
// Display a popup to propose use web fallback
|
// Display a popup to propose use web fallback
|
||||||
onRegistrationStageNotSupported()
|
onRegistrationStageNotSupported()
|
||||||
} else {
|
} else {
|
||||||
|
@ -136,11 +148,7 @@ class FtueAuthVariant(
|
||||||
// First ask for login and password
|
// First ask for login and password
|
||||||
// I add a tag to indicate that this fragment is a registration stage.
|
// I add a tag to indicate that this fragment is a registration stage.
|
||||||
// This way it will be automatically popped in when starting the next registration stage
|
// This way it will be automatically popped in when starting the next registration stage
|
||||||
activity.addFragmentToBackstack(views.loginFragmentContainer,
|
openAuthLoginFragmentWithTag(FRAGMENT_REGISTRATION_STAGE_TAG)
|
||||||
FtueAuthLoginFragment::class.java,
|
|
||||||
tag = FRAGMENT_REGISTRATION_STAGE_TAG,
|
|
||||||
option = commonOption
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -228,13 +236,23 @@ class FtueAuthVariant(
|
||||||
}.exhaustive
|
}.exhaustive
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateWithState(viewState: OnboardingViewState) {
|
private fun registrationShouldFallback(registrationFlowResult: OnboardingViewEvents.RegistrationFlowResult) =
|
||||||
views.loginLoading.isVisible = if (vectorFeatures.isOnboardingPersonalizeEnabled()) {
|
isForceLoginFallbackEnabled || registrationFlowResult.containsUnsupportedRegistrationFlow()
|
||||||
viewState.isLoading()
|
|
||||||
} else {
|
private fun OnboardingViewEvents.RegistrationFlowResult.containsUnsupportedRegistrationFlow() =
|
||||||
// Keep loading when during success because of the delay when switching to the next Activity
|
flowResult.missingStages.any { !it.isSupported() }
|
||||||
viewState.isLoading() || viewState.isAuthTaskCompleted()
|
|
||||||
}
|
private fun onRegistrationStageNotSupported() {
|
||||||
|
MaterialAlertDialogBuilder(activity)
|
||||||
|
.setTitle(R.string.app_name)
|
||||||
|
.setMessage(activity.getString(R.string.login_registration_not_supported))
|
||||||
|
.setPositiveButton(R.string.yes) { _, _ ->
|
||||||
|
activity.addFragmentToBackstack(views.loginFragmentContainer,
|
||||||
|
FtueAuthWebFragment::class.java,
|
||||||
|
option = commonOption)
|
||||||
|
}
|
||||||
|
.setNegativeButton(R.string.no, null)
|
||||||
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onWebLoginError(onWebLoginError: OnboardingViewEvents.OnWebLoginError) {
|
private fun onWebLoginError(onWebLoginError: OnboardingViewEvents.OnWebLoginError) {
|
||||||
|
@ -264,29 +282,58 @@ class FtueAuthVariant(
|
||||||
// state.signMode could not be ready yet. So use value from the ViewEvent
|
// state.signMode could not be ready yet. So use value from the ViewEvent
|
||||||
when (OnboardingViewEvents.signMode) {
|
when (OnboardingViewEvents.signMode) {
|
||||||
SignMode.Unknown -> error("Sign mode has to be set before calling this method")
|
SignMode.Unknown -> error("Sign mode has to be set before calling this method")
|
||||||
SignMode.SignUp -> {
|
SignMode.SignUp -> Unit // This case is processed in handleOnboardingViewEvents
|
||||||
// This is managed by the OnboardingViewEvents
|
SignMode.SignIn -> handleSignInSelected(state)
|
||||||
}
|
SignMode.SignInWithMatrixId -> handleSignInWithMatrixId(state)
|
||||||
SignMode.SignIn -> {
|
|
||||||
// It depends on the LoginMode
|
|
||||||
when (state.loginMode) {
|
|
||||||
LoginMode.Unknown,
|
|
||||||
is LoginMode.Sso -> error("Developer error")
|
|
||||||
is LoginMode.SsoAndPassword,
|
|
||||||
LoginMode.Password -> activity.addFragmentToBackstack(views.loginFragmentContainer,
|
|
||||||
FtueAuthLoginFragment::class.java,
|
|
||||||
tag = FRAGMENT_LOGIN_TAG,
|
|
||||||
option = commonOption)
|
|
||||||
LoginMode.Unsupported -> onLoginModeNotSupported(state.loginModeSupportedTypes)
|
|
||||||
}.exhaustive
|
|
||||||
}
|
|
||||||
SignMode.SignInWithMatrixId -> activity.addFragmentToBackstack(views.loginFragmentContainer,
|
|
||||||
FtueAuthLoginFragment::class.java,
|
|
||||||
tag = FRAGMENT_LOGIN_TAG,
|
|
||||||
option = commonOption)
|
|
||||||
}.exhaustive
|
}.exhaustive
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleSignInSelected(state: OnboardingViewState) {
|
||||||
|
if (isForceLoginFallbackEnabled) {
|
||||||
|
onLoginModeNotSupported(state.loginModeSupportedTypes)
|
||||||
|
} else {
|
||||||
|
disambiguateLoginMode(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disambiguateLoginMode(state: OnboardingViewState) = when (state.loginMode) {
|
||||||
|
LoginMode.Unknown,
|
||||||
|
is LoginMode.Sso -> error("Developer error")
|
||||||
|
is LoginMode.SsoAndPassword,
|
||||||
|
LoginMode.Password -> openAuthLoginFragmentWithTag(FRAGMENT_LOGIN_TAG)
|
||||||
|
LoginMode.Unsupported -> onLoginModeNotSupported(state.loginModeSupportedTypes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openAuthLoginFragmentWithTag(tag: String) {
|
||||||
|
activity.addFragmentToBackstack(views.loginFragmentContainer,
|
||||||
|
FtueAuthLoginFragment::class.java,
|
||||||
|
tag = tag,
|
||||||
|
option = commonOption)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onLoginModeNotSupported(supportedTypes: List<String>) {
|
||||||
|
MaterialAlertDialogBuilder(activity)
|
||||||
|
.setTitle(R.string.app_name)
|
||||||
|
.setMessage(activity.getString(R.string.login_mode_not_supported, supportedTypes.joinToString { "'$it'" }))
|
||||||
|
.setPositiveButton(R.string.yes) { _, _ -> openAuthWebFragment() }
|
||||||
|
.setNegativeButton(R.string.no, null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSignInWithMatrixId(state: OnboardingViewState) {
|
||||||
|
if (isForceLoginFallbackEnabled) {
|
||||||
|
onLoginModeNotSupported(state.loginModeSupportedTypes)
|
||||||
|
} else {
|
||||||
|
openAuthLoginFragmentWithTag(FRAGMENT_LOGIN_TAG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openAuthWebFragment() {
|
||||||
|
activity.addFragmentToBackstack(views.loginFragmentContainer,
|
||||||
|
FtueAuthWebFragment::class.java,
|
||||||
|
option = commonOption)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle the SSO redirection here
|
* Handle the SSO redirection here
|
||||||
*/
|
*/
|
||||||
|
@ -296,32 +343,6 @@ class FtueAuthVariant(
|
||||||
?.let { onboardingViewModel.handle(OnboardingAction.LoginWithToken(it)) }
|
?.let { onboardingViewModel.handle(OnboardingAction.LoginWithToken(it)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onRegistrationStageNotSupported() {
|
|
||||||
MaterialAlertDialogBuilder(activity)
|
|
||||||
.setTitle(R.string.app_name)
|
|
||||||
.setMessage(activity.getString(R.string.login_registration_not_supported))
|
|
||||||
.setPositiveButton(R.string.yes) { _, _ ->
|
|
||||||
activity.addFragmentToBackstack(views.loginFragmentContainer,
|
|
||||||
FtueAuthWebFragment::class.java,
|
|
||||||
option = commonOption)
|
|
||||||
}
|
|
||||||
.setNegativeButton(R.string.no, null)
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onLoginModeNotSupported(supportedTypes: List<String>) {
|
|
||||||
MaterialAlertDialogBuilder(activity)
|
|
||||||
.setTitle(R.string.app_name)
|
|
||||||
.setMessage(activity.getString(R.string.login_mode_not_supported, supportedTypes.joinToString { "'$it'" }))
|
|
||||||
.setPositiveButton(R.string.yes) { _, _ ->
|
|
||||||
activity.addFragmentToBackstack(views.loginFragmentContainer,
|
|
||||||
FtueAuthWebFragment::class.java,
|
|
||||||
option = commonOption)
|
|
||||||
}
|
|
||||||
.setNegativeButton(R.string.no, null)
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleRegistrationNavigation(flowResult: FlowResult) {
|
private fun handleRegistrationNavigation(flowResult: FlowResult) {
|
||||||
// Complete all mandatory stages first
|
// Complete all mandatory stages first
|
||||||
val mandatoryStage = flowResult.missingStages.firstOrNull { it.mandatory }
|
val mandatoryStage = flowResult.missingStages.firstOrNull { it.mandatory }
|
||||||
|
|
|
@ -59,4 +59,16 @@ class VectorDataStore @Inject constructor(
|
||||||
settings[forceDialPadDisplay] = force
|
settings[forceDialPadDisplay] = force
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val forceLoginFallback = booleanPreferencesKey("force_login_fallback")
|
||||||
|
|
||||||
|
val forceLoginFallbackFlow: Flow<Boolean> = context.dataStore.data.map { preferences ->
|
||||||
|
preferences[forceLoginFallback].orFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setForceLoginFallbackFlow(force: Boolean) {
|
||||||
|
context.dataStore.edit { settings ->
|
||||||
|
settings[forceLoginFallback] = force
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue