Merge pull request #7189 from vector-im/feature/mna/device-manager-rename-session

[Device management] Rename a session (PSG-747)
This commit is contained in:
Maxime NATUREL 2022-09-26 10:35:15 +02:00 committed by GitHub
commit 223149805b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1026 additions and 10 deletions

1
changelog.d/7158.wip Normal file
View File

@ -0,0 +1 @@
[Device management] Rename a session

View File

@ -3295,6 +3295,10 @@
<string name="device_manager_session_details_session_id">Session ID</string> <string name="device_manager_session_details_session_id">Session ID</string>
<string name="device_manager_session_details_session_last_activity">Last activity</string> <string name="device_manager_session_details_session_last_activity">Last activity</string>
<string name="device_manager_session_details_device_ip_address">IP address</string> <string name="device_manager_session_details_device_ip_address">IP address</string>
<string name="device_manager_session_rename">Rename session</string>
<string name="device_manager_session_rename_edit_hint">Session name</string>
<string name="device_manager_session_rename_description">Custom session names can help you recognize your devices more easily.</string>
<string name="device_manager_session_rename_warning">Please be aware that session names are also visible to people you communicate with.</string>
<!-- Note to translators: %s will be replaces with selected space name --> <!-- Note to translators: %s will be replaces with selected space name -->
<string name="home_empty_space_no_rooms_title">%s\nis looking a little empty.</string> <string name="home_empty_space_no_rooms_title">%s\nis looking a little empty.</string>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="SessionWarningInfoView">
<attr name="sessionsWarningInfoDescription" format="string" />
<attr name="sessionsWarningInfoHasLearnMore" format="boolean" />
</declare-styleable>
</resources>

View File

@ -325,6 +325,7 @@
<activity android:name=".features.settings.devices.v2.overview.SessionOverviewActivity" /> <activity android:name=".features.settings.devices.v2.overview.SessionOverviewActivity" />
<activity android:name=".features.settings.devices.v2.othersessions.OtherSessionsActivity" /> <activity android:name=".features.settings.devices.v2.othersessions.OtherSessionsActivity" />
<activity android:name=".features.settings.devices.v2.details.SessionDetailsActivity" /> <activity android:name=".features.settings.devices.v2.details.SessionDetailsActivity" />
<activity android:name=".features.settings.devices.v2.rename.RenameSessionActivity" />
<!-- Services --> <!-- Services -->

View File

