FTUE - Choose a display picture (#5323)

* adding tests around the onboarding view model
- cases for the personalisation and display name actions

* adding base choose name fragment with UI

* add click handling for the display name actions

* adding tests around the onboarding view model
- cases for the personalisation and display name actions

* adding barebones profile picture fragment with ability to select a user avatar

* extracting uri filename resolving to a class which can be injected
- includes tests

* updating upstream avatar on profile picture save and continue step
- moves the personalisation state to a dedicated model to allow for back and forth state restoration

* adding test case for skipping profile picture setting

* taking the profile loading into account when rendering the onboarding loading

* extracting method for the handling of the profile picture selection

* adding dedicated camera icon for choosing profile picture

* adding toolbar to back to profile picture page
- this toolbar will fade in with the fragment as it sits at the fragment level, probably worth revisiting once more pages have a toolbar

* changing edit/add picture icon based on if we're already selected an image

* making use of debounced clicks to avoid potential extra clicks

* making the avatar height and camera icon relative percentage based
- also makes the avatar itself clicking, including a foreground ripple

* fixing formatting

* making use of fake session id for user id assertion

* using a real  matrix id syntax for the fake session user id

* removing duplicated dimens

* using self closing imageview tag
This commit is contained in:
Adam Brown 2022-03-07 14:07:22 +00:00 committed by GitHub
parent 9af2f1cdc6
commit 9a02543afd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 807 additions and 43 deletions

View file

@ -17,7 +17,12 @@
package im.vector.lib.multipicker.utils
import android.database.Cursor
import androidx.core.database.getStringOrNull
fun Cursor.getColumnIndexOrNull(column: String): Int? {
return getColumnIndex(column).takeIf { it != -1 }
}
fun Cursor.readStringColumnOrNull(column: String): String? {
return getColumnIndexOrNull(column)?.let { getStringOrNull(it) }
}

View file

@ -67,4 +67,7 @@
<item name="ftue_auth_carousel_item_spacing" format="float" type="dimen">0.01</item>
<item name="ftue_auth_carousel_item_image_height" format="float" type="dimen">0.35</item>
<item name="ftue_auth_profile_picture_height" format="float" type="dimen">0.15</item>
<item name="ftue_auth_profile_picture_icon_height" format="float" type="dimen">0.05</item>
</resources>

View file

@ -100,6 +100,7 @@ import im.vector.app.features.matrixto.MatrixToUserFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthAccountCreatedFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthCaptchaFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthChooseDisplayNameFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthChooseProfilePictureFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthGenericTextInputFormFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthLoginFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthResetPasswordFragment
@ -485,6 +486,11 @@ interface FragmentModule {
@FragmentKey(FtueAuthChooseDisplayNameFragment::class)
fun bindFtueAuthChooseDisplayNameFragment(fragment: FtueAuthChooseDisplayNameFragment): Fragment
@Binds
@IntoMap
@FragmentKey(FtueAuthChooseProfilePictureFragment::class)
fun bindFtueAuthChooseProfilePictureFragment(fragment: FtueAuthChooseProfilePictureFragment): Fragment
@Binds
@IntoMap
@FragmentKey(UserListFragment::class)

View file

@ -18,6 +18,7 @@ package im.vector.app.features.home
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.net.Uri
import android.widget.ImageView
import androidx.annotation.AnyThread
import androidx.annotation.ColorInt
@ -48,6 +49,7 @@ import org.matrix.android.sdk.api.auth.login.LoginProfileInfo
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.content.ContentUrlResolver
import org.matrix.android.sdk.api.util.MatrixItem
import java.io.File
import javax.inject.Inject
/**
@ -100,6 +102,16 @@ class AvatarRenderer @Inject constructor(private val activeSessionHolder: Active
DrawableImageViewTarget(imageView))
}
@UiThread
fun render(matrixItem: MatrixItem, localUri: Uri?, imageView: ImageView) {
val placeholder = getPlaceholderDrawable(matrixItem)
GlideApp.with(imageView)
.load(localUri?.let { File(localUri.path!!) })
.apply(RequestOptions.circleCropTransform())
.placeholder(placeholder)
.into(imageView)
}
@UiThread
fun render(mappedContact: MappedContact, imageView: ImageView) {
// Create a Fake MatrixItem, for the placeholder

View file

@ -16,6 +16,7 @@
package im.vector.app.features.onboarding
import android.net.Uri
import im.vector.app.core.platform.VectorViewModelAction
import im.vector.app.features.login.LoginConfig
import im.vector.app.features.login.ServerType
@ -76,4 +77,7 @@ sealed class OnboardingAction : VectorViewModelAction {
data class UpdateDisplayName(val displayName: String) : OnboardingAction()
object UpdateDisplayNameSkipped : OnboardingAction()
data class ProfilePictureSelected(val uri: Uri) : OnboardingAction()
object SaveSelectedProfilePicture : OnboardingAction()
object UpdateProfilePictureSkipped : OnboardingAction()
}

View file

@ -54,4 +54,6 @@ sealed class OnboardingViewEvents : VectorViewEvents {
object OnPersonalizeProfile : OnboardingViewEvents()
object OnDisplayNameUpdated : OnboardingViewEvents()
object OnDisplayNameSkipped : OnboardingViewEvents()
object OnPersonalizationComplete : OnboardingViewEvents()
object OnBack : OnboardingViewEvents()
}

View file

@ -64,6 +64,7 @@ import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.failure.MatrixIdFailure
import org.matrix.android.sdk.api.session.Session
import timber.log.Timber
import java.util.UUID
import java.util.concurrent.CancellationException
/**
@ -80,6 +81,7 @@ class OnboardingViewModel @AssistedInject constructor(
private val homeServerHistoryService: HomeServerHistoryService,
private val vectorFeatures: VectorFeatures,
private val analyticsTracker: AnalyticsTracker,
private val uriFilenameResolver: UriFilenameResolver,
private val vectorOverrides: VectorOverrides
) : VectorViewModel<OnboardingViewState, OnboardingAction, OnboardingViewEvents>(initialState) {
@ -157,6 +159,9 @@ class OnboardingViewModel @AssistedInject constructor(
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)
is OnboardingAction.ProfilePictureSelected -> handleProfilePictureSelected(action)
OnboardingAction.SaveSelectedProfilePicture -> updateProfilePicture()
}.exhaustive
}
@ -899,7 +904,12 @@ class OnboardingViewModel @AssistedInject constructor(
val activeSession = activeSessionHolder.getActiveSession()
try {
activeSession.setDisplayName(activeSession.myUserId, displayName)
setState { copy(asyncDisplayName = Success(Unit)) }
setState {
copy(
asyncDisplayName = Success(Unit),
personalizationState = personalizationState.copy(displayName = displayName)
)
}
_viewEvents.post(OnboardingViewEvents.OnDisplayNameUpdated)
} catch (error: Throwable) {
setState { copy(asyncDisplayName = Fail(error)) }
@ -907,6 +917,46 @@ class OnboardingViewModel @AssistedInject constructor(
}
}
}
private fun handleProfilePictureSelected(action: OnboardingAction.ProfilePictureSelected) {
setState {
copy(personalizationState = personalizationState.copy(selectedPictureUri = action.uri))
}
}
private fun updateProfilePicture() {
withState { state ->
when (val pictureUri = state.personalizationState.selectedPictureUri) {
null -> _viewEvents.post(OnboardingViewEvents.Failure(NullPointerException("picture uri is missing from state")))
else -> {
setState { copy(asyncProfilePicture = Loading()) }
viewModelScope.launch {
val activeSession = activeSessionHolder.getActiveSession()
try {
activeSession.updateAvatar(
activeSession.myUserId,
pictureUri,
uriFilenameResolver.getFilenameFromUri(pictureUri) ?: UUID.randomUUID().toString()
)
setState {
copy(
asyncProfilePicture = Success(Unit),
)
}
onProfilePictureSaved()
} catch (error: Throwable) {
setState { copy(asyncProfilePicture = Fail(error)) }
_viewEvents.post(OnboardingViewEvents.Failure(error))
}
}
}
}
}
}
private fun onProfilePictureSaved() {
_viewEvents.post(OnboardingViewEvents.OnPersonalizationComplete)
}
}
private fun LoginMode.supportsSignModeScreen(): Boolean {

View file

@ -16,6 +16,8 @@
package im.vector.app.features.onboarding
import android.net.Uri
import android.os.Parcelable
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MavericksState
@ -25,6 +27,7 @@ import com.airbnb.mvrx.Uninitialized
import im.vector.app.features.login.LoginMode
import im.vector.app.features.login.ServerType
import im.vector.app.features.login.SignMode
import kotlinx.parcelize.Parcelize
data class OnboardingViewState(
val asyncLoginAction: Async<Unit> = Uninitialized,
@ -33,6 +36,7 @@ data class OnboardingViewState(
val asyncResetMailConfirmed: Async<Unit> = Uninitialized,
val asyncRegistration: Async<Unit> = Uninitialized,
val asyncDisplayName: Async<Unit> = Uninitialized,
val asyncProfilePicture: Async<Unit> = Uninitialized,
@PersistState
val onboardingFlow: OnboardingFlow? = null,
@ -65,6 +69,9 @@ data class OnboardingViewState(
val loginModeSupportedTypes: List<String> = emptyList(),
val knownCustomHomeServersUrls: List<String> = emptyList(),
val isForceLoginFallbackEnabled: Boolean = false,
@PersistState
val personalizationState: PersonalizationState = PersonalizationState()
) : MavericksState {
fun isLoading(): Boolean {
@ -73,7 +80,8 @@ data class OnboardingViewState(
asyncResetPassword is Loading ||
asyncResetMailConfirmed is Loading ||
asyncRegistration is Loading ||
asyncDisplayName is Loading
asyncDisplayName is Loading ||
asyncProfilePicture is Loading
}
fun isAuthTaskCompleted(): Boolean {
@ -86,3 +94,9 @@ enum class OnboardingFlow {
SignUp,
SignInSignUp
}
@Parcelize
data class PersonalizationState(
val displayName: String? = null,
val selectedPictureUri: Uri? = null
) : Parcelable

View file

@ -0,0 +1,41 @@
/*
* 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.onboarding
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import im.vector.lib.multipicker.utils.readStringColumnOrNull
import javax.inject.Inject
class UriFilenameResolver @Inject constructor(private val context: Context) {
fun getFilenameFromUri(uri: Uri): String? {
val fallback = uri.path?.substringAfterLast('/')
return when (uri.scheme) {
"content" -> readResolvedDisplayName(uri) ?: fallback
else -> fallback
}
}
private fun readResolvedDisplayName(uri: Uri): String? {
return context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
cursor.takeIf { cursor.moveToFirst() }
?.readStringColumnOrNull(OpenableColumns.DISPLAY_NAME)
}
}
}

View file

@ -27,6 +27,7 @@ import im.vector.app.core.platform.SimpleTextWatcher
import im.vector.app.databinding.FragmentFtueDisplayNameBinding
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 FtueAuthChooseDisplayNameFragment @Inject constructor() : AbstractFtueAuthFragment<FragmentFtueDisplayNameBinding>() {
@ -41,7 +42,6 @@ class FtueAuthChooseDisplayNameFragment @Inject constructor() : AbstractFtueAuth
}
private fun setupViews() {
views.displayNameSubmit.isEnabled = views.displayNameInput.hasContentEmpty()
views.displayNameInput.editText?.addTextChangedListener(object : SimpleTextWatcher() {
override fun afterTextChanged(s: Editable) {
val newContent = s.toString()
@ -58,10 +58,7 @@ class FtueAuthChooseDisplayNameFragment @Inject constructor() : AbstractFtueAuth
}
}
views.displayNameSubmit.debouncedClicks {
updateDisplayName()
}
views.displayNameSubmit.debouncedClicks { updateDisplayName() }
views.displayNameSkip.debouncedClicks { viewModel.handle(OnboardingAction.UpdateDisplayNameSkipped) }
}
@ -70,6 +67,11 @@ class FtueAuthChooseDisplayNameFragment @Inject constructor() : AbstractFtueAuth
viewModel.handle(OnboardingAction.UpdateDisplayName(newDisplayName))
}
override fun updateWithState(state: OnboardingViewState) {
views.displayNameInput.editText?.setText(state.personalizationState.displayName)
views.displayNameSubmit.isEnabled = views.displayNameInput.hasContentEmpty()
}
override fun resetViewModel() {
// Nothing to do
}

View file

@ -0,0 +1,96 @@
/*
* 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.onboarding.ftueauth
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import com.airbnb.mvrx.withState
import im.vector.app.R
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.dialogs.GalleryOrCameraDialogHelper
import im.vector.app.core.extensions.singletonEntryPoint
import im.vector.app.core.resources.ColorProvider
import im.vector.app.databinding.FragmentFtueProfilePictureBinding
import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.onboarding.OnboardingAction
import im.vector.app.features.onboarding.OnboardingViewEvents
import im.vector.app.features.onboarding.OnboardingViewState
import org.matrix.android.sdk.api.util.MatrixItem
import javax.inject.Inject
class FtueAuthChooseProfilePictureFragment @Inject constructor(
private val activeSessionHolder: ActiveSessionHolder,
colorProvider: ColorProvider
) : AbstractFtueAuthFragment<FragmentFtueProfilePictureBinding>(), GalleryOrCameraDialogHelper.Listener {
private val galleryOrCameraDialogHelper = GalleryOrCameraDialogHelper(this, colorProvider)
private val avatarRenderer: AvatarRenderer by lazy { requireContext().singletonEntryPoint().avatarRenderer() }
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFtueProfilePictureBinding {
return FragmentFtueProfilePictureBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupViews()
}
private fun setupViews() {
views.profilePictureToolbar.setNavigationOnClickListener {
viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnBack))
}
views.changeProfilePictureButton.debouncedClicks { galleryOrCameraDialogHelper.show() }
views.profilePictureView.debouncedClicks { galleryOrCameraDialogHelper.show() }
views.profilePictureSubmit.debouncedClicks {
withState(viewModel) {
viewModel.handle(OnboardingAction.SaveSelectedProfilePicture)
}
}
views.profilePictureSkip.debouncedClicks { viewModel.handle(OnboardingAction.UpdateProfilePictureSkipped) }
}
override fun updateWithState(state: OnboardingViewState) {
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)
val session = activeSessionHolder.getActiveSession()
val matrixItem = MatrixItem.UserItem(
id = session.myUserId,
displayName = state.personalizationState.displayName ?: ""
)
avatarRenderer.render(matrixItem, localUri = state.personalizationState.selectedPictureUri, imageView = views.profilePictureView)
}
override fun onImageReady(uri: Uri?) {
if (uri == null) {
Toast.makeText(requireContext(), "Cannot retrieve cropped value", Toast.LENGTH_SHORT).show()
} else {
viewModel.handle(OnboardingAction.ProfilePictureSelected(uri))
}
}
override fun resetViewModel() {
// Nothing to do
}
}

View file

@ -32,6 +32,7 @@ import im.vector.app.core.extensions.POP_BACK_STACK_EXCLUSIVE
import im.vector.app.core.extensions.addFragment
import im.vector.app.core.extensions.addFragmentToBackstack
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.extensions.popBackstack
import im.vector.app.core.extensions.replaceFragment
import im.vector.app.core.platform.ScreenOrientationLocker
import im.vector.app.core.platform.VectorBaseActivity
@ -235,6 +236,8 @@ class FtueAuthVariant(
OnboardingViewEvents.OnTakeMeHome -> navigateToHome(createdAccount = true)
OnboardingViewEvents.OnDisplayNameUpdated -> onDisplayNameUpdated()
OnboardingViewEvents.OnDisplayNameSkipped -> onDisplayNameUpdated()
OnboardingViewEvents.OnPersonalizationComplete -> navigateToHome(createdAccount = true)
OnboardingViewEvents.OnBack -> activity.popBackstack()
}.exhaustive
}
@ -421,7 +424,9 @@ class FtueAuthVariant(
}
private fun onDisplayNameUpdated() {
// TODO go to the real profile picture fragment
navigateToHome(createdAccount = true)
activity.addFragmentToBackstack(views.loginFragmentContainer,
FtueAuthChooseProfilePictureFragment::class.java,
option = commonOption
)
}
}

View file

@ -0,0 +1,164 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
style="@style/LoginFormScrollView"
android:layout_height="match_parent"
android:background="?android:colorBackground"
android:fillViewport="true"
android:paddingTop="0dp"
android:paddingBottom="0dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/profilePictureGutterStart"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="vertical"
app:layout_constraintGuide_percent="@dimen/ftue_auth_gutter_start_percent" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/profilePictureGutterEnd"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="vertical"
app:layout_constraintGuide_percent="@dimen/ftue_auth_gutter_end_percent" />
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/profilePictureToolbar"
style="@style/Widget.Vector.Toolbar.Settings"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@id/profilePictureView"
app:layout_constraintTop_toBottomOf="@id/profilePictureToolbar"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0"
app:layout_constraintVertical_chainStyle="packed"
app:navigationIcon="@drawable/ic_back_24dp" />
<ImageView
android:id="@+id/profilePictureView"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:adjustViewBounds="true"
android:contentDescription="@null"
android:foreground="@drawable/bg_rounded_button"
android:src="@drawable/ic_user_round"
app:layout_constraintBottom_toTopOf="@id/avatarTitleSpacing"
app:layout_constraintEnd_toEndOf="@id/profilePictureGutterEnd"
app:layout_constraintHeight_percent="@dimen/ftue_auth_profile_picture_height"
app:layout_constraintStart_toStartOf="@id/profilePictureGutterStart"
app:layout_constraintTop_toBottomOf="@id/profilePictureToolbar" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/changeProfilePictureButton"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:background="@drawable/bg_rounded_button"
android:backgroundTint="?vctr_system"
app:layout_constraintBottom_toBottomOf="@id/profilePictureView"
app:layout_constraintEnd_toEndOf="@id/profilePictureView"
app:layout_constraintHeight_percent="@dimen/ftue_auth_profile_picture_icon_height"
app:layout_constraintHorizontal_bias="1"
app:layout_constraintStart_toStartOf="@id/profilePictureView"
app:layout_constraintTop_toTopOf="@id/profilePictureView"
app:layout_constraintVertical_bias="1">
<androidx.constraintlayout.helper.widget.Flow
android:id="@+id/pos"
android:layout_width="0dp"
android:layout_height="match_parent"
app:layout_constraintDimensionRatio="1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<ImageView
android:id="@+id/changeProfilePictureIcon"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:adjustViewBounds="true"
android:contentDescription="@string/ftue_profile_picture_title"
android:src="@drawable/ic_camera_plain"
app:layout_constraintBottom_toBottomOf="@id/pos"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_percent="0.55"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/pos"
app:tint="?vctr_content_secondary" />
</androidx.constraintlayout.widget.ConstraintLayout>
<Space
android:id="@+id/avatarTitleSpacing"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/profilePictureHeaderTitle"
app:layout_constraintHeight_percent="0.05"
app:layout_constraintTop_toBottomOf="@id/profilePictureView" />
<TextView
android:id="@+id/profilePictureHeaderTitle"
style="@style/Widget.Vector.TextView.Title.Medium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/ftue_profile_picture_title"
android:textColor="?vctr_content_primary"
app:layout_constraintBottom_toTopOf="@id/profilePictureHeaderSubtitle"
app:layout_constraintEnd_toEndOf="@id/profilePictureGutterEnd"
app:layout_constraintStart_toStartOf="@id/profilePictureGutterStart"
app:layout_constraintTop_toBottomOf="@id/avatarTitleSpacing" />
<TextView
android:id="@+id/profilePictureHeaderSubtitle"
style="@style/Widget.Vector.TextView.Subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:text="@string/ftue_profile_picture_subtitle"
android:textColor="?vctr_content_secondary"
app:layout_constraintBottom_toTopOf="@id/actionsSpacing"
app:layout_constraintEnd_toEndOf="@id/profilePictureGutterEnd"
app:layout_constraintStart_toStartOf="@id/profilePictureGutterStart"
app:layout_constraintTop_toBottomOf="@id/profilePictureHeaderTitle" />
<Space
android:id="@+id/actionsSpacing"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/profilePictureSubmit"
app:layout_constraintHeight_percent="0.05"
app:layout_constraintTop_toBottomOf="@id/profilePictureHeaderSubtitle" />
<Button
android:id="@+id/profilePictureSubmit"
style="@style/Widget.Vector.Button.Login"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/ftue_personalize_submit"
android:textAllCaps="true"
app:layout_constraintBottom_toTopOf="@id/profilePictureSkip"
app:layout_constraintEnd_toEndOf="@id/profilePictureGutterEnd"
app:layout_constraintStart_toStartOf="@id/profilePictureGutterStart"
app:layout_constraintTop_toBottomOf="@id/actionsSpacing" />
<Button
android:id="@+id/profilePictureSkip"
style="@style/Widget.Vector.Button.Text.Login"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/ftue_personalize_skip_this_step"
android:textAllCaps="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@id/profilePictureGutterEnd"
app:layout_constraintStart_toStartOf="@id/profilePictureGutterStart"
app:layout_constraintTop_toBottomOf="@id/profilePictureSubmit" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>

View file

@ -28,6 +28,10 @@
<string name="ftue_display_name_entry_title" translatable="false">Display Name</string>
<string name="ftue_display_name_entry_footer" translatable="false">You can change this later</string>
<string name="ftue_profile_picture_title" translatable="false">Add a profile picture</string>
<string name="ftue_profile_picture_subtitle" translatable="false">You can change this anytime.</string>
<string name="ftue_personalize_submit" translatable="false">Save and continue</string>
<string name="ftue_personalize_skip_this_step" translatable="false">Skip this step</string>
</resources>

View file

@ -43,7 +43,7 @@ class SharedSecureStorageViewModelTest {
val mvrxTestRule = MvRxTestRule()
private val stringProvider = FakeStringProvider()
private val session = FakeSession()
private val fakeSession = FakeSession()
val args = SharedSecureStorageActivity.Args(keyId = null, emptyList(), "alias")
@Test
@ -164,7 +164,7 @@ class SharedSecureStorageViewModelTest {
return SharedSecureStorageViewModel(
SharedSecureStorageViewState(args),
stringProvider.instance,
session
fakeSession
)
}
@ -175,15 +175,15 @@ class SharedSecureStorageViewModelTest {
step = step,
activeDeviceCount = 0,
showResetAllAction = false,
userId = ""
userId = fakeSession.myUserId
)
private fun givenKey(keyInfo: KeyInfo) {
givenHasAccessToSecrets()
session.fakeSharedSecretStorageService._defaultKey = KeyInfoResult.Success(keyInfo)
fakeSession.fakeSharedSecretStorageService._defaultKey = KeyInfoResult.Success(keyInfo)
}
private fun givenHasAccessToSecrets() {
session.fakeSharedSecretStorageService.integrityResult = IntegrityResult.Success(passphraseBased = IGNORED_PASSPHRASE_INTEGRITY)
fakeSession.fakeSharedSecretStorageService.integrityResult = IntegrityResult.Success(passphraseBased = IGNORED_PASSPHRASE_INTEGRITY)
}
}

View file

@ -16,6 +16,7 @@
package im.vector.app.features.onboarding
import android.net.Uri
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
@ -30,6 +31,8 @@ import im.vector.app.test.fakes.FakeHomeServerConnectionConfigFactory
import im.vector.app.test.fakes.FakeHomeServerHistoryService
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.test
import kotlinx.coroutines.test.runBlockingTest
@ -38,18 +41,23 @@ import org.junit.Rule
import org.junit.Test
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!")
class OnboardingViewModelTest {
@get:Rule
val mvrxTestRule = MvRxTestRule()
private val fakeUri = FakeUri()
private val fakeContext = FakeContext()
lateinit var viewModel: OnboardingViewModel
private val initialState = OnboardingViewState()
private val fakeSession = FakeSession()
private val fakeUriFilenameResolver = FakeUriFilenameResolver()
private val fakeActiveSessionHolder = FakeActiveSessionHolder(fakeSession)
lateinit var viewModel: OnboardingViewModel
@Before
fun setUp() {
viewModel = createViewModel()
@ -67,7 +75,7 @@ class OnboardingViewModelTest {
}
@Test
fun `when handling display name updates action then updates user display name and emits name updated event`() = runBlockingTest {
fun `when handling display name update then updates upstream user display name`() = runBlockingTest {
val test = viewModel.test(this)
viewModel.handle(OnboardingAction.UpdateDisplayName(A_DISPLAY_NAME))
@ -76,33 +84,105 @@ class OnboardingViewModelTest {
.assertStates(
initialState,
initialState.copy(asyncDisplayName = Loading()),
initialState.copy(asyncDisplayName = Success(Unit)),
initialState.copy(
asyncDisplayName = Success(Unit),
personalizationState = initialState.personalizationState.copy(displayName = A_DISPLAY_NAME)
)
)
.assertEvents(OnboardingViewEvents.OnDisplayNameUpdated)
.finish()
fakeSession.fakeProfileService.verifyUpdatedName(fakeSession.myUserId, A_DISPLAY_NAME)
}
@Test
fun `given upstream failure when handling display name update then emits failure event`() = runBlockingTest {
val test = viewModel.test(this)
fakeSession.fakeProfileService.givenSetDisplayNameErrors(AN_ERROR)
viewModel.handle(OnboardingAction.UpdateDisplayName(A_DISPLAY_NAME))
test
.assertStates(
initialState,
initialState.copy(asyncDisplayName = Loading()),
initialState.copy(asyncDisplayName = Fail(AN_ERROR)),
)
.assertEvents(OnboardingViewEvents.Failure(AN_ERROR))
.finish()
}
@Test
fun `given failure when handling display name updates action then emits failure event`() = runBlockingTest {
fun `when handling profile picture selected then updates selected picture state`() = runBlockingTest {
val test = viewModel.test(this)
val errorCause = RuntimeException("an error!")
fakeSession.fakeProfileService.givenSetDisplayNameErrors(errorCause)
viewModel.handle(OnboardingAction.UpdateDisplayName(A_DISPLAY_NAME))
viewModel.handle(OnboardingAction.ProfilePictureSelected(fakeUri.instance))
test
.assertStates(
initialState,
initialState.copy(asyncDisplayName = Loading()),
initialState.copy(asyncDisplayName = Fail(errorCause)),
initialState.copy(personalizationState = initialState.personalizationState.copy(selectedPictureUri = fakeUri.instance))
)
.assertEvents(OnboardingViewEvents.Failure(errorCause))
.assertNoEvents()
.finish()
}
private fun createViewModel(): OnboardingViewModel {
@Test
fun `given a selected picture when handling save selected profile picture then updates upstream avatar and completes personalization`() = runBlockingTest {
val initialStateWithPicture = givenPictureSelected(fakeUri.instance, A_PICTURE_FILENAME)
viewModel = createViewModel(initialStateWithPicture)
val test = viewModel.test(this)
viewModel.handle(OnboardingAction.SaveSelectedProfilePicture)
test
.assertStates(expectedProfilePictureSuccessStates(initialStateWithPicture))
.assertEvents(OnboardingViewEvents.OnPersonalizationComplete)
.finish()
fakeSession.fakeProfileService.verifyAvatarUpdated(fakeSession.myUserId, fakeUri.instance, A_PICTURE_FILENAME)
}
@Test
fun `given upstream update avatar fails when saving selected profile picture then emits failure event`() = runBlockingTest {
fakeSession.fakeProfileService.givenUpdateAvatarErrors(AN_ERROR)
val initialStateWithPicture = givenPictureSelected(fakeUri.instance, A_PICTURE_FILENAME)
viewModel = createViewModel(initialStateWithPicture)
val test = viewModel.test(this)
viewModel.handle(OnboardingAction.SaveSelectedProfilePicture)
test
.assertStates(expectedProfilePictureFailureStates(initialStateWithPicture, AN_ERROR))
.assertEvents(OnboardingViewEvents.Failure(AN_ERROR))
.finish()
}
@Test
fun `given no selected picture when saving selected profile picture then emits failure event`() = runBlockingTest {
val test = viewModel.test(this)
viewModel.handle(OnboardingAction.SaveSelectedProfilePicture)
test
.assertStates(initialState)
.assertEvent { it is OnboardingViewEvents.Failure && it.throwable is NullPointerException }
.finish()
}
@Test
fun `when handling profile picture skipped then completes personalization`() = runBlockingTest {
val test = viewModel.test(this)
viewModel.handle(OnboardingAction.UpdateProfilePictureSkipped)
test
.assertStates(initialState)
.assertEvents(OnboardingViewEvents.OnPersonalizationComplete)
.finish()
}
private fun createViewModel(state: OnboardingViewState = initialState): OnboardingViewModel {
return OnboardingViewModel(
initialState,
state,
fakeContext.instance,
FakeAuthenticationService(),
fakeActiveSessionHolder.instance,
@ -112,7 +192,26 @@ class OnboardingViewModelTest {
FakeHomeServerHistoryService(),
FakeVectorFeatures(),
FakeAnalyticsTracker(),
DefaultVectorOverrides(),
fakeUriFilenameResolver.instance,
DefaultVectorOverrides()
)
}
private fun givenPictureSelected(fileUri: Uri, filename: String): OnboardingViewState {
val initialStateWithPicture = OnboardingViewState(personalizationState = PersonalizationState(selectedPictureUri = fileUri))
fakeUriFilenameResolver.givenFilename(fileUri, name = filename)
return initialStateWithPicture
}
private fun expectedProfilePictureSuccessStates(state: OnboardingViewState) = listOf(
state,
state.copy(asyncProfilePicture = Loading()),
state.copy(asyncProfilePicture = Success(Unit))
)
private fun expectedProfilePictureFailureStates(state: OnboardingViewState, cause: Exception) = listOf(
state,
state.copy(asyncProfilePicture = Loading()),
state.copy(asyncProfilePicture = Fail(cause))
)
}

View file

@ -0,0 +1,85 @@
/*
* 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.onboarding
import android.provider.OpenableColumns
import im.vector.app.test.fakes.FakeContentResolver
import im.vector.app.test.fakes.FakeContext
import im.vector.app.test.fakes.FakeCursor
import im.vector.app.test.fakes.FakeUri
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
private const val A_LAST_SEGMENT = "a-file-name.foo"
private const val A_DISPLAY_NAME = "file-display-name.foo"
class UriFilenameResolverTest {
private val fakeUri = FakeUri()
private val fakeContentResolver = FakeContentResolver()
private val uriFilenameResolver = UriFilenameResolver(FakeContext(fakeContentResolver.instance).instance)
@Test
fun `given a non hierarchical Uri when querying file name then is null`() {
fakeUri.givenNonHierarchical()
val result = uriFilenameResolver.getFilenameFromUri(fakeUri.instance)
result shouldBeEqualTo null
}
@Test
fun `given a non content schema Uri when querying file name then returns last segment`() {
fakeUri.givenContent(schema = "file", path = "path/to/$A_LAST_SEGMENT")
val result = uriFilenameResolver.getFilenameFromUri(fakeUri.instance)
result shouldBeEqualTo A_LAST_SEGMENT
}
@Test
fun `given content schema Uri with no backing content when querying file name then returns last segment`() {
fakeUri.givenContent(schema = "content", path = "path/to/$A_LAST_SEGMENT")
fakeContentResolver.givenUriResult(fakeUri.instance, null)
val result = uriFilenameResolver.getFilenameFromUri(fakeUri.instance)
result shouldBeEqualTo A_LAST_SEGMENT
}
@Test
fun `given content schema Uri with empty backing content when querying file name then returns last segment`() {
fakeUri.givenContent(schema = "content", path = "path/to/$A_LAST_SEGMENT")
val emptyCursor = FakeCursor().also { it.givenEmpty() }
fakeContentResolver.givenUriResult(fakeUri.instance, emptyCursor.instance)
val result = uriFilenameResolver.getFilenameFromUri(fakeUri.instance)
result shouldBeEqualTo A_LAST_SEGMENT
}
@Test
fun `given content schema Uri with backing content when querying file name then returns display name column`() {
fakeUri.givenContent(schema = "content", path = "path/to/$A_DISPLAY_NAME")
val aCursor = FakeCursor().also { it.givenString(OpenableColumns.DISPLAY_NAME, A_DISPLAY_NAME) }
fakeContentResolver.givenUriResult(fakeUri.instance, aCursor.instance)
val result = uriFilenameResolver.getFilenameFromUri(fakeUri.instance)
result shouldBeEqualTo A_DISPLAY_NAME
}
}

View file

@ -25,8 +25,6 @@ import kotlinx.coroutines.CoroutineScope
fun String.trimIndentOneLine() = trimIndent().replace("\n", "")
fun <S : MavericksState, VA : VectorViewModelAction, VE : VectorViewEvents> VectorViewModel<S, VA, VE>.test(coroutineScope: CoroutineScope): ViewModelTest<S, VE> {
// val state = { com.airbnb.mvrx.withState(this) { it } }
val state = stateFlow.test(coroutineScope)
val viewEvents = viewEvents.stream().test(coroutineScope)
return ViewModelTest(state, viewEvents)
@ -37,16 +35,31 @@ class ViewModelTest<S, VE>(
val viewEvents: FlowTestObserver<VE>
) {
fun assertNoEvents(): ViewModelTest<S, VE> {
viewEvents.assertNoValues()
return this
}
fun assertEvents(vararg expected: VE): ViewModelTest<S, VE> {
viewEvents.assertValues(*expected)
return this
}
fun assertEvent(position: Int = 0, predicate: (VE) -> Boolean): ViewModelTest<S, VE> {
viewEvents.assertValue(position, predicate)
return this
}
fun assertStates(vararg expected: S): ViewModelTest<S, VE> {
states.assertValues(*expected)
return this
}
fun assertStates(expected: List<S>): ViewModelTest<S, VE> {
states.assertValues(expected)
return this
}
fun assertState(expected: S): ViewModelTest<S, VE> {
states.assertValues(expected)
return this

View file

@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
fun <T> Flow<T>.test(scope: CoroutineScope): FlowTestObserver<T> {
return FlowTestObserver(scope, this)
@ -37,13 +38,17 @@ class FlowTestObserver<T>(
values.add(it)
}.launchIn(scope)
fun assertNoValues(): FlowTestObserver<T> {
assertEquals(emptyList<T>(), this.values)
fun assertNoValues() = assertValues(emptyList())
fun assertValues(vararg values: T) = assertValues(values.toList())
fun assertValue(position: Int, predicate: (T) -> Boolean): FlowTestObserver<T> {
assertTrue(predicate(values[position]))
return this
}
fun assertValues(vararg values: T): FlowTestObserver<T> {
assertEquals(values.toList(), this.values)
fun assertValues(values: List<T>): FlowTestObserver<T> {
assertEquals(values, this.values)
return this
}

View file

@ -0,0 +1,32 @@
/*
* 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 android.content.ContentResolver
import android.database.Cursor
import android.net.Uri
import io.mockk.every
import io.mockk.mockk
class FakeContentResolver {
val instance = mockk<ContentResolver>()
fun givenUriResult(uri: Uri, cursor: Cursor?) {
every { instance.query(uri, null, null, null, null) } returns cursor
}
}

View file

@ -24,9 +24,10 @@ import io.mockk.every
import io.mockk.mockk
import java.io.OutputStream
class FakeContext {
class FakeContext(
private val contentResolver: ContentResolver = mockk()
) {
private val contentResolver = mockk<ContentResolver>()
val instance = mockk<Context>()
init {

View file

@ -0,0 +1,43 @@
/*
* 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 android.database.Cursor
import io.mockk.every
import io.mockk.mockk
class FakeCursor {
val instance = mockk<Cursor>()
init {
every { instance.close() } answers {}
}
fun givenEmpty() {
every { instance.count } returns 0
every { instance.moveToFirst() } returns false
}
fun givenString(columnName: String, content: String?) {
val columnId = columnName.hashCode()
every { instance.moveToFirst() } returns true
every { instance.isNull(columnId) } returns (content == null)
every { instance.getColumnIndex(columnName) } returns columnId
every { instance.getString(columnId) } returns content
}
}

View file

@ -16,18 +16,27 @@
package im.vector.app.test.fakes
import android.net.Uri
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import org.matrix.android.sdk.api.session.profile.ProfileService
class FakeProfileService : ProfileService by mockk() {
private var setDisplayNameError: Throwable? = null
override suspend fun setDisplayName(userId: String, newDisplayName: String) {
setDisplayNameError?.let { throw it }
}
class FakeProfileService : ProfileService by mockk(relaxed = true) {
fun givenSetDisplayNameErrors(errorCause: RuntimeException) {
setDisplayNameError = errorCause
coEvery { setDisplayName(any(), any()) } throws errorCause
}
fun givenUpdateAvatarErrors(errorCause: RuntimeException) {
coEvery { updateAvatar(any(), any(), any()) } throws errorCause
}
fun verifyUpdatedName(userId: String, newName: String) {
coVerify { setDisplayName(userId, newName) }
}
fun verifyAvatarUpdated(userId: String, newAvatarUri: Uri, fileName: String) {
coVerify { updateAvatar(userId, newAvatarUri, fileName) }
}
}

View file

@ -16,6 +16,7 @@
package im.vector.app.test.fakes
import android.net.Uri
import im.vector.app.core.extensions.vectorStore
import im.vector.app.features.session.VectorSessionStore
import im.vector.app.test.testCoroutineDispatchers
@ -34,10 +35,13 @@ class FakeSession(
mockkStatic("im.vector.app.core.extensions.SessionKt")
}
override val myUserId: String = "@fake:server.fake"
override fun cryptoService() = fakeCryptoService
override val sharedSecretStorageService = fakeSharedSecretStorageService
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)
fun givenVectorStore(vectorSessionStore: VectorSessionStore) {
coEvery {

View file

@ -0,0 +1,34 @@
/*
* 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 android.net.Uri
import io.mockk.every
import io.mockk.mockk
class FakeUri {
val instance = mockk<Uri>()
fun givenNonHierarchical() {
givenContent(schema = "mail", path = null)
}
fun givenContent(schema: String, path: String?) {
every { instance.scheme } returns schema
every { instance.path } returns path
}
}

View file

@ -0,0 +1,31 @@
/*
* 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 android.net.Uri
import im.vector.app.features.onboarding.UriFilenameResolver
import io.mockk.every
import io.mockk.mockk
class FakeUriFilenameResolver {
val instance = mockk<UriFilenameResolver>()
fun givenFilename(uri: Uri, name: String?) {
every { instance.getFilenameFromUri(uri) } returns name
}
}