Merge pull request #5375 from vector-im/feature/adm/display-personalisation-based-on-capabilities

FTUE - Capability based personalisation flow
This commit is contained in:
Adam Brown 2022-03-14 13:34:21 +00:00 committed by GitHub
commit 82e1afdb72
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 619 additions and 70 deletions

1
changelog.d/5375.wip Normal file
View file

@ -0,0 +1 @@
Dynamically showing/hiding onboarding personalisation screens based on the users homeserver capabilities

View file

@ -22,13 +22,17 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import im.vector.app.features.HomeserverCapabilitiesOverride
import im.vector.app.features.VectorOverrides
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import org.matrix.android.sdk.api.extensions.orFalse
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "vector_overrides")
private val keyForceDialPadDisplay = booleanPreferencesKey("force_dial_pad_display")
private val keyForceLoginFallback = booleanPreferencesKey("force_login_fallback")
private val forceCanChangeDisplayName = booleanPreferencesKey("force_can_change_display_name")
private val forceCanChangeAvatar = booleanPreferencesKey("force_can_change_avatar")
class DebugVectorOverrides(private val context: Context) : VectorOverrides {
@ -40,6 +44,13 @@ class DebugVectorOverrides(private val context: Context) : VectorOverrides {
preferences[keyForceLoginFallback].orFalse()
}
override val forceHomeserverCapabilities = context.dataStore.data.map { preferences ->
HomeserverCapabilitiesOverride(
canChangeDisplayName = preferences[forceCanChangeDisplayName],
canChangeAvatar = preferences[forceCanChangeAvatar]
)
}
suspend fun setForceDialPadDisplay(force: Boolean) {
context.dataStore.edit { settings ->
settings[keyForceDialPadDisplay] = force
@ -51,4 +62,18 @@ class DebugVectorOverrides(private val context: Context) : VectorOverrides {
settings[keyForceLoginFallback] = force
}
}
suspend fun setHomeserverCapabilities(block: HomeserverCapabilitiesOverride.() -> HomeserverCapabilitiesOverride) {
val capabilitiesOverride = block(forceHomeserverCapabilities.firstOrNull() ?: HomeserverCapabilitiesOverride(null, null))
context.dataStore.edit { settings ->
when (capabilitiesOverride.canChangeDisplayName) {
null -> settings.remove(forceCanChangeDisplayName)
else -> settings[forceCanChangeDisplayName] = capabilitiesOverride.canChangeDisplayName
}
when (capabilitiesOverride.canChangeAvatar) {
null -> settings.remove(forceCanChangeAvatar)
else -> settings[forceCanChangeAvatar] = capabilitiesOverride.canChangeAvatar
}
}
}
}

View file

@ -50,6 +50,12 @@ class DebugPrivateSettingsFragment : VectorBaseFragment<FragmentDebugPrivateSett
override fun invalidate() = withState(viewModel) {
views.forceDialPadTabDisplay.isChecked = it.dialPadVisible
views.forceChangeDisplayNameCapability.bind(it.homeserverCapabilityOverrides.displayName) { option ->
viewModel.handle(DebugPrivateSettingsViewActions.SetDisplayNameCapabilityOverride(option))
}
views.forceChangeAvatarCapability.bind(it.homeserverCapabilityOverrides.avatar) { option ->
viewModel.handle(DebugPrivateSettingsViewActions.SetAvatarCapabilityOverride(option))
}
views.forceLoginFallback.isChecked = it.forceLoginFallback
}
}

View file

