diff --git a/changelog.d/6964.wip b/changelog.d/6964.wip new file mode 100644 index 0000000000..f96dfe41ed --- /dev/null +++ b/changelog.d/6964.wip @@ -0,0 +1 @@ +[Device Manager] Render Security Recommendations diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index f8eb4b8de0..df0e10627a 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -3225,5 +3225,19 @@ View All (%1$d) Verified · Last activity %1$s Unverified · Last activity %1$s + + + Inactive for %1$d+ day (%2$s) + Inactive for %1$d+ days (%2$s) + + Security recommendations + Improve your account security by following these recommendations. + Unverified sessions + Verify or sign out from unverified sessions. + Inactive sessions + + Consider signing out from old sessions (%1$d day or more) that you don’t use anymore. + Consider signing out from old sessions (%1$d days or more) that you don’t use anymore. + diff --git a/library/ui-styles/src/main/res/values/colors.xml b/library/ui-styles/src/main/res/values/colors.xml index e72d02f51e..01af740d43 100644 --- a/library/ui-styles/src/main/res/values/colors.xml +++ b/library/ui-styles/src/main/res/values/colors.xml @@ -143,6 +143,7 @@ #0DBD8B #17191C #FF4B55 + #0FFF4B55 diff --git a/library/ui-styles/src/main/res/values/stylable_security_recommendation_view.xml b/library/ui-styles/src/main/res/values/stylable_security_recommendation_view.xml new file mode 100644 index 0000000000..4283c8da8a --- /dev/null +++ b/library/ui-styles/src/main/res/values/stylable_security_recommendation_view.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/vector/src/main/java/im/vector/app/core/date/DateFormatKind.kt b/vector/src/main/java/im/vector/app/core/date/DateFormatKind.kt index b08a1dc725..a66066ac8b 100644 --- a/vector/src/main/java/im/vector/app/core/date/DateFormatKind.kt +++ b/vector/src/main/java/im/vector/app/core/date/DateFormatKind.kt @@ -27,7 +27,7 @@ enum class DateFormatKind { // Will show hour or date relative (9:30am or yesterday or Sep 7 or 09/07/2020) ROOM_LIST, - // Will show full date (Sep 7 2020) + // Will show full date (Sep 7, 2020) TIMELINE_DAY_DIVIDER, // Will show full date and time (Mon, Sep 7 2020, 9:30am) diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/DevicesViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/DevicesViewModel.kt index 11fc6ecd64..3b5bcb61d9 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/DevicesViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/DevicesViewModel.kt @@ -34,6 +34,7 @@ import im.vector.app.core.resources.StringProvider import im.vector.app.core.utils.PublishDataSource import im.vector.app.features.auth.ReAuthActivity import im.vector.app.features.login.ReAuthHelper +import im.vector.app.features.settings.devices.v2.list.CheckIfSessionIsInactiveUseCase import im.vector.lib.core.utils.flow.throttleFirst import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.combine @@ -52,6 +53,7 @@ import org.matrix.android.sdk.api.auth.UserPasswordAuth import org.matrix.android.sdk.api.auth.data.LoginFlowTypes import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse import org.matrix.android.sdk.api.auth.registration.nextUncompletedStage +import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.crypto.crosssigning.DeviceTrustLevel @@ -81,12 +83,15 @@ data class DevicesViewState( val request: Async = Uninitialized, val hasAccountCrossSigning: Boolean = false, val accountCrossSigningIsTrusted: Boolean = false, + val unverifiedSessionsCount: Int = 0, + val inactiveSessionsCount: Int = 0, ) : MavericksState data class DeviceFullInfo( val deviceInfo: DeviceInfo, val cryptoDeviceInfo: CryptoDeviceInfo?, val trustLevelForShield: RoomEncryptionTrustLevel, + val isInactive: Boolean, ) class DevicesViewModel @AssistedInject constructor( @@ -95,6 +100,7 @@ class DevicesViewModel @AssistedInject constructor( private val reAuthHelper: ReAuthHelper, private val stringProvider: StringProvider, private val matrix: Matrix, + private val checkIfSessionIsInactiveUseCase: CheckIfSessionIsInactiveUseCase, ) : VectorViewModel(initialState), VerificationService.Listener { var uiaContinuation: Continuation? = null @@ -125,6 +131,14 @@ class DevicesViewModel @AssistedInject constructor( session.flow().liveUserCryptoDevices(session.myUserId), session.flow().liveMyDevicesInfo() ) { cryptoList, infoList -> + val unverifiedSessionsCount = cryptoList.count { !it.trustLevel?.isVerified().orFalse() } + val inactiveSessionsCount = infoList.count { checkIfSessionIsInactiveUseCase.execute(it.date) } + setState { + copy( + unverifiedSessionsCount = unverifiedSessionsCount, + inactiveSessionsCount = inactiveSessionsCount + ) + } infoList .sortedByDescending { it.lastSeenTs } .map { deviceInfo -> @@ -135,7 +149,8 @@ class DevicesViewModel @AssistedInject constructor( deviceTrustLevel = cryptoDeviceInfo?.trustLevel, isCurrentDevice = deviceInfo.deviceId == session.sessionParams.deviceId ) - DeviceFullInfo(deviceInfo, cryptoDeviceInfo, trustLevelForShield) + val isInactive = checkIfSessionIsInactiveUseCase.execute(deviceInfo.lastSeenTs ?: 0) + DeviceFullInfo(deviceInfo, cryptoDeviceInfo, trustLevelForShield, isInactive) } } .distinctUntilChanged() diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt index 80dfe25c77..78b8c66f9c 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt @@ -40,6 +40,8 @@ import im.vector.app.features.settings.devices.DeviceFullInfo import im.vector.app.features.settings.devices.DevicesAction import im.vector.app.features.settings.devices.DevicesViewEvents import im.vector.app.features.settings.devices.DevicesViewModel +import im.vector.app.features.settings.devices.v2.list.SESSION_IS_MARKED_AS_INACTIVE_AFTER_DAYS +import im.vector.app.features.settings.devices.v2.list.SecurityRecommendationViewState /** * Display the list of the user's devices and sessions. @@ -131,9 +133,11 @@ class VectorSettingsDevicesFragment : } val otherDevices = devices?.filter { it.deviceInfo.deviceId != state.myDeviceId } + renderSecurityRecommendations(state.inactiveSessionsCount, state.unverifiedSessionsCount) renderCurrentDevice(currentDeviceInfo) renderOtherSessionsView(otherDevices) } else { + hideSecurityRecommendations() hideCurrentSessionView() hideOtherSessionsView() } @@ -141,6 +145,38 @@ class VectorSettingsDevicesFragment : handleRequestStatus(state.request) } + private fun renderSecurityRecommendations(inactiveSessionsCount: Int, unverifiedSessionsCount: Int) { + if (unverifiedSessionsCount == 0 && inactiveSessionsCount == 0) { + hideSecurityRecommendations() + } else { + views.deviceListHeaderSectionSecurityRecommendations.isVisible = true + views.deviceListSecurityRecommendationsDivider.isVisible = true + views.deviceListUnverifiedSessionsRecommendation.isVisible = unverifiedSessionsCount > 0 + views.deviceListInactiveSessionsRecommendation.isVisible = inactiveSessionsCount > 0 + val unverifiedSessionsViewState = SecurityRecommendationViewState( + description = getString(R.string.device_manager_unverified_sessions_description), + sessionsCount = unverifiedSessionsCount, + ) + views.deviceListUnverifiedSessionsRecommendation.render(unverifiedSessionsViewState) + val inactiveSessionsViewState = SecurityRecommendationViewState( + description = resources.getQuantityString( + R.plurals.device_manager_inactive_sessions_description, + SESSION_IS_MARKED_AS_INACTIVE_AFTER_DAYS, + SESSION_IS_MARKED_AS_INACTIVE_AFTER_DAYS + ), + sessionsCount = inactiveSessionsCount, + ) + views.deviceListInactiveSessionsRecommendation.render(inactiveSessionsViewState) + } + } + + private fun hideSecurityRecommendations() { + views.deviceListHeaderSectionSecurityRecommendations.isVisible = false + views.deviceListUnverifiedSessionsRecommendation.isVisible = false + views.deviceListInactiveSessionsRecommendation.isVisible = false + views.deviceListSecurityRecommendationsDivider.isVisible = false + } + private fun renderOtherSessionsView(otherDevices: List?) { if (otherDevices.isNullOrEmpty()) { hideOtherSessionsView() @@ -169,6 +205,7 @@ class VectorSettingsDevicesFragment : private fun hideCurrentSessionView() { views.deviceListHeaderCurrentSession.isVisible = false views.deviceListCurrentSession.isVisible = false + views.deviceListDividerCurrentSession.isVisible = false } private fun handleRequestStatus(unIgnoreRequest: Async) { diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/CheckIfSessionIsInactiveUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/CheckIfSessionIsInactiveUseCase.kt new file mode 100644 index 0000000000..8991ad1e3d --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/CheckIfSessionIsInactiveUseCase.kt @@ -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.features.settings.devices.v2.list + +import im.vector.app.core.time.Clock +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +class CheckIfSessionIsInactiveUseCase @Inject constructor( + private val clock: Clock, +) { + + fun execute(lastSeenTs: Long): Boolean { + // In case of the server doesn't send the last seen date. + if (lastSeenTs == 0L) return true + + val diffMilliseconds = clock.epochMillis() - lastSeenTs + return diffMilliseconds >= TimeUnit.DAYS.toMillis(SESSION_IS_MARKED_AS_INACTIVE_AFTER_DAYS.toLong()) + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionItem.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionItem.kt index 2a62100994..e9376953e0 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionItem.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionItem.kt @@ -16,6 +16,7 @@ package im.vector.app.features.settings.devices.v2.list +import android.graphics.drawable.Drawable import android.widget.ImageView import android.widget.TextView import com.airbnb.epoxy.EpoxyAttribute @@ -42,6 +43,9 @@ abstract class OtherSessionItem : VectorEpoxyModel(R.la @EpoxyAttribute var sessionDescription: String? = null + @EpoxyAttribute + var sessionDescriptionDrawable: Drawable? = null + @EpoxyAttribute lateinit var stringProvider: StringProvider @@ -68,6 +72,7 @@ abstract class OtherSessionItem : VectorEpoxyModel(R.la holder.otherSessionVerificationStatusImageView.render(roomEncryptionTrustLevel) holder.otherSessionNameTextView.text = sessionName holder.otherSessionDescriptionTextView.text = sessionDescription + holder.otherSessionDescriptionTextView.setCompoundDrawablesWithIntrinsicBounds(sessionDescriptionDrawable, null, null, null) } class Holder : VectorEpoxyHolder() { diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionsController.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionsController.kt index 44c73e6eb7..8a5ee05af7 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionsController.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionsController.kt @@ -21,6 +21,8 @@ import im.vector.app.R import im.vector.app.core.date.DateFormatKind import im.vector.app.core.date.VectorDateFormatter import im.vector.app.core.epoxy.noResultItem +import im.vector.app.core.resources.ColorProvider +import im.vector.app.core.resources.DrawableProvider import im.vector.app.core.resources.StringProvider import im.vector.app.features.settings.devices.DeviceFullInfo import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel @@ -29,6 +31,8 @@ import javax.inject.Inject class OtherSessionsController @Inject constructor( private val stringProvider: StringProvider, private val dateFormatter: VectorDateFormatter, + private val drawableProvider: DrawableProvider, + private val colorProvider: ColorProvider, ) : TypedEpoxyController>() { override fun buildModels(data: List?) { @@ -41,12 +45,22 @@ class OtherSessionsController @Inject constructor( } } else { data.take(NUMBER_OF_OTHER_DEVICES_TO_RENDER).forEach { device -> - val formattedLastActivityDate = host.dateFormatter.format(device.deviceInfo.lastSeenTs, DateFormatKind.DEFAULT_DATE_AND_TIME) - val description = if (device.trustLevelForShield == RoomEncryptionTrustLevel.Trusted) { + val dateFormatKind = if (device.isInactive) DateFormatKind.TIMELINE_DAY_DIVIDER else DateFormatKind.DEFAULT_DATE_AND_TIME + val formattedLastActivityDate = host.dateFormatter.format(device.deviceInfo.lastSeenTs, dateFormatKind) + val description = if (device.isInactive) { + stringProvider.getQuantityString( + R.plurals.device_manager_other_sessions_description_inactive, + SESSION_IS_MARKED_AS_INACTIVE_AFTER_DAYS, + SESSION_IS_MARKED_AS_INACTIVE_AFTER_DAYS, + formattedLastActivityDate + ) + } else if (device.trustLevelForShield == RoomEncryptionTrustLevel.Trusted) { stringProvider.getString(R.string.device_manager_other_sessions_description_verified, formattedLastActivityDate) } else { stringProvider.getString(R.string.device_manager_other_sessions_description_unverified, formattedLastActivityDate) } + val drawableColor = colorProvider.getColorFromAttribute(R.attr.vctr_content_secondary) + val descriptionDrawable = if (device.isInactive) drawableProvider.getDrawable(R.drawable.ic_inactive_sessions, drawableColor) else null otherSessionItem { id(device.deviceInfo.deviceId) @@ -54,6 +68,7 @@ class OtherSessionsController @Inject constructor( roomEncryptionTrustLevel(device.trustLevelForShield) sessionName(device.deviceInfo.displayName) sessionDescription(description) + sessionDescriptionDrawable(descriptionDrawable) stringProvider(this@OtherSessionsController.stringProvider) } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SecurityRecommendationView.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SecurityRecommendationView.kt new file mode 100644 index 0000000000..93cf3c0501 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SecurityRecommendationView.kt @@ -0,0 +1,81 @@ +/* + * 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.list + +import android.content.Context +import android.content.res.ColorStateList +import android.content.res.TypedArray +import android.util.AttributeSet +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.res.use +import im.vector.app.R +import im.vector.app.databinding.ViewSecurityRecommendationBinding + +class SecurityRecommendationView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr) { + + private val views: ViewSecurityRecommendationBinding + + init { + inflate(context, R.layout.view_security_recommendation, this) + views = ViewSecurityRecommendationBinding.bind(this) + + context.obtainStyledAttributes( + attrs, + R.styleable.SecurityRecommendationView, + 0, + 0 + ).use { + setTitle(it) + setDescription(it) + setImage(it) + } + } + + private fun setTitle(typedArray: TypedArray) { + val title = typedArray.getString(R.styleable.SecurityRecommendationView_recommendationTitle) + views.recommendationTitleTextView.text = title + } + + private fun setDescription(typedArray: TypedArray) { + val description = typedArray.getString(R.styleable.SecurityRecommendationView_recommendationDescription) + setDescription(description) + } + + private fun setImage(typedArray: TypedArray) { + val imageResource = typedArray.getResourceId(R.styleable.SecurityRecommendationView_recommendationImageResource, 0) + val backgroundTint = typedArray.getColor(R.styleable.SecurityRecommendationView_recommendationImageBackgroundTint, 0) + views.recommendationShieldImageView.setImageResource(imageResource) + views.recommendationShieldImageView.backgroundTintList = ColorStateList.valueOf(backgroundTint) + } + + private fun setDescription(description: String?) { + views.recommendationDescriptionTextView.text = description + } + + private fun setCount(sessionsCount: Int) { + views.recommendationViewAllButton.text = context.getString(R.string.device_manager_other_sessions_view_all, sessionsCount) + } + + fun render(viewState: SecurityRecommendationViewState) { + setDescription(viewState.description) + setCount(viewState.sessionsCount) + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SecurityRecommendationViewState.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SecurityRecommendationViewState.kt new file mode 100644 index 0000000000..a5b30ea13b --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SecurityRecommendationViewState.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.settings.devices.v2.list + +data class SecurityRecommendationViewState( + val description: String, + val sessionsCount: Int, +) diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionListConstants.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionListConstants.kt index c1dbbdff4f..662ce536e7 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionListConstants.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionListConstants.kt @@ -17,3 +17,4 @@ package im.vector.app.features.settings.devices.v2.list internal const val NUMBER_OF_OTHER_DEVICES_TO_RENDER = 5 +internal const val SESSION_IS_MARKED_AS_INACTIVE_AFTER_DAYS = 90 diff --git a/vector/src/main/res/drawable/bg_security_recommendation_shield.xml b/vector/src/main/res/drawable/bg_security_recommendation_shield.xml new file mode 100644 index 0000000000..3d46fd4ff6 --- /dev/null +++ b/vector/src/main/res/drawable/bg_security_recommendation_shield.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/vector/src/main/res/drawable/ic_inactive_sessions.xml b/vector/src/main/res/drawable/ic_inactive_sessions.xml new file mode 100644 index 0000000000..30ee2b3b3c --- /dev/null +++ b/vector/src/main/res/drawable/ic_inactive_sessions.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/layout/fragment_settings_devices.xml b/vector/src/main/res/layout/fragment_settings_devices.xml index 1367835d2c..6710f345ce 100644 --- a/vector/src/main/res/layout/fragment_settings_devices.xml +++ b/vector/src/main/res/layout/fragment_settings_devices.xml @@ -8,6 +8,54 @@ android:layout_width="match_parent" android:layout_height="wrap_content"> + + + + + + + + + app:layout_constraintTop_toBottomOf="@id/deviceListSecurityRecommendationsDivider" /> diff --git a/vector/src/main/res/layout/view_current_session.xml b/vector/src/main/res/layout/view_current_session.xml index 31ad3cce2c..91977eba40 100644 --- a/vector/src/main/res/layout/view_current_session.xml +++ b/vector/src/main/res/layout/view_current_session.xml @@ -12,12 +12,12 @@ android:layout_width="40dp" android:layout_height="40dp" android:layout_marginTop="16dp" + android:background="@drawable/bg_device_type" android:contentDescription="@string/a11y_device_manager_device_type_mobile" android:padding="8dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - tools:background="@drawable/bg_device_type" tools:src="@drawable/ic_device_type_mobile" /> + + + + + + + + +