@ -91,6 +91,7 @@ import im.vector.app.features.settings.devices.DevicesViewModel
import im.vector.app.features.settings.devices.v2.details.SessionDetailsViewModel import im.vector.app.features.settings.devices.v2.details.SessionDetailsViewModel
import im.vector.app.features.settings.devices.v2.othersessions.OtherSessionsViewModel import im.vector.app.features.settings.devices.v2.othersessions.OtherSessionsViewModel
import im.vector.app.features.settings.devices.v2.overview.SessionOverviewViewModel import im.vector.app.features.settings.devices.v2.overview.SessionOverviewViewModel
import im.vector.app.features.settings.devices.v2.rename.RenameSessionViewModel
import im.vector.app.features.settings.devtools.AccountDataViewModel import im.vector.app.features.settings.devtools.AccountDataViewModel
import im.vector.app.features.settings.devtools.GossipingEventsPaperTrailViewModel import im.vector.app.features.settings.devtools.GossipingEventsPaperTrailViewModel
import im.vector.app.features.settings.devtools.KeyRequestListViewModel import im.vector.app.features.settings.devtools.KeyRequestListViewModel
@ -653,4 +654,9 @@ interface MavericksViewModelModule {
@IntoMap @IntoMap
@MavericksViewModelKey(SessionDetailsViewModel::class) @MavericksViewModelKey(SessionDetailsViewModel::class)
fun sessionDetailsViewModelFactory(factory: SessionDetailsViewModel.Factory): MavericksAssistedViewModelFactory<*, *> fun sessionDetailsViewModelFactory(factory: SessionDetailsViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
@Binds
@IntoMap
@MavericksViewModelKey(RenameSessionViewModel::class)
fun renameSessionViewModelFactory(factory: RenameSessionViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
} }

View File

@ -0,0 +1,75 @@
/*
* 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.settings.devices.v2
import android.content.Context
import android.content.res.TypedArray
import android.util.AttributeSet
import android.view.LayoutInflater
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.res.use
import im.vector.app.R
import im.vector.app.core.extensions.setTextWithColoredPart
import im.vector.app.databinding.ViewSessionWarningInfoBinding
class SessionWarningInfoView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
private val binding = ViewSessionWarningInfoBinding.inflate(
LayoutInflater.from(context),
this
)
var onLearnMoreClickListener: (() -> Unit)? = null
init {
context.obtainStyledAttributes(
attrs,
R.styleable.SessionWarningInfoView,
0,
0
).use {
setDescription(it)
}
}
private fun setDescription(typedArray: TypedArray) {
val description = typedArray.getString(R.styleable.SessionWarningInfoView_sessionsWarningInfoDescription)
val hasLearnMore = typedArray.getBoolean(R.styleable.SessionWarningInfoView_sessionsWarningInfoHasLearnMore, false)
if (hasLearnMore) {
val learnMore = context.getString(R.string.action_learn_more)
val fullDescription = buildString {
append(description)
append(" ")
append(learnMore)
}
binding.sessionWarningInfoDescription.setTextWithColoredPart(
fullText = fullDescription,
coloredPart = learnMore,
underline = false
) {
onLearnMoreClickListener?.invoke()
}
} else {
binding.sessionWarningInfoDescription.text = description
}
}
}

View File

@ -18,11 +18,11 @@ package im.vector.app.features.settings.devices.v2.overview
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.airbnb.mvrx.Success import com.airbnb.mvrx.Success
import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.fragmentViewModel
@ -31,6 +31,7 @@ import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.date.VectorDateFormatter import im.vector.app.core.date.VectorDateFormatter
import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.platform.VectorMenuProvider
import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.resources.DrawableProvider import im.vector.app.core.resources.DrawableProvider
import im.vector.app.databinding.FragmentSessionOverviewBinding import im.vector.app.databinding.FragmentSessionOverviewBinding
@ -43,7 +44,8 @@ import javax.inject.Inject
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class SessionOverviewFragment : class SessionOverviewFragment :
VectorBaseFragment<FragmentSessionOverviewBinding>() { VectorBaseFragment<FragmentSessionOverviewBinding>(),
VectorMenuProvider {
@Inject lateinit var viewNavigator: SessionOverviewViewNavigator @Inject lateinit var viewNavigator: SessionOverviewViewNavigator
@ -103,6 +105,22 @@ class SessionOverviewFragment :
views.sessionOverviewInfo.onLearnMoreClickListener = null views.sessionOverviewInfo.onLearnMoreClickListener = null
} }
override fun getMenuRes() = R.menu.menu_session_overview
override fun handleMenuItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.sessionOverviewRename -> {
goToRenameSession()
true
}
else -> false
}
}
private fun goToRenameSession() = withState(viewModel) { state ->
viewNavigator.goToRenameSession(requireContext(), state.deviceId)
}
override fun invalidate() = withState(viewModel) { state -> override fun invalidate() = withState(viewModel) { state ->
updateToolbar(state.isCurrentSession) updateToolbar(state.isCurrentSession)
updateEntryDetails(state.deviceId) updateEntryDetails(state.deviceId)
@ -118,7 +136,7 @@ class SessionOverviewFragment :
private fun updateEntryDetails(deviceId: String) { private fun updateEntryDetails(deviceId: String) {
views.sessionOverviewEntryDetails.setOnClickListener { views.sessionOverviewEntryDetails.setOnClickListener {
viewNavigator.navigateToSessionDetails(requireContext(), deviceId) viewNavigator.goToSessionDetails(requireContext(), deviceId)
} }
} }
@ -136,11 +154,7 @@ class SessionOverviewFragment :
) )
views.sessionOverviewInfo.render(infoViewState, dateFormatter, drawableProvider, colorProvider) views.sessionOverviewInfo.render(infoViewState, dateFormatter, drawableProvider, colorProvider)
} else { } else {
hideSessionInfo() views.sessionOverviewInfo.isVisible = false
} }
} }
private fun hideSessionInfo() {
views.sessionOverviewInfo.isGone = true
}
} }

View File

@ -18,11 +18,16 @@ package im.vector.app.features.settings.devices.v2.overview
import android.content.Context import android.content.Context
import im.vector.app.features.settings.devices.v2.details.SessionDetailsActivity import im.vector.app.features.settings.devices.v2.details.SessionDetailsActivity
import im.vector.app.features.settings.devices.v2.rename.RenameSessionActivity
import javax.inject.Inject import javax.inject.Inject
class SessionOverviewViewNavigator @Inject constructor() { class SessionOverviewViewNavigator @Inject constructor() {
fun navigateToSessionDetails(context: Context, deviceId: String) { fun goToSessionDetails(context: Context, deviceId: String) {
context.startActivity(SessionDetailsActivity.newIntent(context, deviceId)) context.startActivity(SessionDetailsActivity.newIntent(context, deviceId))
} }
fun goToRenameSession(context: Context, deviceId: String) {
context.startActivity(RenameSessionActivity.newIntent(context, deviceId))
}
} }

View File

@ -0,0 +1,25 @@
/*
* Copyright (c) 2020 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.settings.devices.v2.rename
import im.vector.app.core.platform.VectorViewModelAction
sealed class RenameSessionAction : VectorViewModelAction {
object InitWithLastEditedName : RenameSessionAction()
object SaveModifications : RenameSessionAction()
data class EditLocally(val editedName: String) : RenameSessionAction()
}

View File

@ -0,0 +1,57 @@
/*
* Copyright 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.settings.devices.v2.rename
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.WindowManager
import com.airbnb.mvrx.Mavericks
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.core.extensions.addFragment
import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.databinding.ActivitySimpleBinding
/**
* Display the screen to rename a Session.
*/
@AndroidEntryPoint
class RenameSessionActivity : VectorBaseActivity<ActivitySimpleBinding>() {
override fun getBinding() = ActivitySimpleBinding.inflate(layoutInflater)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (isFirstCreation()) {
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE)
addFragment(
container = views.simpleFragmentContainer,
fragmentClass = RenameSessionFragment::class.java,
params = intent.getParcelableExtra(Mavericks.KEY_ARG)
)
}
}
companion object {
fun newIntent(context: Context, deviceId: String): Intent {
return Intent(context, RenameSessionActivity::class.java).apply {
putExtra(Mavericks.KEY_ARG, RenameSessionArgs(deviceId))
}
}
}
}

View File

@ -0,0 +1,25 @@
/*
* 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.settings.devices.v2.rename
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class RenameSessionArgs(
val deviceId: String
) : Parcelable

View File

@ -0,0 +1,98 @@
/*
* 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.settings.devices.v2.rename
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.widget.doOnTextChanged
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.core.extensions.showKeyboard
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.databinding.FragmentSessionRenameBinding
import javax.inject.Inject
/**
* Display the screen to rename a Session.
*/
@AndroidEntryPoint
class RenameSessionFragment :
VectorBaseFragment<FragmentSessionRenameBinding>() {
private val viewModel: RenameSessionViewModel by fragmentViewModel()
@Inject lateinit var viewNavigator: RenameSessionViewNavigator
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentSessionRenameBinding {
return FragmentSessionRenameBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
observeViewEvents()
initToolbar()
initEditText()
initSaveButton()
initWithLastEditedName()
}
private fun initToolbar() {
setupToolbar(views.renameSessionToolbar)
.allowBack(useCross = true)
}
private fun initEditText() {
views.renameSessionEditText.showKeyboard(andRequestFocus = true)
views.renameSessionEditText.doOnTextChanged { text, _, _, _ ->
viewModel.handle(RenameSessionAction.EditLocally(text.toString()))
}
}
private fun initSaveButton() {
views.renameSessionSave.debouncedClicks {
viewModel.handle(RenameSessionAction.SaveModifications)
}
}
private fun initWithLastEditedName() {
viewModel.handle(RenameSessionAction.InitWithLastEditedName)
}
private fun observeViewEvents() {
viewModel.observeViewEvents {
when (it) {
is RenameSessionViewEvent.Initialized -> {
views.renameSessionEditText.setText(it.deviceName)
views.renameSessionEditText.setSelection(views.renameSessionEditText.length())
}
is RenameSessionViewEvent.SessionRenamed -> {
viewNavigator.goBack(requireActivity())
}
is RenameSessionViewEvent.Failure -> {
showFailure(it.throwable)
}
}
}
}
override fun invalidate() = withState(viewModel) { state ->
views.renameSessionSave.isEnabled = state.editedDeviceName.isNotEmpty()
}
}