@ -18,7 +18,9 @@ package im.vector.app.features.debug.settings
import im.vector.app.core.platform.VectorViewModelAction
sealed class DebugPrivateSettingsViewActions : VectorViewModelAction {
data class SetDialPadVisibility(val force: Boolean) : DebugPrivateSettingsViewActions()
data class SetForceLoginFallbackEnabled(val force: Boolean) : DebugPrivateSettingsViewActions()
sealed interface DebugPrivateSettingsViewActions : VectorViewModelAction {
data class SetDialPadVisibility(val force: Boolean) : DebugPrivateSettingsViewActions
data class SetForceLoginFallbackEnabled(val force: Boolean) : DebugPrivateSettingsViewActions
data class SetDisplayNameCapabilityOverride(val option: BooleanHomeserverCapabilitiesOverride?) : DebugPrivateSettingsViewActions
data class SetAvatarCapabilityOverride(val option: BooleanHomeserverCapabilitiesOverride?) : DebugPrivateSettingsViewActions
}

View file

@ -22,9 +22,12 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.EmptyViewEvents
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.debug.features.DebugVectorOverrides
import im.vector.app.features.debug.settings.DebugPrivateSettingsViewActions.SetAvatarCapabilityOverride
import im.vector.app.features.debug.settings.DebugPrivateSettingsViewActions.SetDisplayNameCapabilityOverride
import kotlinx.coroutines.launch
class DebugPrivateSettingsViewModel @AssistedInject constructor(
@ -40,10 +43,10 @@ class DebugPrivateSettingsViewModel @AssistedInject constructor(
companion object : MavericksViewModelFactory<DebugPrivateSettingsViewModel, DebugPrivateSettingsViewState> by hiltMavericksViewModelFactory()
init {
observeVectorDataStore()
observeVectorOverrides()
}
private fun observeVectorDataStore() {
private fun observeVectorOverrides() {
debugVectorOverrides.forceDialPad.setOnEach {
copy(
dialPadVisible = it
@ -52,13 +55,23 @@ class DebugPrivateSettingsViewModel @AssistedInject constructor(
debugVectorOverrides.forceLoginFallback.setOnEach {
copy(forceLoginFallback = it)
}
debugVectorOverrides.forceHomeserverCapabilities.setOnEach {
val activeDisplayNameOption = BooleanHomeserverCapabilitiesOverride.from(it.canChangeDisplayName)
val activeAvatarOption = BooleanHomeserverCapabilitiesOverride.from(it.canChangeAvatar)
copy(homeserverCapabilityOverrides = homeserverCapabilityOverrides.copy(
displayName = homeserverCapabilityOverrides.displayName.copy(activeOption = activeDisplayNameOption),
avatar = homeserverCapabilityOverrides.avatar.copy(activeOption = activeAvatarOption),
))
}
}
override fun handle(action: DebugPrivateSettingsViewActions) {
when (action) {
is DebugPrivateSettingsViewActions.SetDialPadVisibility -> handleSetDialPadVisibility(action)
is DebugPrivateSettingsViewActions.SetForceLoginFallbackEnabled -> handleSetForceLoginFallbackEnabled(action)
}
is SetDisplayNameCapabilityOverride -> handSetDisplayNameCapabilityOverride(action)
is SetAvatarCapabilityOverride -> handSetAvatarCapabilityOverride(action)
}.exhaustive
}
private fun handleSetDialPadVisibility(action: DebugPrivateSettingsViewActions.SetDialPadVisibility) {
@ -72,4 +85,18 @@ class DebugPrivateSettingsViewModel @AssistedInject constructor(
debugVectorOverrides.setForceLoginFallback(action.force)
}
}
private fun handSetDisplayNameCapabilityOverride(action: SetDisplayNameCapabilityOverride) {
viewModelScope.launch {
val forceDisplayName = action.option.toBoolean()
debugVectorOverrides.setHomeserverCapabilities { copy(canChangeDisplayName = forceDisplayName) }
}
}
private fun handSetAvatarCapabilityOverride(action: SetAvatarCapabilityOverride) {
viewModelScope.launch {
val forceAvatar = action.option.toBoolean()
debugVectorOverrides.setHomeserverCapabilities { copy(canChangeAvatar = forceAvatar) }
}
}
}

View file

@ -17,8 +17,23 @@
package im.vector.app.features.debug.settings
import com.airbnb.mvrx.MavericksState
import im.vector.app.features.debug.settings.OverrideDropdownView.OverrideDropdown
data class DebugPrivateSettingsViewState(
val dialPadVisible: Boolean = false,
val forceLoginFallback: Boolean = false,
val homeserverCapabilityOverrides: HomeserverCapabilityOverrides = HomeserverCapabilityOverrides()
) : MavericksState
data class HomeserverCapabilityOverrides(
val displayName: OverrideDropdown<BooleanHomeserverCapabilitiesOverride> = OverrideDropdown(
label = "Override display name capability",
activeOption = null,
options = listOf(BooleanHomeserverCapabilitiesOverride.ForceEnabled, BooleanHomeserverCapabilitiesOverride.ForceDisabled)
),
val avatar: OverrideDropdown<BooleanHomeserverCapabilitiesOverride> = OverrideDropdown(
label = "Override avatar capability",
activeOption = null,
options = listOf(BooleanHomeserverCapabilitiesOverride.ForceEnabled, BooleanHomeserverCapabilitiesOverride.ForceDisabled)
)
)

View file

@ -0,0 +1,86 @@
/*
* Copyright (c) 2022 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.app.features.debug.settings
import android.content.Context
import android.util.AttributeSet
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.LinearLayout
import im.vector.app.R
import im.vector.app.databinding.ViewBooleanDropdownBinding
class OverrideDropdownView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : LinearLayout(context, attrs) {
private val binding = ViewBooleanDropdownBinding.inflate(
LayoutInflater.from(context),
this
)
init {
orientation = HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
}
fun <T : OverrideOption> bind(feature: OverrideDropdown<T>, listener: Listener<T>) {
binding.overrideLabel.text = feature.label
binding.overrideOptions.apply {
val arrayAdapter = ArrayAdapter<String>(context, android.R.layout.simple_spinner_dropdown_item)
val options = listOf("Inactive") + feature.options.map { it.label }
arrayAdapter.addAll(options)
adapter = arrayAdapter
feature.activeOption?.let {
setSelection(options.indexOf(it.label), false)
}
onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
when (position) {
0 -> listener.onOverrideSelected(option = null)
else -> listener.onOverrideSelected(feature.options[position - 1])
}
}
override fun onNothingSelected(parent: AdapterView<*>?) {
// do nothing
}
}
}
}
fun interface Listener<T> {
fun onOverrideSelected(option: T?)
}
data class OverrideDropdown<T : OverrideOption>(
val label: String,
val options: List<T>,
val activeOption: T?,
)
}
interface OverrideOption {
val label: String
}

View file

@ -0,0 +1,42 @@
/*
* Copyright (c) 2022 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.app.features.debug.settings
sealed interface BooleanHomeserverCapabilitiesOverride : OverrideOption {
companion object {
fun from(value: Boolean?) = when (value) {
null -> null
true -> ForceEnabled
false -> ForceDisabled
}
}
object ForceEnabled : BooleanHomeserverCapabilitiesOverride {
override val label = "Force enabled"
}
object ForceDisabled : BooleanHomeserverCapabilitiesOverride {
override val label = "Force disabled"
}
}
fun BooleanHomeserverCapabilitiesOverride?.toBoolean() = when (this) {
null -> null
BooleanHomeserverCapabilitiesOverride.ForceDisabled -> false
BooleanHomeserverCapabilitiesOverride.ForceEnabled -> true
}

View file

@ -31,6 +31,24 @@
android:layout_height="wrap_content"
android:text="Force login and registration fallback" />
<im.vector.app.features.debug.settings.OverrideDropdownView
android:id="@+id/forceChangeDisplayNameCapability"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="4dp" />
<im.vector.app.features.debug.settings.OverrideDropdownView
android:id="@+id/forceChangeAvatarCapability"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="4dp" />
</LinearLayout>
</ScrollView>

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<merge 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"
tools:parentTag="android.widget.LinearLayout">
<TextView
android:id="@+id/overrideLabel"
style="@style/Widget.Vector.TextView.Subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:gravity="center"
android:textColor="?vctr_content_primary"
tools:text="Login version" />
<androidx.appcompat.widget.AppCompatSpinner
android:id="@+id/overrideOptions"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
</merge>

View file

@ -22,9 +22,16 @@ import kotlinx.coroutines.flow.flowOf
interface VectorOverrides {
val forceDialPad: Flow<Boolean>
val forceLoginFallback: Flow<Boolean>
val forceHomeserverCapabilities: Flow<HomeserverCapabilitiesOverride>?
}
data class HomeserverCapabilitiesOverride(
val canChangeDisplayName: Boolean?,
val canChangeAvatar: Boolean?
)
class DefaultVectorOverrides : VectorOverrides {
override val forceDialPad = flowOf(false)
override val forceLoginFallback = flowOf(false)
override val forceHomeserverCapabilities: Flow<HomeserverCapabilitiesOverride>? = null
}

View file

@ -75,6 +75,7 @@ sealed class OnboardingAction : VectorViewModelAction {
data class UserAcceptCertificate(val fingerprint: Fingerprint) : OnboardingAction()
object PersonalizeProfile : OnboardingAction()
data class UpdateDisplayName(val displayName: String) : OnboardingAction()
object UpdateDisplayNameSkipped : OnboardingAction()
data class ProfilePictureSelected(val uri: Uri) : OnboardingAction()

View file

@ -51,9 +51,8 @@ sealed class OnboardingViewEvents : VectorViewEvents {
object OnAccountCreated : OnboardingViewEvents()
object OnAccountSignedIn : OnboardingViewEvents()
object OnTakeMeHome : OnboardingViewEvents()
object OnPersonalizeProfile : OnboardingViewEvents()
object OnDisplayNameUpdated : OnboardingViewEvents()
object OnDisplayNameSkipped : OnboardingViewEvents()
object OnChooseDisplayName : OnboardingViewEvents()
object OnChooseProfilePicture : OnboardingViewEvents()
object OnPersonalizationComplete : OnboardingViewEvents()
object OnBack : OnboardingViewEvents()
}

View file

@ -48,6 +48,7 @@ import im.vector.app.features.login.ReAuthHelper
import im.vector.app.features.login.ServerType
import im.vector.app.features.login.SignMode
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.MatrixPatterns.getDomain
import org.matrix.android.sdk.api.auth.AuthenticationService
@ -156,12 +157,13 @@ class OnboardingViewModel @AssistedInject constructor(
is OnboardingAction.ResetAction -> handleResetAction(action)
is OnboardingAction.UserAcceptCertificate -> handleUserAcceptCertificate(action)
OnboardingAction.ClearHomeServerHistory -> handleClearHomeServerHistory()
is OnboardingAction.PostViewEvent -> _viewEvents.post(action.viewEvent)
is OnboardingAction.UpdateDisplayName -> updateDisplayName(action.displayName)
OnboardingAction.UpdateDisplayNameSkipped -> _viewEvents.post(OnboardingViewEvents.OnDisplayNameSkipped)
OnboardingAction.UpdateProfilePictureSkipped -> _viewEvents.post(OnboardingViewEvents.OnPersonalizationComplete)
OnboardingAction.UpdateDisplayNameSkipped -> handleDisplayNameStepComplete()
OnboardingAction.UpdateProfilePictureSkipped -> completePersonalization()
OnboardingAction.PersonalizeProfile -> handlePersonalizeProfile()
is OnboardingAction.ProfilePictureSelected -> handleProfilePictureSelected(action)
OnboardingAction.SaveSelectedProfilePicture -> updateProfilePicture()
is OnboardingAction.PostViewEvent -> _viewEvents.post(action.viewEvent)
}.exhaustive
}
@ -762,15 +764,33 @@ class OnboardingViewModel @AssistedInject constructor(
authenticationService.reset()
session.configureAndStart(applicationContext)
setState {
copy(
asyncLoginAction = Success(Unit)
)
}
when (isAccountCreated) {
true -> _viewEvents.post(OnboardingViewEvents.OnAccountCreated)
false -> _viewEvents.post(OnboardingViewEvents.OnAccountSignedIn)
true -> {
val personalizationState = createPersonalizationState(session, state)
setState {
copy(asyncLoginAction = Success(Unit), personalizationState = personalizationState)
}
_viewEvents.post(OnboardingViewEvents.OnAccountCreated)
}
false -> {
setState { copy(asyncLoginAction = Success(Unit)) }
_viewEvents.post(OnboardingViewEvents.OnAccountSignedIn)
}
}
}
private suspend fun createPersonalizationState(session: Session, state: OnboardingViewState): PersonalizationState {
return when {
vectorFeatures.isOnboardingPersonalizeEnabled() -> {
val homeServerCapabilities = session.getHomeServerCapabilities()
val capabilityOverrides = vectorOverrides.forceHomeserverCapabilities?.firstOrNull()
state.personalizationState.copy(
supportsChangingDisplayName = capabilityOverrides?.canChangeDisplayName ?: homeServerCapabilities.canChangeDisplayName,
supportsChangingProfilePicture = capabilityOverrides?.canChangeAvatar ?: homeServerCapabilities.canChangeAvatar
)
}
else -> state.personalizationState
}
}
@ -910,7 +930,7 @@ class OnboardingViewModel @AssistedInject constructor(
personalizationState = personalizationState.copy(displayName = displayName)
)
}
_viewEvents.post(OnboardingViewEvents.OnDisplayNameUpdated)
handleDisplayNameStepComplete()
} catch (error: Throwable) {
setState { copy(asyncDisplayName = Fail(error)) }
_viewEvents.post(OnboardingViewEvents.Failure(error))
@ -918,12 +938,37 @@ class OnboardingViewModel @AssistedInject constructor(
}
}
private fun handlePersonalizeProfile() {
withPersonalisationState {
when {
it.supportsChangingDisplayName -> _viewEvents.post(OnboardingViewEvents.OnChooseDisplayName)
it.supportsChangingProfilePicture -> _viewEvents.post(OnboardingViewEvents.OnChooseProfilePicture)
else -> {
throw IllegalStateException("It should not be possible to personalize without supporting display name or avatar changing")
}
}
}
}
private fun handleDisplayNameStepComplete() {
withPersonalisationState {
when {
it.supportsChangingProfilePicture -> _viewEvents.post(OnboardingViewEvents.OnChooseProfilePicture)
else -> completePersonalization()
}
}
}
private fun handleProfilePictureSelected(action: OnboardingAction.ProfilePictureSelected) {
setState {
copy(personalizationState = personalizationState.copy(selectedPictureUri = action.uri))
}
}
private fun withPersonalisationState(block: (PersonalizationState) -> Unit) {
withState { block(it.personalizationState) }
}
private fun updateProfilePicture() {
withState { state ->
when (val pictureUri = state.personalizationState.selectedPictureUri) {
@ -955,6 +1000,10 @@ class OnboardingViewModel @AssistedInject constructor(
}
private fun onProfilePictureSaved() {
completePersonalization()
}
private fun completePersonalization() {
_viewEvents.post(OnboardingViewEvents.OnPersonalizationComplete)
}
}

View file

@ -22,7 +22,6 @@ import com.airbnb.mvrx.Async
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.PersistState
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.Uninitialized
import im.vector.app.features.login.LoginMode
import im.vector.app.features.login.ServerType
@ -83,10 +82,6 @@ data class OnboardingViewState(
asyncDisplayName is Loading ||
asyncProfilePicture is Loading
}
fun isAuthTaskCompleted(): Boolean {
return asyncLoginAction is Success
}
}
enum class OnboardingFlow {
@ -97,6 +92,11 @@ enum class OnboardingFlow {
@Parcelize
data class PersonalizationState(
val supportsChangingDisplayName: Boolean = false,
val supportsChangingProfilePicture: Boolean = false,
val displayName: String? = null,
val selectedPictureUri: Uri? = null
) : Parcelable
) : Parcelable {
fun supportsPersonalization() = supportsChangingDisplayName || supportsChangingProfilePicture
}

View file

@ -20,11 +20,13 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import im.vector.app.R
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.databinding.FragmentFtueAccountCreatedBinding
import im.vector.app.features.onboarding.OnboardingAction
import im.vector.app.features.onboarding.OnboardingViewEvents
import im.vector.app.features.onboarding.OnboardingViewState
import javax.inject.Inject
class FtueAuthAccountCreatedFragment @Inject constructor(
@ -42,8 +44,15 @@ class FtueAuthAccountCreatedFragment @Inject constructor(
private fun setupViews() {
views.accountCreatedSubtitle.text = getString(R.string.ftue_account_created_subtitle, activeSessionHolder.getActiveSession().myUserId)
views.accountCreatedPersonalize.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnPersonalizeProfile)) }
views.accountCreatedPersonalize.debouncedClicks { viewModel.handle(OnboardingAction.PersonalizeProfile) }
views.accountCreatedTakeMeHome.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnTakeMeHome)) }
views.accountCreatedTakeMeHomeCta.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnTakeMeHome)) }
}
override fun updateWithState(state: OnboardingViewState) {
val canPersonalize = state.personalizationState.supportsPersonalization()
views.personalizeButtonGroup.isVisible = canPersonalize
views.takeMeHomeButtonGroup.isVisible = !canPersonalize
}
override fun resetViewModel() {

View file

@ -22,6 +22,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.view.isInvisible
import com.airbnb.mvrx.withState
import im.vector.app.R
import im.vector.app.core.di.ActiveSessionHolder
@ -70,6 +71,8 @@ class FtueAuthChooseProfilePictureFragment @Inject constructor(
}
override fun updateWithState(state: OnboardingViewState) {
views.profilePictureToolbar.isInvisible = !state.personalizationState.supportsChangingDisplayName
val hasSetPicture = state.personalizationState.selectedPictureUri != null
views.profilePictureSubmit.isEnabled = hasSetPicture
views.changeProfilePictureIcon.setImageResource(if (hasSetPicture) R.drawable.ic_edit else R.drawable.ic_camera_plain)
@ -93,4 +96,14 @@ class FtueAuthChooseProfilePictureFragment @Inject constructor(
override fun resetViewModel() {
// Nothing to do
}
override fun onBackPressed(toolbarButton: Boolean): Boolean {
return when (withState(viewModel) { it.personalizationState.supportsChangingDisplayName }) {
true -> super.onBackPressed(toolbarButton)
false -> {
viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnTakeMeHome))
true
}
}
}
}

View file

@ -122,17 +122,9 @@ class FtueAuthVariant(
private fun updateWithState(viewState: OnboardingViewState) {
isForceLoginFallbackEnabled = viewState.isForceLoginFallbackEnabled
views.loginLoading.isVisible = shouldShowLoading(viewState)
views.loginLoading.isVisible = viewState.isLoading()
}
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) {
@ -230,12 +222,11 @@ class FtueAuthVariant(
FtueAuthUseCaseFragment::class.java,
option = commonOption)
}
OnboardingViewEvents.OnAccountCreated -> onAccountCreated()
is OnboardingViewEvents.OnAccountCreated -> onAccountCreated()
OnboardingViewEvents.OnAccountSignedIn -> onAccountSignedIn()
OnboardingViewEvents.OnPersonalizeProfile -> onPersonalizeProfile()
OnboardingViewEvents.OnChooseDisplayName -> onChooseDisplayName()
OnboardingViewEvents.OnTakeMeHome -> navigateToHome(createdAccount = true)
OnboardingViewEvents.OnDisplayNameUpdated -> onDisplayNameUpdated()
OnboardingViewEvents.OnDisplayNameSkipped -> onDisplayNameUpdated()
OnboardingViewEvents.OnChooseProfilePicture -> onChooseProfilePicture()
OnboardingViewEvents.OnPersonalizationComplete -> navigateToHome(createdAccount = true)
OnboardingViewEvents.OnBack -> activity.popBackstack()
}.exhaustive
@ -399,15 +390,11 @@ class FtueAuthVariant(
}
private fun onAccountCreated() {
if (vectorFeatures.isOnboardingPersonalizeEnabled()) {
activity.supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
activity.replaceFragment(
views.loginFragmentContainer,
FtueAuthAccountCreatedFragment::class.java,
)
} else {
navigateToHome(createdAccount = true)
}
activity.supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
activity.replaceFragment(
views.loginFragmentContainer,
FtueAuthAccountCreatedFragment::class.java
)
}
private fun navigateToHome(createdAccount: Boolean) {
@ -416,14 +403,14 @@ class FtueAuthVariant(
activity.finish()
}
private fun onPersonalizeProfile() {
private fun onChooseDisplayName() {
activity.addFragmentToBackstack(views.loginFragmentContainer,
FtueAuthChooseDisplayNameFragment::class.java,
option = commonOption
)
}
private fun onDisplayNameUpdated() {
private fun onChooseProfilePicture() {
activity.addFragmentToBackstack(views.loginFragmentContainer,
FtueAuthChooseProfilePictureFragment::class.java,
option = commonOption

View file

@ -86,6 +86,14 @@
app:layout_constraintBottom_toTopOf="@id/accountCreatedPersonalize"
app:layout_constraintTop_toBottomOf="@id/accountCreatedSubtitle" />
<androidx.constraintlayout.widget.Group
android:id="@+id/personalizeButtonGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:constraint_referenced_ids="accountCreatedPersonalize,accountCreatedTakeMeHome"
tools:visibility="visible" />
<Button
android:id="@+id/accountCreatedPersonalize"
style="@style/Widget.Vector.Button.Login"
@ -96,11 +104,10 @@
android:textAllCaps="true"
android:textColor="?colorSecondary"
android:transitionName="loginSubmitTransition"
app:layout_constraintBottom_toTopOf="@id/accountCreatedSpace5"
app:layout_constraintBottom_toTopOf="@id/accountCreatedTakeMeHome"
app:layout_constraintEnd_toEndOf="@id/ftueAuthGutterEnd"
app:layout_constraintStart_toStartOf="@id/ftueAuthGutterStart"
app:layout_constraintTop_toBottomOf="@id/accountCreatedSpace4"
tools:text="@string/ftue_account_created_personalize" />
app:layout_constraintTop_toBottomOf="@id/accountCreatedSpace4" />
<Button
android:id="@+id/accountCreatedTakeMeHome"
@ -111,17 +118,46 @@
android:textAllCaps="true"
android:textColor="@color/element_background_light"
android:transitionName="loginSubmitTransition"
app:layout_constraintBottom_toTopOf="@id/accountCreatedSpace5"
app:layout_constraintBottom_toTopOf="@id/ctaBottomBarrier"
app:layout_constraintEnd_toEndOf="@id/ftueAuthGutterEnd"
app:layout_constraintStart_toStartOf="@id/ftueAuthGutterStart"
app:layout_constraintTop_toBottomOf="@id/accountCreatedPersonalize" />
<androidx.constraintlayout.widget.Group
android:id="@+id/takeMeHomeButtonGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:constraint_referenced_ids="accountCreatedTakeMeHomeCta" />
<Button
android:id="@+id/accountCreatedTakeMeHomeCta"
style="@style/Widget.Vector.Button.Login"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:backgroundTint="@color/element_background_light"
android:text="@string/ftue_account_created_take_me_home"
android:textAllCaps="true"
android:textColor="?colorSecondary"
android:transitionName="loginSubmitTransition"
app:layout_constraintBottom_toTopOf="@id/ctaBottomBarrier"
app:layout_constraintEnd_toEndOf="@id/ftueAuthGutterEnd"
app:layout_constraintStart_toStartOf="@id/ftueAuthGutterStart"
app:layout_constraintTop_toBottomOf="@id/accountCreatedSpace4" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/ctaBottomBarrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:constraint_referenced_ids="accountCreatedTakeMeHomeCta,accountCreatedTakeMeHome" />
<Space
android:id="@+id/accountCreatedSpace5"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintHeight_percent="0.05"
app:layout_constraintTop_toBottomOf="@id/accountCreatedPersonalize" />
app:layout_constraintTop_toBottomOf="@id/ctaBottomBarrier" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -31,6 +31,7 @@
style="@style/Widget.Vector.Toolbar.Settings"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="invisible"
app:layout_constraintBottom_toTopOf="@id/profilePictureView"
app:layout_constraintTop_toBottomOf="@id/profilePictureToolbar"
app:layout_constraintTop_toTopOf="parent"

View file

@ -20,8 +20,8 @@ import android.net.Uri
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.Uninitialized
import com.airbnb.mvrx.test.MvRxTestRule
import im.vector.app.features.DefaultVectorOverrides
import im.vector.app.features.login.ReAuthHelper
import im.vector.app.test.fakes.FakeActiveSessionHolder
import im.vector.app.test.fakes.FakeAnalyticsTracker
@ -29,20 +29,27 @@ import im.vector.app.test.fakes.FakeAuthenticationService
import im.vector.app.test.fakes.FakeContext
import im.vector.app.test.fakes.FakeHomeServerConnectionConfigFactory
import im.vector.app.test.fakes.FakeHomeServerHistoryService
import im.vector.app.test.fakes.FakeRegistrationWizard
import im.vector.app.test.fakes.FakeSession
import im.vector.app.test.fakes.FakeStringProvider
import im.vector.app.test.fakes.FakeUri
import im.vector.app.test.fakes.FakeUriFilenameResolver
import im.vector.app.test.fakes.FakeVectorFeatures
import im.vector.app.test.fakes.FakeVectorOverrides
import im.vector.app.test.test
import kotlinx.coroutines.test.runBlockingTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
private const val A_DISPLAY_NAME = "a display name"
private const val A_PICTURE_FILENAME = "a-picture.png"
private val AN_ERROR = RuntimeException("an error!")
private val AN_UNSUPPORTED_PERSONALISATION_STATE = PersonalizationState(
supportsChangingDisplayName = false,
supportsChangingProfilePicture = false
)
class OnboardingViewModelTest {
@ -55,6 +62,7 @@ class OnboardingViewModelTest {
private val fakeSession = FakeSession()
private val fakeUriFilenameResolver = FakeUriFilenameResolver()
private val fakeActiveSessionHolder = FakeActiveSessionHolder(fakeSession)
private val fakeAuthenticationService = FakeAuthenticationService()
lateinit var viewModel: OnboardingViewModel
@ -75,21 +83,84 @@ class OnboardingViewModelTest {
}
@Test
fun `when handling display name update then updates upstream user display name`() = runBlockingTest {
fun `given supports changing display name when handling PersonalizeProfile then emits contents choose display name`() = runBlockingTest {
val initialState = initialState.copy(personalizationState = PersonalizationState(supportsChangingDisplayName = true, supportsChangingProfilePicture = false))
viewModel = createViewModel(initialState)
val test = viewModel.test(this)
viewModel.handle(OnboardingAction.PersonalizeProfile)
test
.assertEvents(OnboardingViewEvents.OnChooseDisplayName)
.finish()
}
@Test
fun `given only supports changing profile picture when handling PersonalizeProfile then emits contents choose profile picture`() = runBlockingTest {
val initialState = initialState.copy(personalizationState = PersonalizationState(supportsChangingDisplayName = false, supportsChangingProfilePicture = true))
viewModel = createViewModel(initialState)
val test = viewModel.test(this)
viewModel.handle(OnboardingAction.PersonalizeProfile)
test
.assertEvents(OnboardingViewEvents.OnChooseProfilePicture)
.finish()
}
@Test
fun `given homeserver does not support personalisation when registering account then updates state and emits account created event`() = runBlockingTest {
fakeSession.fakeHomeServerCapabilitiesService.givenCapabilities(HomeServerCapabilities(canChangeDisplayName = false, canChangeAvatar = false))
givenSuccessfullyCreatesAccount()
val test = viewModel.test(this)
viewModel.handle(OnboardingAction.RegisterDummy)
test
.assertStates(
initialState,
initialState.copy(asyncRegistration = Loading()),
initialState.copy(
asyncLoginAction = Success(Unit),
asyncRegistration = Loading(),
personalizationState = AN_UNSUPPORTED_PERSONALISATION_STATE
),
initialState.copy(
asyncLoginAction = Success(Unit),
asyncRegistration = Uninitialized,
personalizationState = AN_UNSUPPORTED_PERSONALISATION_STATE
)
)
.assertEvents(OnboardingViewEvents.OnAccountCreated)
.finish()
}
@Test
fun `given changing profile picture is supported when updating display name then updates upstream user display name and moves to choose profile picture`() = runBlockingTest {
val personalisedInitialState = initialState.copy(personalizationState = PersonalizationState(supportsChangingProfilePicture = true))
viewModel = createViewModel(personalisedInitialState)
val test = viewModel.test(this)
viewModel.handle(OnboardingAction.UpdateDisplayName(A_DISPLAY_NAME))
test
.assertStates(
initialState,
initialState.copy(asyncDisplayName = Loading()),
initialState.copy(
asyncDisplayName = Success(Unit),
personalizationState = initialState.personalizationState.copy(displayName = A_DISPLAY_NAME)
)
)
.assertEvents(OnboardingViewEvents.OnDisplayNameUpdated)
.assertStates(expectedSuccessfulDisplayNameUpdateStates(personalisedInitialState))
.assertEvents(OnboardingViewEvents.OnChooseProfilePicture)
.finish()
fakeSession.fakeProfileService.verifyUpdatedName(fakeSession.myUserId, A_DISPLAY_NAME)
}
@Test
fun `given changing profile picture is not supported when updating display name then updates upstream user display name and completes personalization`() = runBlockingTest {
val personalisedInitialState = initialState.copy(personalizationState = PersonalizationState(supportsChangingProfilePicture = false))
viewModel = createViewModel(personalisedInitialState)
val test = viewModel.test(this)
viewModel.handle(OnboardingAction.UpdateDisplayName(A_DISPLAY_NAME))
test
.assertStates(expectedSuccessfulDisplayNameUpdateStates(personalisedInitialState))
.assertEvents(OnboardingViewEvents.OnPersonalizationComplete)
.finish()
fakeSession.fakeProfileService.verifyUpdatedName(fakeSession.myUserId, A_DISPLAY_NAME)
}
@ -184,7 +255,7 @@ class OnboardingViewModelTest {
return OnboardingViewModel(
state,
fakeContext.instance,
FakeAuthenticationService(),
fakeAuthenticationService,
fakeActiveSessionHolder.instance,
FakeHomeServerConnectionConfigFactory().instance,
ReAuthHelper(),
@ -193,7 +264,7 @@ class OnboardingViewModelTest {
FakeVectorFeatures(),
FakeAnalyticsTracker(),
fakeUriFilenameResolver.instance,
DefaultVectorOverrides()
FakeVectorOverrides()
)
}
@ -214,4 +285,23 @@ class OnboardingViewModelTest {
state.copy(asyncProfilePicture = Loading()),
state.copy(asyncProfilePicture = Fail(cause))
)
private fun givenSuccessfullyCreatesAccount() {
fakeActiveSessionHolder.expectSetsActiveSession(fakeSession)
val registrationWizard = FakeRegistrationWizard().also { it.givenSuccessfulDummy(fakeSession) }
fakeAuthenticationService.givenRegistrationWizard(registrationWizard)
fakeAuthenticationService.expectReset()
fakeSession.expectStartsSyncing()
}
private fun expectedSuccessfulDisplayNameUpdateStates(personalisedInitialState: OnboardingViewState): List<OnboardingViewState> {
return listOf(
personalisedInitialState,
personalisedInitialState.copy(asyncDisplayName = Loading()),
personalisedInitialState.copy(
asyncDisplayName = Success(Unit),
personalizationState = personalisedInitialState.personalizationState.copy(displayName = A_DISPLAY_NAME)
)
)
}
}

View file

@ -18,7 +18,9 @@ package im.vector.app.test.fakes
import im.vector.app.core.di.ActiveSessionHolder
import io.mockk.every
import io.mockk.justRun
import io.mockk.mockk
import org.matrix.android.sdk.api.session.Session
class FakeActiveSessionHolder(
private val fakeSession: FakeSession = FakeSession()
@ -26,4 +28,8 @@ class FakeActiveSessionHolder(
val instance = mockk<ActiveSessionHolder> {
every { getActiveSession() } returns fakeSession
}
fun expectSetsActiveSession(session: Session) {
justRun { instance.setActiveSession(session) }
}
}

View file

@ -16,7 +16,18 @@
package im.vector.app.test.fakes
import io.mockk.coJustRun
import io.mockk.every
import io.mockk.mockk
import org.matrix.android.sdk.api.auth.AuthenticationService
import org.matrix.android.sdk.api.auth.registration.RegistrationWizard
class FakeAuthenticationService : AuthenticationService by mockk()
class FakeAuthenticationService : AuthenticationService by mockk() {
fun givenRegistrationWizard(registrationWizard: RegistrationWizard) {
every { getRegistrationWizard() } returns registrationWizard
}
fun expectReset() {
coJustRun { reset() }
}
}

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2022 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.app.test.fakes
import io.mockk.every
import io.mockk.mockk
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService
class FakeHomeServerCapabilitiesService : HomeServerCapabilitiesService by mockk() {
fun givenCapabilities(homeServerCapabilities: HomeServerCapabilities) {
every { getHomeServerCapabilities() } returns homeServerCapabilities
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2022 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.app.test.fakes
import io.mockk.coEvery
import io.mockk.mockk
import org.matrix.android.sdk.api.auth.registration.RegistrationResult
import org.matrix.android.sdk.api.auth.registration.RegistrationWizard
import org.matrix.android.sdk.api.session.Session
class FakeRegistrationWizard : RegistrationWizard by mockk() {
fun givenSuccessfulDummy(session: Session) {
coEvery { dummy() } returns RegistrationResult.Success(session)
}
}

View file

@ -17,10 +17,13 @@
package im.vector.app.test.fakes
import android.net.Uri
import im.vector.app.core.extensions.configureAndStart
import im.vector.app.core.extensions.startSyncing
import im.vector.app.core.extensions.vectorStore
import im.vector.app.features.session.VectorSessionStore
import im.vector.app.test.testCoroutineDispatchers
import io.mockk.coEvery
import io.mockk.coJustRun
import io.mockk.mockk
import io.mockk.mockkStatic
import org.matrix.android.sdk.api.session.Session
@ -28,6 +31,7 @@ import org.matrix.android.sdk.api.session.Session
class FakeSession(
val fakeCryptoService: FakeCryptoService = FakeCryptoService(),
val fakeProfileService: FakeProfileService = FakeProfileService(),
val fakeHomeServerCapabilitiesService: FakeHomeServerCapabilitiesService = FakeHomeServerCapabilitiesService(),
val fakeSharedSecretStorageService: FakeSharedSecretStorageService = FakeSharedSecretStorageService()
) : Session by mockk(relaxed = true) {
@ -42,6 +46,7 @@ class FakeSession(
override val coroutineDispatchers = testCoroutineDispatchers
override suspend fun setDisplayName(userId: String, newDisplayName: String) = fakeProfileService.setDisplayName(userId, newDisplayName)
override suspend fun updateAvatar(userId: String, newAvatarUri: Uri, fileName: String) = fakeProfileService.updateAvatar(userId, newAvatarUri, fileName)
override fun getHomeServerCapabilities() = fakeHomeServerCapabilitiesService.getHomeServerCapabilities()
fun givenVectorStore(vectorSessionStore: VectorSessionStore) {
coEvery {
@ -50,4 +55,11 @@ class FakeSession(
vectorSessionStore
}
}
fun expectStartsSyncing() {
coJustRun {
this@FakeSession.configureAndStart(any(), startSyncing = true)
this@FakeSession.startSyncing(any())
}
}
}

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2022 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.app.test.fakes
import im.vector.app.features.DefaultVectorOverrides
import im.vector.app.features.VectorOverrides
class FakeVectorOverrides : VectorOverrides by DefaultVectorOverrides()