diff --git a/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxSession.kt b/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxSession.kt index 383a2ccb63..046ff55a3f 100644 --- a/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxSession.kt +++ b/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxSession.kt @@ -18,6 +18,7 @@ package im.vector.matrix.rx import androidx.paging.PagedList import im.vector.matrix.android.api.session.Session +import im.vector.matrix.android.api.session.crypto.crosssigning.MXCrossSigningInfo import im.vector.matrix.android.api.session.group.GroupSummaryQueryParams import im.vector.matrix.android.api.session.group.model.GroupSummary import im.vector.matrix.android.api.session.pushers.Pusher @@ -100,9 +101,14 @@ class RxSession(private val session: Session) { session.getProfile(userId, it) } - fun liveUserCryptoDevices(userId: String) : Observable> { + fun liveUserCryptoDevices(userId: String): Observable> { return session.getLiveCryptoDeviceInfo(userId).asObservable() } + + fun liveCrossSigningInfo(userId: String): Observable> { + return session.getCrossSigningService().getLiveCrossSigningKeys(userId).asObservable() + .startWith(session.getCrossSigningService().getUserCrossSigningKeys(userId).toOptional()) + } } fun Session.rx(): RxSession { diff --git a/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt b/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt index 92e5a89ddb..f03f6cb784 100644 --- a/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt +++ b/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt @@ -52,6 +52,7 @@ import im.vector.riotx.features.reactions.EmojiReactionPickerActivity import im.vector.riotx.features.reactions.widget.ReactionButton import im.vector.riotx.features.roomdirectory.RoomDirectoryActivity import im.vector.riotx.features.roomdirectory.createroom.CreateRoomActivity +import im.vector.riotx.features.roommemberprofile.devices.DeviceListBottomSheet import im.vector.riotx.features.settings.VectorSettingsActivity import im.vector.riotx.features.settings.devices.DeviceVerificationInfoBottomSheet import im.vector.riotx.features.share.IncomingShareActivity @@ -148,6 +149,8 @@ interface ScreenComponent { fun inject(deviceVerificationInfoBottomSheet: DeviceVerificationInfoBottomSheet) + fun inject(deviceListBottomSheet: DeviceListBottomSheet) + @Component.Factory interface Factory { fun create(vectorComponent: VectorComponent, diff --git a/vector/src/main/java/im/vector/riotx/core/ui/list/GenericItemWithValue.kt b/vector/src/main/java/im/vector/riotx/core/ui/list/GenericItemWithValue.kt new file mode 100644 index 0000000000..2f1e77e965 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/ui/list/GenericItemWithValue.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2019 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.riotx.core.ui.list + +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.core.view.isVisible +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.riotx.R +import im.vector.riotx.core.epoxy.VectorEpoxyHolder +import im.vector.riotx.core.epoxy.VectorEpoxyModel +import im.vector.riotx.core.extensions.setTextOrHide +import im.vector.riotx.core.utils.DebouncedClickListener +import im.vector.riotx.features.themes.ThemeUtils + +/** + * A generic list item. + * Displays an item with a title, and optional description. + * Can display an accessory on the right, that can be an image or an indeterminate progress. + * If provided with an action, will display a button at the bottom of the list item. + */ +@EpoxyModelClass(layout = R.layout.item_generic_with_value) +abstract class GenericItemWithValue : VectorEpoxyModel() { + + @EpoxyAttribute + var title: String? = null + + @EpoxyAttribute + var value: CharSequence? = null + + @EpoxyAttribute + @ColorInt + var valueColorInt: Int? = null + + @EpoxyAttribute + @DrawableRes + var titleIconResourceId: Int = -1 + + @EpoxyAttribute + var itemClickAction: View.OnClickListener? = null + + override fun bind(holder: Holder) { + holder.titleText.setTextOrHide(title) + + if (titleIconResourceId != -1) { + holder.titleIcon.setImageResource(titleIconResourceId) + holder.titleIcon.isVisible = true + } else { + holder.titleIcon.isVisible = false + } + + + holder.valueText.setTextOrHide(value) + + if (valueColorInt != null) { + holder.valueText.setTextColor(valueColorInt!!) + } else { + holder.valueText.setTextColor(ThemeUtils.getColor(holder.view.context, R.attr.riotx_text_primary)) + } + + holder.view.setOnClickListener(itemClickAction?.let { DebouncedClickListener(it) }) + } + + class Holder : VectorEpoxyHolder() { + val titleIcon by bind(R.id.itemGenericWithValueTitleIcon) + val titleText by bind(R.id.itemGenericWithValueLabelText) + val valueText by bind(R.id.itemGenericWithValueValueText) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/roommemberprofile/RoomMemberProfileController.kt b/vector/src/main/java/im/vector/riotx/features/roommemberprofile/RoomMemberProfileController.kt index e98fdcf0cf..6ad4c02e1f 100644 --- a/vector/src/main/java/im/vector/riotx/features/roommemberprofile/RoomMemberProfileController.kt +++ b/vector/src/main/java/im/vector/riotx/features/roommemberprofile/RoomMemberProfileController.kt @@ -75,9 +75,9 @@ class RoomMemberProfileController @Inject constructor( buildProfileSection(stringProvider.getString(R.string.room_profile_section_security)) if (state.isRoomEncrypted) { - if (state.userMXCrossSigningInfo != null) { + if (state.userMXCrossSigningInfo.invoke() != null) { // Cross signing is enabled for this user - if (state.userMXCrossSigningInfo.isTrusted) { + if (state.userMXCrossSigningInfo.invoke()?.isTrusted() == true) { //User is trusted val icon = if (state.allDevicesAreTrusted.invoke() == true) R.drawable.ic_shield_trusted else R.drawable.ic_shield_warning @@ -126,14 +126,6 @@ class RoomMemberProfileController @Inject constructor( ) } } else { -// buildProfileAction( -// id = "learn_more", -// title = stringProvider.getString(R.string.room_profile_section_security_learn_more), -// dividerColor = dividerColor, -// editable = false, -// divider = false, -// subtitle = stringProvider.getString(R.string.room_profile_not_encrypted_subtitle) -// ) genericFooterItem { id("verify_footer_not_encrypted") text(stringProvider.getString(R.string.room_profile_not_encrypted_subtitle)) diff --git a/vector/src/main/java/im/vector/riotx/features/roommemberprofile/RoomMemberProfileFragment.kt b/vector/src/main/java/im/vector/riotx/features/roommemberprofile/RoomMemberProfileFragment.kt index e932a54a18..b07fa5a596 100644 --- a/vector/src/main/java/im/vector/riotx/features/roommemberprofile/RoomMemberProfileFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/roommemberprofile/RoomMemberProfileFragment.kt @@ -38,6 +38,7 @@ import im.vector.riotx.core.platform.StateView import im.vector.riotx.core.platform.VectorBaseFragment import im.vector.riotx.features.crypto.verification.VerificationBottomSheet import im.vector.riotx.features.home.AvatarRenderer +import im.vector.riotx.features.roommemberprofile.devices.DeviceListBottomSheet import kotlinx.android.parcel.Parcelize import kotlinx.android.synthetic.main.fragment_matrix_profile.* import kotlinx.android.synthetic.main.view_stub_room_member_profile_header.* @@ -175,8 +176,8 @@ class RoomMemberProfileFragment @Inject constructor( // } // } - override fun onShowDeviceList() { - TODO("not implemented") //To change body of created functions use File | Settings | File Templates. + override fun onShowDeviceList() = withState(viewModel) { + DeviceListBottomSheet.newInstance(it.userId).show(parentFragmentManager, "DEV_LIST") } override fun onShowDeviceListNoCrossSigning() { diff --git a/vector/src/main/java/im/vector/riotx/features/roommemberprofile/RoomMemberProfileViewModel.kt b/vector/src/main/java/im/vector/riotx/features/roommemberprofile/RoomMemberProfileViewModel.kt index 745b91bbe5..419278b486 100644 --- a/vector/src/main/java/im/vector/riotx/features/roommemberprofile/RoomMemberProfileViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/roommemberprofile/RoomMemberProfileViewModel.kt @@ -76,16 +76,6 @@ class RoomMemberProfileViewModel @AssistedInject constructor(@Assisted private v return fragment.viewModelFactory.create(state) } - override fun initialState(viewModelContext: ViewModelContext): RoomMemberProfileViewState? { - val session = (viewModelContext.activity as HasScreenInjector).injector().activeSessionHolder().getActiveSession() - val args = viewModelContext.args() - - return RoomMemberProfileViewState( - userId = args.userId, - roomId = args.roomId, - userMXCrossSigningInfo = session.getCrossSigningService().getUserCrossSigningKeys(args.userId) - ) - } } private val _viewEvents = PublishDataSource() @@ -126,6 +116,14 @@ class RoomMemberProfileViewModel @AssistedInject constructor(@Assisted private v .execute { copy(allDevicesAreTrusted = it) } + + session.rx().liveCrossSigningInfo(initialState.userId) + .map { + it.getOrNull() + } + .execute { + copy(userMXCrossSigningInfo = it) + } } } @@ -152,7 +150,7 @@ class RoomMemberProfileViewModel @AssistedInject constructor(@Assisted private v private fun prepareVerification(action: RoomMemberProfileAction.VerifyUser) = withState { state -> // Sanity if (state.isRoomEncrypted) { - if (!state.isMine && state.userMXCrossSigningInfo?.isTrusted == false) { + if (!state.isMine && state.userMXCrossSigningInfo.invoke()?.isTrusted() == false) { // ok, let's find or create the DM room _actionResultLiveData.postValue( LiveEvent(Success(action.copy(userId = state.userId))) diff --git a/vector/src/main/java/im/vector/riotx/features/roommemberprofile/RoomMemberProfileViewState.kt b/vector/src/main/java/im/vector/riotx/features/roommemberprofile/RoomMemberProfileViewState.kt index d4fa73c0b7..7a773d1228 100644 --- a/vector/src/main/java/im/vector/riotx/features/roommemberprofile/RoomMemberProfileViewState.kt +++ b/vector/src/main/java/im/vector/riotx/features/roommemberprofile/RoomMemberProfileViewState.kt @@ -35,9 +35,9 @@ data class RoomMemberProfileViewState( val powerLevelsContent: Async = Uninitialized, val userPowerLevelString: Async = Uninitialized, val userMatrixItem: Async = Uninitialized, - val userMXCrossSigningInfo: MXCrossSigningInfo? = null, + val userMXCrossSigningInfo: Async = Uninitialized, val allDevicesAreTrusted: Async = Uninitialized ) : MvRxState { - //constructor(args: RoomMemberProfileArgs) : this(roomId = args.roomId, userId = args.userId) + constructor(args: RoomMemberProfileArgs) : this(roomId = args.roomId, userId = args.userId) } diff --git a/vector/src/main/java/im/vector/riotx/features/roommemberprofile/devices/DeviceListBottomSheet.kt b/vector/src/main/java/im/vector/riotx/features/roommemberprofile/devices/DeviceListBottomSheet.kt new file mode 100644 index 0000000000..c4a1e102fc --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/roommemberprofile/devices/DeviceListBottomSheet.kt @@ -0,0 +1,87 @@ +/* + * Copyright 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.riotx.features.roommemberprofile.devices + +import android.os.Bundle +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import butterknife.BindView +import com.airbnb.mvrx.MvRx +import com.airbnb.mvrx.fragmentViewModel +import com.airbnb.mvrx.withState +import im.vector.matrix.android.internal.crypto.model.CryptoDeviceInfo +import im.vector.riotx.R +import im.vector.riotx.core.di.ScreenComponent +import im.vector.riotx.core.extensions.cleanup +import im.vector.riotx.core.extensions.configureWith +import im.vector.riotx.core.platform.VectorBaseBottomSheetDialogFragment +import im.vector.riotx.core.utils.DimensionConverter +import kotlinx.android.synthetic.main.bottom_sheet_generic_list_with_title.* +import javax.inject.Inject + +class DeviceListBottomSheet : VectorBaseBottomSheetDialogFragment(), DeviceListEpoxyController.InteractionListener { + + override fun getLayoutResId() = R.layout.bottom_sheet_generic_list_with_title + + private val viewModel: DeviceListBottomSheetViewModel by fragmentViewModel(DeviceListBottomSheetViewModel::class) + + @Inject lateinit var viewModelFactory: DeviceListBottomSheetViewModel.Factory + + @Inject lateinit var dimensionConverter: DimensionConverter + + @BindView(R.id.bottomSheetRecyclerView) + lateinit var recyclerView: RecyclerView + + override fun injectWith(injector: ScreenComponent) { + injector.inject(this) + } + + @Inject lateinit var epoxyController: DeviceListEpoxyController + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + recyclerView.setPadding(0, dimensionConverter.dpToPx(16 ),0, dimensionConverter.dpToPx(16 )) + recyclerView.configureWith( + epoxyController, + showDivider = false, + hasFixedSize = false) + epoxyController.interactionListener = this + bottomSheetTitle.isVisible = false + } + + override fun onDestroyView() { + recyclerView.cleanup() + super.onDestroyView() + } + + override fun invalidate() = withState(viewModel) { + epoxyController.setData(it) + super.invalidate() + } + + override fun onDeviceSelected(device: CryptoDeviceInfo) { + // TODO + } + + companion object { + fun newInstance(userId: String): DeviceListBottomSheet { + val args = Bundle() + args.putString(MvRx.KEY_ARG, userId) + return DeviceListBottomSheet().apply { arguments = args } + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/roommemberprofile/devices/DeviceListBottomSheetViewModel.kt b/vector/src/main/java/im/vector/riotx/features/roommemberprofile/devices/DeviceListBottomSheetViewModel.kt new file mode 100644 index 0000000000..59abbebeab --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/roommemberprofile/devices/DeviceListBottomSheetViewModel.kt @@ -0,0 +1,49 @@ +package im.vector.riotx.features.roommemberprofile.devices + +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.FragmentViewModelContext +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.MvRxState +import com.airbnb.mvrx.MvRxViewModelFactory +import com.airbnb.mvrx.ViewModelContext +import com.squareup.inject.assisted.Assisted +import com.squareup.inject.assisted.AssistedInject +import im.vector.matrix.android.api.session.Session +import im.vector.matrix.android.internal.crypto.model.CryptoDeviceInfo +import im.vector.matrix.rx.rx +import im.vector.riotx.core.platform.EmptyAction +import im.vector.riotx.core.platform.VectorViewModel +import im.vector.riotx.core.resources.StringProvider + +data class DeviceListViewState( + val cryptoDevices: Async> = Loading() +) : MvRxState + +class DeviceListBottomSheetViewModel @AssistedInject constructor(@Assisted private val initialState: DeviceListViewState, + @Assisted private val userId: String, + private val stringProvider: StringProvider, + private val session: Session) : VectorViewModel(initialState) { + + @AssistedInject.Factory + interface Factory { + fun create(initialState: DeviceListViewState, userId: String): DeviceListBottomSheetViewModel + } + + init { + session.rx().liveUserCryptoDevices(userId) + .execute { + copy(cryptoDevices = it) + } + } + + override fun handle(action: EmptyAction) {} + + companion object : MvRxViewModelFactory { + @JvmStatic + override fun create(viewModelContext: ViewModelContext, state: DeviceListViewState): DeviceListBottomSheetViewModel? { + val fragment: DeviceListBottomSheet = (viewModelContext as FragmentViewModelContext).fragment() + val userId = viewModelContext.args() + return fragment.viewModelFactory.create(state, userId) + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/roommemberprofile/devices/DeviceListEpoxyController.kt b/vector/src/main/java/im/vector/riotx/features/roommemberprofile/devices/DeviceListEpoxyController.kt new file mode 100644 index 0000000000..e00d5afda9 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/roommemberprofile/devices/DeviceListEpoxyController.kt @@ -0,0 +1,119 @@ +package im.vector.riotx.features.roommemberprofile.devices + +import com.airbnb.epoxy.TypedEpoxyController +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.Uninitialized +import im.vector.matrix.android.internal.crypto.model.CryptoDeviceInfo +import im.vector.riotx.R +import im.vector.riotx.core.epoxy.errorWithRetryItem +import im.vector.riotx.core.epoxy.loadingItem +import im.vector.riotx.core.resources.ColorProvider +import im.vector.riotx.core.resources.StringProvider +import im.vector.riotx.core.resources.UserPreferencesProvider +import im.vector.riotx.core.ui.list.GenericItem +import im.vector.riotx.core.ui.list.genericFooterItem +import im.vector.riotx.core.ui.list.genericItem +import im.vector.riotx.core.ui.list.genericItemWithValue +import im.vector.riotx.features.settings.VectorPreferences +import javax.inject.Inject + +class DeviceListEpoxyController @Inject constructor(private val stringProvider: StringProvider, + private val colorProvider: ColorProvider, + private val vectorPreferences: VectorPreferences) + : TypedEpoxyController() { + + interface InteractionListener { + fun onDeviceSelected(device: CryptoDeviceInfo) + } + + var interactionListener: InteractionListener? = null + + override fun buildModels(data: DeviceListViewState?) { + if (data == null) { + return + } + when (data.cryptoDevices) { + Uninitialized -> { + + } + is Loading -> { + loadingItem { + id("loading") + loadingText(stringProvider.getString(R.string.loading)) + } + } + is Success -> { + + val deviceList = data.cryptoDevices.invoke() + + // Build top header + val allGreen = deviceList.fold(true, { prev, device -> + prev && device.isVerified + }) + + genericItem { + id("title") + style(GenericItem.STYLE.BIG_TEXT) + titleIconResourceId(if (allGreen) R.drawable.ic_shield_trusted else R.drawable.ic_shield_warning) + title(stringProvider.getString(R.string.verification_profile_verified)) + description(stringProvider.getString(R.string.verification_conclusion_ok_notice)) + } + + genericItem { + id("sessions") + style(GenericItem.STYLE.BIG_TEXT) + title(stringProvider.getString(R.string.room_member_profile_sessions_section_title)) + + } + if (deviceList.isEmpty()) { + // Can this really happen? + genericFooterItem { + id("empty") + text(stringProvider.getString(R.string.search_no_results)) + } + } else { + // Build list of device with status + deviceList.forEach { device -> + genericItemWithValue { + id(device.deviceId) + titleIconResourceId(if (device.isVerified) R.drawable.ic_shield_trusted else R.drawable.ic_shield_warning) + title( + buildString { + append(device.displayName() ?: device.deviceId) + apply { + if (vectorPreferences.developerMode()) { + append("\n") + append(device.deviceId) + } + } + } + + ) + value( + stringProvider.getString( + if (device.isVerified) R.string.trusted else R.string.not_trusted + ) + ) + valueColorInt( + colorProvider.getColor( + if (device.isVerified) R.color.riotx_positive_accent else R.color.riotx_destructive_accent + ) + ) + } + } + } + } + is Fail -> { + errorWithRetryItem { + id("error") + text(stringProvider.getString(R.string.room_member_profile_failed_to_get_devices)) + listener { + // TODO + } + } + } + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/settings/devices/DeviceVerificationInfoEpoxyController.kt b/vector/src/main/java/im/vector/riotx/features/settings/devices/DeviceVerificationInfoEpoxyController.kt index a323dd4358..111dda6962 100644 --- a/vector/src/main/java/im/vector/riotx/features/settings/devices/DeviceVerificationInfoEpoxyController.kt +++ b/vector/src/main/java/im/vector/riotx/features/settings/devices/DeviceVerificationInfoEpoxyController.kt @@ -22,6 +22,7 @@ import im.vector.riotx.core.epoxy.dividerItem import im.vector.riotx.core.epoxy.loadingItem import im.vector.riotx.core.resources.ColorProvider import im.vector.riotx.core.resources.StringProvider +import im.vector.riotx.core.resources.UserPreferencesProvider import im.vector.riotx.core.ui.list.GenericItem import im.vector.riotx.core.ui.list.genericItem import im.vector.riotx.features.crypto.verification.epoxy.bottomSheetVerificationActionItem @@ -30,8 +31,7 @@ import javax.inject.Inject class DeviceVerificationInfoEpoxyController @Inject constructor(private val stringProvider: StringProvider, private val colorProvider: ColorProvider, - private val session: Session, - private val avatarRender: AvatarRenderer) + private val session: Session) : TypedEpoxyController() { var callback: Callback? = null diff --git a/vector/src/main/res/layout/item_generic_with_value.xml b/vector/src/main/res/layout/item_generic_with_value.xml new file mode 100644 index 0000000000..6fd45fa2ae --- /dev/null +++ b/vector/src/main/res/layout/item_generic_with_value.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/values/strings_riotX.xml b/vector/src/main/res/values/strings_riotX.xml index 5803157c6d..91a57e7a5a 100644 --- a/vector/src/main/res/values/strings_riotX.xml +++ b/vector/src/main/res/values/strings_riotX.xml @@ -124,4 +124,9 @@ Verified Warning + Failed to get devices + Sessions + Trusted + Not Trusted +