View File

@ -0,0 +1,44 @@
/*
* 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.settings.devices.v2.rename
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.extensions.andThen
import im.vector.app.features.settings.devices.v2.RefreshDevicesUseCase
import org.matrix.android.sdk.api.util.awaitCallback
import javax.inject.Inject
class RenameSessionUseCase @Inject constructor(
private val activeSessionHolder: ActiveSessionHolder,
private val refreshDevicesUseCase: RefreshDevicesUseCase,
) {
suspend fun execute(deviceId: String, newName: String): Result<Unit> {
return renameDevice(deviceId, newName)
.andThen { refreshDevices() }
}
private suspend fun renameDevice(deviceId: String, newName: String) = runCatching {
awaitCallback<Unit> { matrixCallback ->
activeSessionHolder.getActiveSession()
.cryptoService()
.setDeviceName(deviceId, newName, matrixCallback)
}
}
private fun refreshDevices() = runCatching { refreshDevicesUseCase.execute() }
}

View File

@ -0,0 +1,25 @@
/*
* 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.settings.devices.v2.rename
import im.vector.app.core.platform.VectorViewEvents
sealed class RenameSessionViewEvent : VectorViewEvents {
data class Initialized(val deviceName: String) : RenameSessionViewEvent()
object SessionRenamed : RenameSessionViewEvent()
data class Failure(val throwable: Throwable) : RenameSessionViewEvent()
}

View File

@ -0,0 +1,97 @@
/*
* 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.settings.devices.v2.rename
import androidx.annotation.VisibleForTesting
import com.airbnb.mvrx.MavericksViewModelFactory
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.settings.devices.v2.overview.GetDeviceFullInfoUseCase
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
class RenameSessionViewModel @AssistedInject constructor(
@Assisted val initialState: RenameSessionViewState,
private val getDeviceFullInfoUseCase: GetDeviceFullInfoUseCase,
private val renameSessionUseCase: RenameSessionUseCase,
) : VectorViewModel<RenameSessionViewState, RenameSessionAction, RenameSessionViewEvent>(initialState) {
companion object : MavericksViewModelFactory<RenameSessionViewModel, RenameSessionViewState> by hiltMavericksViewModelFactory()
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<RenameSessionViewModel, RenameSessionViewState> {
override fun create(initialState: RenameSessionViewState): RenameSessionViewModel
}
@VisibleForTesting
var hasRetrievedOriginalDeviceName = false
override fun handle(action: RenameSessionAction) {
when (action) {
is RenameSessionAction.InitWithLastEditedName -> handleInitWithLastEditedName()
is RenameSessionAction.EditLocally -> handleEditLocally(action.editedName)
is RenameSessionAction.SaveModifications -> handleSaveModifications()
}
}
private fun handleInitWithLastEditedName() = withState { state ->
if (hasRetrievedOriginalDeviceName) {
postInitEvent()
} else {
hasRetrievedOriginalDeviceName = true
viewModelScope.launch {
setStateWithOriginalDeviceName(state.deviceId)
postInitEvent()
}
}
}
private suspend fun setStateWithOriginalDeviceName(deviceId: String) {
getDeviceFullInfoUseCase.execute(deviceId)
.firstOrNull()
?.let { deviceFullInfo ->
setState { copy(editedDeviceName = deviceFullInfo.deviceInfo.displayName.orEmpty()) }
}
}
private fun postInitEvent() = withState { state ->
_viewEvents.post(RenameSessionViewEvent.Initialized(state.editedDeviceName))
}
private fun handleEditLocally(editedName: String) {
setState { copy(editedDeviceName = editedName) }
}
private fun handleSaveModifications() = withState { viewState ->
viewModelScope.launch {
val result = renameSessionUseCase.execute(
deviceId = viewState.deviceId,
newName = viewState.editedDeviceName,
)
val viewEvent = if (result.isSuccess) {
RenameSessionViewEvent.SessionRenamed
} else {
RenameSessionViewEvent.Failure(result.exceptionOrNull() ?: Exception())
}
_viewEvents.post(viewEvent)
}
}
}

View File

@ -0,0 +1,27 @@
/*
* 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.settings.devices.v2.rename
import androidx.fragment.app.FragmentActivity
import javax.inject.Inject
class RenameSessionViewNavigator @Inject constructor() {
fun goBack(activity: FragmentActivity) {
activity.finish()
}
}

View File

@ -0,0 +1,28 @@
/*
* 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.settings.devices.v2.rename
import com.airbnb.mvrx.MavericksState
data class RenameSessionViewState(
val deviceId: String,
val editedDeviceName: String = "",
) : MavericksState {
constructor(args: RenameSessionArgs) : this(
deviceId = args.deviceId
)
}

View File

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBarLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/renameSessionToolbar"
android:layout_width="match_parent"
android:layout_height="?actionBarSize"
app:title="@string/device_manager_session_rename">
<Button
android:id="@+id/renameSessionSave"
style="@style/Widget.Vector.Button.Text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:text="@string/action_save" />
</com.google.android.material.appbar.MaterialToolbar>
</com.google.android.material.appbar.AppBarLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/renameSessionInputLayout"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/layout_horizontal_margin"
android:layout_marginTop="28dp"
android:hint="@string/device_manager_session_rename_edit_hint"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/appBarLayout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/renameSessionEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/renameSessionDescription"
style="@style/TextAppearance.Vector.Body.DevicesManagement"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/layout_horizontal_margin"
android:layout_marginTop="8dp"
android:text="@string/device_manager_session_rename_description"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/renameSessionInputLayout" />
<im.vector.app.features.settings.devices.v2.SessionWarningInfoView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/layout_horizontal_margin"
android:layout_marginVertical="@dimen/layout_vertical_margin"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/renameSessionDescription"
app:sessionsWarningInfoDescription="@string/device_manager_session_rename_warning"
app:sessionsWarningInfoHasLearnMore="false" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
<View
android:id="@+id/sessionWarningInfoBackground"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@drawable/rounded_rect_shape_8"
android:backgroundTint="?colorSurface"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/sessionWarningInfoIcon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="14dp"
android:layout_marginVertical="14dp"
android:layout_marginEnd="10dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toStartOf="@id/sessionWarningInfoDescription"
android:importantForAccessibility="no"
android:src="@drawable/ic_info"
app:tint="?vctr_content_secondary" />
<TextView
android:id="@+id/sessionWarningInfoDescription"
style="@style/TextAppearance.Vector.Body.DevicesManagement"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginVertical="12dp"
android:layout_marginEnd="12dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/sessionWarningInfoIcon"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="@id/sessionWarningInfoBackground"
tools:text="Please be aware that session names are also visible to people you communicate with. Learn more" />
</merge>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="AlwaysShowAction">
<item
android:id="@+id/sessionOverviewRename"
android:title="@string/device_manager_session_rename"
app:showAsAction="withText|never" />
</menu>

View File

@ -18,6 +18,7 @@ package im.vector.app.features.settings.devices.v2.overview
import android.content.Intent import android.content.Intent
import im.vector.app.features.settings.devices.v2.details.SessionDetailsActivity import im.vector.app.features.settings.devices.v2.details.SessionDetailsActivity
import im.vector.app.features.settings.devices.v2.rename.RenameSessionActivity
import im.vector.app.test.fakes.FakeContext import im.vector.app.test.fakes.FakeContext
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
@ -38,6 +39,7 @@ class SessionOverviewViewNavigatorTest {
@Before @Before
fun setUp() { fun setUp() {
mockkObject(SessionDetailsActivity) mockkObject(SessionDetailsActivity)
mockkObject(RenameSessionActivity)
} }
@After @After
@ -52,7 +54,22 @@ class SessionOverviewViewNavigatorTest {
context.givenStartActivity(intent) context.givenStartActivity(intent)
// When // When
sessionOverviewViewNavigator.navigateToSessionDetails(context.instance, A_SESSION_ID) sessionOverviewViewNavigator.goToSessionDetails(context.instance, A_SESSION_ID)
// Then
verify {
context.instance.startActivity(intent)
}
}
@Test
fun `given a session id when navigating to rename screen then it starts the correct activity`() {
// Given
val intent = givenIntentForRenameSession(A_SESSION_ID)
context.givenStartActivity(intent)
// When
sessionOverviewViewNavigator.goToRenameSession(context.instance, A_SESSION_ID)
// Then // Then
verify { verify {
@ -65,4 +82,10 @@ class SessionOverviewViewNavigatorTest {
every { SessionDetailsActivity.newIntent(context.instance, sessionId) } returns intent every { SessionDetailsActivity.newIntent(context.instance, sessionId) } returns intent
return intent return intent
} }
private fun givenIntentForRenameSession(sessionId: String): Intent {
val intent = mockk<Intent>()
every { RenameSessionActivity.newIntent(context.instance, sessionId) } returns intent
return intent
}
} }

View File

@ -0,0 +1,91 @@
/*
* 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.settings.devices.v2.rename
import im.vector.app.features.settings.devices.v2.RefreshDevicesUseCase
import im.vector.app.test.fakes.FakeActiveSessionHolder
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBe
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
private const val A_DEVICE_ID = "device-id"
private const val A_DEVICE_NAME = "device-name"
class RenameSessionUseCaseTest {
private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private val refreshDevicesUseCase = mockk<RefreshDevicesUseCase>()
private val renameSessionUseCase = RenameSessionUseCase(
activeSessionHolder = fakeActiveSessionHolder.instance,
refreshDevicesUseCase = refreshDevicesUseCase
)
@Test
fun `given a device id and a new name when no error during rename then the device is renamed with success`() = runTest {
// Given
fakeActiveSessionHolder.fakeSession.fakeCryptoService.givenSetDeviceNameSucceeds()
every { refreshDevicesUseCase.execute() } just runs
// When
val result = renameSessionUseCase.execute(A_DEVICE_ID, A_DEVICE_NAME)
// Then
result.isSuccess shouldBe true
verify {
fakeActiveSessionHolder.fakeSession
.cryptoService()
.setDeviceName(A_DEVICE_ID, A_DEVICE_NAME, any())
refreshDevicesUseCase.execute()
}
}
@Test
fun `given a device id and a new name when an error occurs during rename then result is failure`() = runTest {
// Given
val error = Exception()
fakeActiveSessionHolder.fakeSession.fakeCryptoService.givenSetDeviceNameFailsWithError(error)
// When
val result = renameSessionUseCase.execute(A_DEVICE_ID, A_DEVICE_NAME)
// Then
result.isFailure shouldBe true
result.exceptionOrNull() shouldBeEqualTo error
}
@Test
fun `given a device id and a new name when an error occurs during devices refresh then result is failure`() = runTest {
// Given
val error = Exception()
fakeActiveSessionHolder.fakeSession.fakeCryptoService.givenSetDeviceNameSucceeds()
every { refreshDevicesUseCase.execute() } throws error
// When
val result = renameSessionUseCase.execute(A_DEVICE_ID, A_DEVICE_NAME)
// Then
result.isFailure shouldBe true
result.exceptionOrNull() shouldBeEqualTo error
}
}

View File

@ -0,0 +1,169 @@
/*
* 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.settings.devices.v2.rename
import com.airbnb.mvrx.test.MvRxTestRule
import im.vector.app.features.settings.devices.v2.DeviceFullInfo
import im.vector.app.features.settings.devices.v2.overview.GetDeviceFullInfoUseCase
import im.vector.app.test.test
import im.vector.app.test.testDispatcher
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.flowOf
import org.junit.Rule
import org.junit.Test
private const val A_SESSION_ID = "session-id"
private const val A_SESSION_NAME = "session-name"
private const val AN_EDITED_SESSION_NAME = "edited-session-name"
class RenameSessionViewModelTest {
@get:Rule
val mvRxTestRule = MvRxTestRule(testDispatcher = testDispatcher)
private val args = RenameSessionArgs(
deviceId = A_SESSION_ID
)
private val getDeviceFullInfoUseCase = mockk<GetDeviceFullInfoUseCase>()
private val renameSessionUseCase = mockk<RenameSessionUseCase>()
private fun createViewModel() = RenameSessionViewModel(
initialState = RenameSessionViewState(args),
getDeviceFullInfoUseCase = getDeviceFullInfoUseCase,
renameSessionUseCase = renameSessionUseCase,
)
@Test
fun `given the original device name has not been retrieved when handling init with last edited name action then view state and view events are updated`() {
// Given
givenSessionWithName(A_SESSION_NAME)
val action = RenameSessionAction.InitWithLastEditedName
val expectedState = RenameSessionViewState(
deviceId = A_SESSION_ID,
editedDeviceName = A_SESSION_NAME,
)
val expectedEvent = RenameSessionViewEvent.Initialized(
deviceName = A_SESSION_NAME,
)
val viewModel = createViewModel()
viewModel.hasRetrievedOriginalDeviceName = false
// When
val viewModelTest = viewModel.test()
viewModel.handle(action)
// Then
viewModelTest.assertLatestState { state -> state == expectedState }
.assertEvent { event -> event == expectedEvent }
.finish()
verify {
getDeviceFullInfoUseCase.execute(A_SESSION_ID)
}
}
@Test
fun `given the original device name has been retrieved when handling init with last edited name action then view state and view events are updated`() {
// Given
val action = RenameSessionAction.InitWithLastEditedName
val expectedState = RenameSessionViewState(
deviceId = A_SESSION_ID,
editedDeviceName = AN_EDITED_SESSION_NAME,
)
val expectedEvent = RenameSessionViewEvent.Initialized(
deviceName = AN_EDITED_SESSION_NAME,
)
val viewModel = createViewModel()
viewModel.handle(RenameSessionAction.EditLocally(AN_EDITED_SESSION_NAME))
viewModel.hasRetrievedOriginalDeviceName = true
// When
val viewModelTest = viewModel.test()
viewModel.handle(action)
// Then
viewModelTest.assertLatestState { state -> state == expectedState }
.assertEvent { event -> event == expectedEvent }
.finish()
verify(inverse = true) {
getDeviceFullInfoUseCase.execute(A_SESSION_ID)
}
}
@Test
fun `given a new edited name when handling edit name locally action then view state is updated accordingly`() {
// Given
val action = RenameSessionAction.EditLocally(AN_EDITED_SESSION_NAME)
val expectedState = RenameSessionViewState(
deviceId = A_SESSION_ID,
editedDeviceName = AN_EDITED_SESSION_NAME,
)
val viewModel = createViewModel()
// When
val viewModelTest = viewModel.test()
viewModel.handle(action)
// Then
viewModelTest
.assertLatestState { state -> state == expectedState }
.finish()
}
@Test
fun `given current edited name when handling save modifications action with success then correct view event is posted`() {
// Given
coEvery { renameSessionUseCase.execute(A_SESSION_ID, any()) } returns Result.success(Unit)
val action = RenameSessionAction.SaveModifications
val viewModel = createViewModel()
// When
val viewModelTest = viewModel.test()
viewModel.handle(action)
// Then
viewModelTest
.assertEvent { event -> event is RenameSessionViewEvent.SessionRenamed }
.finish()
}
@Test
fun `given current edited name when handling save modifications action with error then correct view event is posted`() {
// Given
val error = Exception()
coEvery { renameSessionUseCase.execute(A_SESSION_ID, any()) } returns Result.failure(error)
val action = RenameSessionAction.SaveModifications
val viewModel = createViewModel()
// When
val viewModelTest = viewModel.test()
viewModel.handle(action)
// Then
viewModelTest
.assertEvent { event -> event is RenameSessionViewEvent.Failure && event.throwable == error }
.finish()
}
private fun givenSessionWithName(sessionName: String) {
val deviceFullInfo = mockk<DeviceFullInfo>()
every { deviceFullInfo.deviceInfo.displayName } returns sessionName
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID) } returns flowOf(deviceFullInfo)
}
}

View File

@ -0,0 +1,45 @@
/*
* 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.settings.devices.v2.rename
import androidx.fragment.app.FragmentActivity
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import org.junit.Test
class RenameSessionViewNavigatorTest {
private val renameSessionViewNavigator = RenameSessionViewNavigator()
@Test
fun `given an activity when going back then the activity is finished`() {
// Given
val fragmentActivity = mockk<FragmentActivity>()
every { fragmentActivity.finish() } just runs
// When
renameSessionViewNavigator.goBack(fragmentActivity)
// Then
verify {
fragmentActivity.finish()
}
}
}

View File

@ -17,7 +17,10 @@
package im.vector.app.test.fakes package im.vector.app.test.fakes
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.slot
import org.matrix.android.sdk.api.MatrixCallback
import org.matrix.android.sdk.api.session.crypto.CryptoService import org.matrix.android.sdk.api.session.crypto.CryptoService
import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo
import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo
@ -50,4 +53,18 @@ class FakeCryptoService(
override fun getLiveCryptoDeviceInfoWithId(deviceId: String) = cryptoDeviceInfoWithIdLiveData override fun getLiveCryptoDeviceInfoWithId(deviceId: String) = cryptoDeviceInfoWithIdLiveData
override fun getMyDevicesInfoLive(deviceId: String) = myDevicesInfoWithIdLiveData override fun getMyDevicesInfoLive(deviceId: String) = myDevicesInfoWithIdLiveData
fun givenSetDeviceNameSucceeds() {
val matrixCallback = slot<MatrixCallback<Unit>>()
every { setDeviceName(any(), any(), capture(matrixCallback)) } answers {
thirdArg<MatrixCallback<Unit>>().onSuccess(Unit)
}
}
fun givenSetDeviceNameFailsWithError(error: Exception) {
val matrixCallback = slot<MatrixCallback<Unit>>()
every { setDeviceName(any(), any(), capture(matrixCallback)) } answers {
thirdArg<MatrixCallback<Unit>>().onFailure(error)
}
}
} }