mirror of
https://github.com/element-hq/element-android
synced 2024-11-27 20:06:51 +03:00
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:
parent
9af2f1cdc6
commit
9a02543afd
26 changed files with 807 additions and 43 deletions
|
@ -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) }
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -54,4 +54,6 @@ sealed class OnboardingViewEvents : VectorViewEvents {
|
|||
object OnPersonalizeProfile : OnboardingViewEvents()
|
||||
object OnDisplayNameUpdated : OnboardingViewEvents()
|
||||
object OnDisplayNameSkipped : OnboardingViewEvents()
|
||||
object OnPersonalizationComplete : OnboardingViewEvents()
|
||||
object OnBack : OnboardingViewEvents()
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
164
vector/src/main/res/layout/fragment_ftue_profile_picture.xml
Normal file
164
vector/src/main/res/layout/fragment_ftue_profile_picture.xml
Normal 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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
43
vector/src/test/java/im/vector/app/test/fakes/FakeCursor.kt
Normal file
43
vector/src/test/java/im/vector/app/test/fakes/FakeCursor.kt
Normal 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
|
||||
}
|
||||
}
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
34
vector/src/test/java/im/vector/app/test/fakes/FakeUri.kt
Normal file
34
vector/src/test/java/im/vector/app/test/fakes/FakeUri.kt
Normal 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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue