diff --git a/CHANGES.md b/CHANGES.md index 69d82683ad..882d300e45 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,12 +6,14 @@ Features ✨: - Cross-Signing | Verify new session from existing session (#1134) - Cross-Signing | Bootstraping cross signing with 4S from mobile (#985) + Improvements πŸ™Œ: - Verification DM / Handle concurrent .start after .ready (#794) - Cross-Signing | Update Shield Logic for DM (#963) - Cross-Signing | Complete security new session design update (#1135) - Cross-Signing | Setup key backup as part of SSSS bootstrapping (#1201) - Cross-Signing | Gossip key backup recovery key (#1200) + - Show room encryption status as a bubble tile (#1078) - UX/UI | Add indicator to home tab on invite (#957) Bugfix πŸ›: @@ -20,6 +22,7 @@ Bugfix πŸ›: - RiotX can't restore cross signing keys saved by web in SSSS (#1174) - Cross- Signing | After signin in new session, verification paper trail in DM is off (#1191) - Failed to encrypt message in room (message stays in red), [thanks to pwr22] (#925) + - Cross-Signing | web <-> riotX After QR code scan, gossiping fails (#1210) Translations πŸ—£: - diff --git a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/internal/crypto/gossiping/KeyShareTests.kt b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/internal/crypto/gossiping/KeyShareTests.kt index 7ac92ed74c..bb6e020d89 100644 --- a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/internal/crypto/gossiping/KeyShareTests.kt +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/internal/crypto/gossiping/KeyShareTests.kt @@ -282,7 +282,7 @@ class KeyShareTests : InstrumentedTest { val keysBackupService = aliceSession2.cryptoService().keysBackupService() mTestHelper.retryPeriodicallyWithLatch(latch) { Log.d("#TEST", "Recovery :${ keysBackupService.getKeyBackupRecoveryKeyInfo()?.recoveryKey}") - keysBackupService.getKeyBackupRecoveryKeyInfo()?.recoveryKey != creationInfo.recoveryKey + keysBackupService.getKeyBackupRecoveryKeyInfo()?.recoveryKey == creationInfo.recoveryKey } } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultVerificationService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultVerificationService.kt index dc68fa6b76..db6c224019 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultVerificationService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultVerificationService.kt @@ -22,6 +22,9 @@ import dagger.Lazy import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.session.crypto.CryptoService import im.vector.matrix.android.api.session.crypto.crosssigning.CrossSigningService +import im.vector.matrix.android.api.session.crypto.crosssigning.KEYBACKUP_SECRET_SSSS_NAME +import im.vector.matrix.android.api.session.crypto.crosssigning.SELF_SIGNING_KEY_SSSS_NAME +import im.vector.matrix.android.api.session.crypto.crosssigning.USER_SIGNING_KEY_SSSS_NAME import im.vector.matrix.android.api.session.crypto.verification.CancelCode import im.vector.matrix.android.api.session.crypto.verification.PendingVerificationRequest import im.vector.matrix.android.api.session.crypto.verification.QrCodeVerificationTransaction @@ -60,6 +63,7 @@ import im.vector.matrix.android.internal.crypto.model.MXUsersDevicesMap import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent import im.vector.matrix.android.internal.crypto.model.rest.KeyVerificationAccept import im.vector.matrix.android.internal.crypto.model.rest.KeyVerificationCancel +import im.vector.matrix.android.internal.crypto.model.rest.KeyVerificationDone import im.vector.matrix.android.internal.crypto.model.rest.KeyVerificationKey import im.vector.matrix.android.internal.crypto.model.rest.KeyVerificationMac import im.vector.matrix.android.internal.crypto.model.rest.KeyVerificationReady @@ -109,6 +113,10 @@ internal class DefaultVerificationService @Inject constructor( // map [sender : [transaction]] private val txMap = HashMap>() + // we need to keep track of finished transaction + // It will be used for gossiping (to send request after request is completed and 'done' by other) + private val pastTransactions = HashMap>() + /** * Map [sender: [PendingVerificationRequest]] * For now we keep all requests (even terminated ones) during the lifetime of the app. @@ -137,6 +145,9 @@ internal class DefaultVerificationService @Inject constructor( EventType.KEY_VERIFICATION_READY -> { onReadyReceived(event) } + EventType.KEY_VERIFICATION_DONE -> { + onDoneReceived(event) + } MessageType.MSGTYPE_VERIFICATION_REQUEST -> { onRequestReceived(event) } @@ -778,6 +789,31 @@ internal class DefaultVerificationService @Inject constructor( } } + private fun onDoneReceived(event: Event) { + Timber.v("## onDoneReceived") + val doneReq = event.getClearContent().toModel()?.asValidObject() + if (doneReq == null || event.senderId != userId) { + // ignore + Timber.e("## SAS Received invalid done request") + return + } + + // We only send gossiping request when the other sent us a done + // We can ask without checking too much thinks (like trust), because we will check validity of secret on reception + getExistingTransaction(userId, doneReq.transactionId) + ?: getOldTransaction(userId, doneReq.transactionId) + ?.let { vt -> + val otherDeviceId = vt.otherDeviceId + if (!crossSigningService.canCrossSign()) { + outgoingGossipingRequestManager.sendSecretShareRequest(SELF_SIGNING_KEY_SSSS_NAME, mapOf(userId to listOf(otherDeviceId + ?: "*"))) + outgoingGossipingRequestManager.sendSecretShareRequest(USER_SIGNING_KEY_SSSS_NAME, mapOf(userId to listOf(otherDeviceId + ?: "*"))) + } + outgoingGossipingRequestManager.sendSecretShareRequest(KEYBACKUP_SECRET_SSSS_NAME, mapOf(userId to listOf(otherDeviceId ?: "*"))) + } + } + private fun onRoomDoneReceived(event: Event) { val doneReq = event.getClearContent().toModel() ?.copy( @@ -1003,7 +1039,11 @@ internal class DefaultVerificationService @Inject constructor( private fun removeTransaction(otherUser: String, tid: String) { synchronized(txMap) { - txMap[otherUser]?.remove(tid)?.removeListener(this) + txMap[otherUser]?.remove(tid)?.also { + it.removeListener(this) + } + }?.let { + rememberOldTransaction(it) } } @@ -1016,6 +1056,20 @@ internal class DefaultVerificationService @Inject constructor( } } + private fun rememberOldTransaction(tx: DefaultVerificationTransaction) { + synchronized(pastTransactions) { + pastTransactions.getOrPut(tx.otherUserId) { HashMap() }[tx.transactionId] = tx + } + } + + private fun getOldTransaction(userId: String, tid: String?): DefaultVerificationTransaction? { + return tid?.let { + synchronized(pastTransactions) { + pastTransactions[userId]?.get(it) + } + } + } + override fun beginKeyVerification(method: VerificationMethod, otherUserId: String, otherDeviceId: String, transactionId: String?): String? { val txID = transactionId?.takeIf { it.isNotEmpty() } ?: createUniqueIDForTransaction(otherUserId, otherDeviceId) // should check if already one (and cancel it) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultVerificationTransaction.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultVerificationTransaction.kt index 5b8191fc99..29cfcd2383 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultVerificationTransaction.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/DefaultVerificationTransaction.kt @@ -17,9 +17,6 @@ package im.vector.matrix.android.internal.crypto.verification import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.session.crypto.crosssigning.CrossSigningService -import im.vector.matrix.android.api.session.crypto.crosssigning.KEYBACKUP_SECRET_SSSS_NAME -import im.vector.matrix.android.api.session.crypto.crosssigning.SELF_SIGNING_KEY_SSSS_NAME -import im.vector.matrix.android.api.session.crypto.crosssigning.USER_SIGNING_KEY_SSSS_NAME import im.vector.matrix.android.api.session.crypto.verification.VerificationTransaction import im.vector.matrix.android.api.session.crypto.verification.VerificationTxState import im.vector.matrix.android.internal.crypto.IncomingGossipingRequestManager @@ -100,15 +97,15 @@ internal abstract class DefaultVerificationTransaction( }) } - transport.done(transactionId) { - if (otherUserId == userId && !crossSigningService.canCrossSign()) { - outgoingGossipingRequestManager.sendSecretShareRequest(SELF_SIGNING_KEY_SSSS_NAME, mapOf(userId to listOf(otherDeviceId ?: "*"))) - outgoingGossipingRequestManager.sendSecretShareRequest(USER_SIGNING_KEY_SSSS_NAME, mapOf(userId to listOf(otherDeviceId ?: "*"))) - outgoingGossipingRequestManager.sendSecretShareRequest(KEYBACKUP_SECRET_SSSS_NAME, mapOf(userId to listOf(otherDeviceId ?: "*"))) - } - } - state = VerificationTxState.Verified + + transport.done(transactionId) { +// if (otherUserId == userId && !crossSigningService.canCrossSign()) { +// outgoingGossipingRequestManager.sendSecretShareRequest(SELF_SIGNING_KEY_SSSS_NAME, mapOf(userId to listOf(otherDeviceId ?: "*"))) +// outgoingGossipingRequestManager.sendSecretShareRequest(USER_SIGNING_KEY_SSSS_NAME, mapOf(userId to listOf(otherDeviceId ?: "*"))) +// outgoingGossipingRequestManager.sendSecretShareRequest(KEYBACKUP_SECRET_SSSS_NAME, mapOf(userId to listOf(otherDeviceId ?: "*"))) +// } + } } private fun setDeviceVerified(userId: String, deviceId: String) { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/VerificationInfoDone.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/VerificationInfoDone.kt index abb1141355..2986013fca 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/VerificationInfoDone.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/verification/VerificationInfoDone.kt @@ -18,11 +18,9 @@ package im.vector.matrix.android.internal.crypto.verification internal interface VerificationInfoDone : VerificationInfo { override fun asValidObject(): ValidVerificationInfoDone? { - if (transactionId.isNullOrEmpty()) { - return null - } - return ValidVerificationInfoDone + val validTransactionId = transactionId?.takeIf { it.isNotEmpty() } ?: return null + return ValidVerificationInfoDone(validTransactionId) } } -internal object ValidVerificationInfoDone +internal data class ValidVerificationInfoDone(val transactionId: String) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt index a8c9cf679b..48e92ca438 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt @@ -47,9 +47,9 @@ import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineEventVi import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider import im.vector.riotx.features.home.room.detail.timeline.helper.nextOrNull import im.vector.riotx.features.home.room.detail.timeline.item.BaseEventItem +import im.vector.riotx.features.home.room.detail.timeline.item.BasedMergedItem import im.vector.riotx.features.home.room.detail.timeline.item.DaySeparatorItem import im.vector.riotx.features.home.room.detail.timeline.item.DaySeparatorItem_ -import im.vector.riotx.features.home.room.detail.timeline.item.MergedHeaderItem import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData import im.vector.riotx.features.home.room.detail.timeline.item.ReadReceiptData import im.vector.riotx.features.home.room.detail.timeline.item.TimelineReadMarkerItem_ @@ -373,7 +373,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec val localId: Long, val eventId: String?, val eventModel: EpoxyModel<*>? = null, - val mergedHeaderModel: MergedHeaderItem? = null, + val mergedHeaderModel: BasedMergedItem<*>? = null, val formattedDayModel: DaySeparatorItem? = null ) { fun shouldTriggerBuild(): Boolean { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptionItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptionItemFactory.kt new file mode 100644 index 0000000000..ff65b0e656 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptionItemFactory.kt @@ -0,0 +1,80 @@ +/* + * 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.riotx.features.home.room.detail.timeline.factory + +import im.vector.matrix.android.api.session.events.model.toModel +import im.vector.matrix.android.api.session.room.timeline.TimelineEvent +import im.vector.matrix.android.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM +import im.vector.matrix.android.internal.crypto.model.event.EncryptionEventContent +import im.vector.riotx.R +import im.vector.riotx.core.resources.StringProvider +import im.vector.riotx.features.home.room.detail.timeline.MessageColorProvider +import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController +import im.vector.riotx.features.home.room.detail.timeline.helper.AvatarSizeProvider +import im.vector.riotx.features.home.room.detail.timeline.helper.MessageInformationDataFactory +import im.vector.riotx.features.home.room.detail.timeline.helper.MessageItemAttributesFactory +import im.vector.riotx.features.home.room.detail.timeline.item.StatusTileTimelineItem +import im.vector.riotx.features.home.room.detail.timeline.item.StatusTileTimelineItem_ +import javax.inject.Inject + +class EncryptionItemFactory @Inject constructor( + private val messageItemAttributesFactory: MessageItemAttributesFactory, + private val messageColorProvider: MessageColorProvider, + private val stringProvider: StringProvider, + private val informationDataFactory: MessageInformationDataFactory, + private val avatarSizeProvider: AvatarSizeProvider) { + + fun create(event: TimelineEvent, + highlight: Boolean, + callback: TimelineEventController.Callback?): StatusTileTimelineItem? { + val algorithm = event.root.getClearContent().toModel()?.algorithm + val informationData = informationDataFactory.create(event, null) + val attributes = messageItemAttributesFactory.create(null, informationData, callback) + + val isSafeAlgorithm = algorithm == MXCRYPTO_ALGORITHM_MEGOLM + val title: String + val description: String + val shield: StatusTileTimelineItem.ShieldUIState + if (isSafeAlgorithm) { + title = stringProvider.getString(R.string.encryption_enabled) + description = stringProvider.getString(R.string.encryption_enabled_tile_description) + shield = StatusTileTimelineItem.ShieldUIState.BLACK + } else { + title = stringProvider.getString(R.string.encryption_not_enabled) + description = stringProvider.getString(R.string.encryption_unknown_algorithm_tile_description) + shield = StatusTileTimelineItem.ShieldUIState.RED + } + return StatusTileTimelineItem_() + .attributes( + StatusTileTimelineItem.Attributes( + title = title, + description = description, + shieldUIState = shield, + informationData = informationData, + avatarRenderer = attributes.avatarRenderer, + messageColorProvider = messageColorProvider, + emojiTypeFace = attributes.emojiTypeFace, + itemClickListener = attributes.itemClickListener, + itemLongClickListener = attributes.itemLongClickListener, + reactionPillCallback = attributes.reactionPillCallback, + readReceiptsCallback = attributes.readReceiptsCallback + ) + ) + .highlighted(highlight) + .leftGuideline(avatarSizeProvider.leftGuideline) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt index 42dc4e07eb..377fc5ab4a 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt @@ -16,16 +16,24 @@ package im.vector.riotx.features.home.room.detail.timeline.factory +import im.vector.matrix.android.api.session.events.model.EventType +import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.room.timeline.TimelineEvent +import im.vector.matrix.android.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM +import im.vector.matrix.android.internal.crypto.model.event.EncryptionEventContent import im.vector.riotx.core.di.ActiveSessionHolder import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController import im.vector.riotx.features.home.room.detail.timeline.helper.AvatarSizeProvider import im.vector.riotx.features.home.room.detail.timeline.helper.MergedTimelineEventVisibilityStateChangedListener import im.vector.riotx.features.home.room.detail.timeline.helper.canBeMerged +import im.vector.riotx.features.home.room.detail.timeline.helper.isRoomConfiguration import im.vector.riotx.features.home.room.detail.timeline.helper.prevSameTypeEvents -import im.vector.riotx.features.home.room.detail.timeline.item.MergedHeaderItem -import im.vector.riotx.features.home.room.detail.timeline.item.MergedHeaderItem_ +import im.vector.riotx.features.home.room.detail.timeline.item.BasedMergedItem +import im.vector.riotx.features.home.room.detail.timeline.item.MergedMembershipEventsItem +import im.vector.riotx.features.home.room.detail.timeline.item.MergedMembershipEventsItem_ +import im.vector.riotx.features.home.room.detail.timeline.item.MergedRoomCreationItem +import im.vector.riotx.features.home.room.detail.timeline.item.MergedRoomCreationItem_ import javax.inject.Inject class MergedHeaderItemFactory @Inject constructor(private val sessionHolder: ActiveSessionHolder, @@ -43,8 +51,12 @@ class MergedHeaderItemFactory @Inject constructor(private val sessionHolder: Act eventIdToHighlight: String?, callback: TimelineEventController.Callback?, requestModelBuild: () -> Unit) - : MergedHeaderItem? { - return if (!event.canBeMerged() || (nextEvent?.root?.getClearType() == event.root.getClearType() && !addDaySeparator)) { + : BasedMergedItem<*>? { + return if (nextEvent?.root?.getClearType() == EventType.STATE_ROOM_CREATE && event.isRoomConfiguration()) { + // It's the first item before room.create + // Collapse all room configuration events + buildRoomCreationMergedSummary(currentPosition, items, event, eventIdToHighlight, requestModelBuild, callback) + } else if (!event.canBeMerged() || (nextEvent?.root?.getClearType() == event.root.getClearType() && !addDaySeparator)) { null } else { val prevSameTypeEvents = items.prevSameTypeEvents(currentPosition, 2) @@ -53,14 +65,14 @@ class MergedHeaderItemFactory @Inject constructor(private val sessionHolder: Act } else { var highlighted = false val mergedEvents = (prevSameTypeEvents + listOf(event)).asReversed() - val mergedData = ArrayList(mergedEvents.size) + val mergedData = ArrayList(mergedEvents.size) mergedEvents.forEach { mergedEvent -> if (!highlighted && mergedEvent.root.eventId == eventIdToHighlight) { highlighted = true } val senderAvatar = mergedEvent.senderAvatar val senderName = mergedEvent.getDisambiguatedDisplayName() - val data = MergedHeaderItem.Data( + val data = BasedMergedItem.Data( userId = mergedEvent.root.senderId ?: "", avatarUrl = senderAvatar, memberName = senderName, @@ -82,7 +94,7 @@ class MergedHeaderItemFactory @Inject constructor(private val sessionHolder: Act collapsedEventIds.removeAll(mergedEventIds) } val mergeId = mergedEventIds.joinToString(separator = "_") { it.toString() } - val attributes = MergedHeaderItem.Attributes( + val attributes = MergedMembershipEventsItem.Attributes( isCollapsed = isCollapsed, mergeData = mergedData, avatarRenderer = avatarRenderer, @@ -92,7 +104,7 @@ class MergedHeaderItemFactory @Inject constructor(private val sessionHolder: Act }, readReceiptsCallback = callback ) - MergedHeaderItem_() + MergedMembershipEventsItem_() .id(mergeId) .leftGuideline(avatarSizeProvider.leftGuideline) .highlighted(isCollapsed && highlighted) @@ -104,6 +116,81 @@ class MergedHeaderItemFactory @Inject constructor(private val sessionHolder: Act } } + private fun buildRoomCreationMergedSummary(currentPosition: Int, + items: List, + event: TimelineEvent, + eventIdToHighlight: String?, + requestModelBuild: () -> Unit, + callback: TimelineEventController.Callback?): MergedRoomCreationItem_? { + var prevEvent = if (currentPosition > 0) items[currentPosition - 1] else null + var tmpPos = currentPosition - 1 + val mergedEvents = ArrayList().also { it.add(event) } + var hasEncryption = false + var encryptionAlgorithm: String? = null + while (prevEvent != null && prevEvent.isRoomConfiguration()) { + if (prevEvent.root.getClearType() == EventType.STATE_ROOM_ENCRYPTION) { + hasEncryption = true + encryptionAlgorithm = prevEvent.root.getClearContent()?.toModel()?.algorithm + } + mergedEvents.add(prevEvent) + tmpPos-- + prevEvent = if (tmpPos >= 0) items[tmpPos] else null + } + return if (mergedEvents.size > 2) { + var highlighted = false + val mergedData = ArrayList(mergedEvents.size) + mergedEvents.reversed() + .forEach { mergedEvent -> + if (!highlighted && mergedEvent.root.eventId == eventIdToHighlight) { + highlighted = true + } + val senderAvatar = mergedEvent.senderAvatar + val senderName = mergedEvent.getDisambiguatedDisplayName() + val data = BasedMergedItem.Data( + userId = mergedEvent.root.senderId ?: "", + avatarUrl = senderAvatar, + memberName = senderName, + localId = mergedEvent.localId, + eventId = mergedEvent.root.eventId ?: "" + ) + mergedData.add(data) + } + val mergedEventIds = mergedEvents.map { it.localId } + // We try to find if one of the item id were used as mergeItemCollapseStates key + // => handle case where paginating from mergeable events and we get more + val previousCollapseStateKey = mergedEventIds.intersect(mergeItemCollapseStates.keys).firstOrNull() + val initialCollapseState = mergeItemCollapseStates.remove(previousCollapseStateKey) + ?: true + val isCollapsed = mergeItemCollapseStates.getOrPut(event.localId) { initialCollapseState } + if (isCollapsed) { + collapsedEventIds.addAll(mergedEventIds) + } else { + collapsedEventIds.removeAll(mergedEventIds) + } + val mergeId = mergedEventIds.joinToString(separator = "_") { it.toString() } + val attributes = MergedRoomCreationItem.Attributes( + isCollapsed = isCollapsed, + mergeData = mergedData, + avatarRenderer = avatarRenderer, + onCollapsedStateChanged = { + mergeItemCollapseStates[event.localId] = it + requestModelBuild() + }, + hasEncryptionEvent = hasEncryption, + isEncryptionAlgorithmSecure = encryptionAlgorithm == MXCRYPTO_ALGORITHM_MEGOLM, + readReceiptsCallback = callback + ) + MergedRoomCreationItem_() + .id(mergeId) + .leftGuideline(avatarSizeProvider.leftGuideline) + .highlighted(isCollapsed && highlighted) + .attributes(attributes) + .also { + it.setOnVisibilityStateChanged(MergedTimelineEventVisibilityStateChangedListener(callback, mergedEvents)) + } + } else null + } + fun isCollapsed(localId: Long): Boolean { return collapsedEventIds.contains(localId) } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt index 1462f5fe0d..7e6c387934 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt @@ -29,6 +29,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me private val encryptedItemFactory: EncryptedItemFactory, private val noticeItemFactory: NoticeItemFactory, private val defaultItemFactory: DefaultItemFactory, + private val encryptionItemFactory: EncryptionItemFactory, private val roomCreateItemFactory: RoomCreateItemFactory, private val verificationConclusionItemFactory: VerificationItemFactory, private val userPreferencesProvider: UserPreferencesProvider) { @@ -57,8 +58,10 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me EventType.CALL_HANGUP, EventType.CALL_ANSWER, EventType.REACTION, - EventType.REDACTION, - EventType.STATE_ROOM_ENCRYPTION -> noticeItemFactory.create(event, highlight, callback) + EventType.REDACTION -> noticeItemFactory.create(event, highlight, callback) + EventType.STATE_ROOM_ENCRYPTION -> { + encryptionItemFactory.create(event, highlight, callback) + } // State room create EventType.STATE_ROOM_CREATE -> roomCreateItemFactory.create(event, callback) // Crypto diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/VerificationItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/VerificationItemFactory.kt index ee529282f9..837d0ad571 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/VerificationItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/VerificationItemFactory.kt @@ -25,15 +25,17 @@ import im.vector.matrix.android.api.session.room.model.message.MessageRelationCo import im.vector.matrix.android.api.session.room.model.message.MessageVerificationCancelContent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.internal.session.room.VerificationState +import im.vector.riotx.R import im.vector.riotx.core.epoxy.VectorEpoxyModel +import im.vector.riotx.core.resources.StringProvider import im.vector.riotx.core.resources.UserPreferencesProvider import im.vector.riotx.features.home.room.detail.timeline.MessageColorProvider import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController import im.vector.riotx.features.home.room.detail.timeline.helper.AvatarSizeProvider import im.vector.riotx.features.home.room.detail.timeline.helper.MessageInformationDataFactory import im.vector.riotx.features.home.room.detail.timeline.helper.MessageItemAttributesFactory -import im.vector.riotx.features.home.room.detail.timeline.item.VerificationRequestConclusionItem -import im.vector.riotx.features.home.room.detail.timeline.item.VerificationRequestConclusionItem_ +import im.vector.riotx.features.home.room.detail.timeline.item.StatusTileTimelineItem +import im.vector.riotx.features.home.room.detail.timeline.item.StatusTileTimelineItem_ import javax.inject.Inject /** @@ -48,6 +50,7 @@ class VerificationItemFactory @Inject constructor( private val avatarSizeProvider: AvatarSizeProvider, private val noticeItemFactory: NoticeItemFactory, private val userPreferencesProvider: UserPreferencesProvider, + private val stringProvider: StringProvider, private val session: Session ) { @@ -88,12 +91,12 @@ class VerificationItemFactory @Inject constructor( CancelCode.MismatchedKeys, CancelCode.MismatchedSas -> { // We should display these bad conclusions - return VerificationRequestConclusionItem_() + return StatusTileTimelineItem_() .attributes( - VerificationRequestConclusionItem.Attributes( - toUserId = informationData.senderId, - toUserName = informationData.memberName.toString(), - isPositive = false, + StatusTileTimelineItem.Attributes( + title = stringProvider.getString(R.string.verification_conclusion_warning), + description = "${informationData.memberName} (${informationData.senderId})", + shieldUIState = StatusTileTimelineItem.ShieldUIState.RED, informationData = informationData, avatarRenderer = attributes.avatarRenderer, messageColorProvider = messageColorProvider, @@ -121,12 +124,12 @@ class VerificationItemFactory @Inject constructor( // We only display the done sent by the other user, the done send by me is ignored return ignoredConclusion(event, highlight, callback) } - return VerificationRequestConclusionItem_() + return StatusTileTimelineItem_() .attributes( - VerificationRequestConclusionItem.Attributes( - toUserId = informationData.senderId, - toUserName = informationData.memberName.toString(), - isPositive = true, + StatusTileTimelineItem.Attributes( + title = stringProvider.getString(R.string.sas_verified), + description = "${informationData.memberName} (${informationData.senderId})", + shieldUIState = StatusTileTimelineItem.ShieldUIState.GREEN, informationData = informationData, avatarRenderer = attributes.avatarRenderer, messageColorProvider = messageColorProvider, diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt index 5c763cb114..1ea3cd64ac 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt @@ -50,6 +50,18 @@ fun TimelineEvent.canBeMerged(): Boolean { return root.getClearType() == EventType.STATE_ROOM_MEMBER } +fun TimelineEvent.isRoomConfiguration(): Boolean { + return when (root.getClearType()) { + EventType.STATE_ROOM_GUEST_ACCESS, + EventType.STATE_ROOM_HISTORY_VISIBILITY, + EventType.STATE_ROOM_JOIN_RULES, + EventType.STATE_ROOM_MEMBER, + EventType.STATE_ROOM_NAME, + EventType.STATE_ROOM_ENCRYPTION -> true + else -> false + } +} + fun List.nextSameTypeEvents(index: Int, minSize: Int): List { if (index >= size - 1) { return emptyList() diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BasedMergedItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BasedMergedItem.kt new file mode 100644 index 0000000000..adc9b1442f --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BasedMergedItem.kt @@ -0,0 +1,82 @@ +/* + * 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.riotx.features.home.room.detail.timeline.item + +import android.view.View +import android.widget.TextView +import androidx.annotation.IdRes +import androidx.core.view.isVisible +import im.vector.matrix.android.api.util.MatrixItem +import im.vector.riotx.R +import im.vector.riotx.features.home.AvatarRenderer +import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController + +abstract class BasedMergedItem : BaseEventItem() { + + abstract val attributes: Attributes + + override fun bind(holder: H) { + super.bind(holder) + holder.expandView.setOnClickListener { + attributes.onCollapsedStateChanged(!attributes.isCollapsed) + } + if (attributes.isCollapsed) { + holder.separatorView.visibility = View.GONE + holder.expandView.setText(R.string.merged_events_expand) + } else { + holder.separatorView.visibility = View.VISIBLE + holder.expandView.setText(R.string.merged_events_collapse) + } + // No read receipt for this item + holder.readReceiptsView.isVisible = false + } + + protected val distinctMergeData by lazy { + attributes.mergeData.distinctBy { it.userId } + } + + override fun getEventIds(): List { + return if (attributes.isCollapsed) { + attributes.mergeData.map { it.eventId } + } else { + emptyList() + } + } + + data class Data( + val localId: Long, + val eventId: String, + val userId: String, + val memberName: String, + val avatarUrl: String? + ) + + fun Data.toMatrixItem() = MatrixItem.UserItem(userId, memberName, avatarUrl) + + interface Attributes { + val isCollapsed: Boolean + val mergeData: List + val avatarRenderer: AvatarRenderer + val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? + val onCollapsedStateChanged: (Boolean) -> Unit + } + + abstract class Holder(@IdRes stubId: Int) : BaseEventItem.BaseHolder(stubId) { + val expandView by bind(R.id.itemMergedExpandTextView) + val separatorView by bind(R.id.itemMergedSeparatorView) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedMembershipEventsItem.kt similarity index 61% rename from vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt rename to vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedMembershipEventsItem.kt index 93f7dc271d..8e3ba0bcff 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedMembershipEventsItem.kt @@ -24,28 +24,20 @@ import androidx.core.view.children import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass -import im.vector.matrix.android.api.util.MatrixItem import im.vector.riotx.R import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController @EpoxyModelClass(layout = R.layout.item_timeline_event_base_noinfo) -abstract class MergedHeaderItem : BaseEventItem() { - - @EpoxyAttribute - lateinit var attributes: Attributes - - private val distinctMergeData by lazy { - attributes.mergeData.distinctBy { it.userId } - } +abstract class MergedMembershipEventsItem : BasedMergedItem() { override fun getViewType() = STUB_ID + @EpoxyAttribute + override lateinit var attributes: Attributes + override fun bind(holder: Holder) { super.bind(holder) - holder.expandView.setOnClickListener { - attributes.onCollapsedStateChanged(!attributes.isCollapsed) - } if (attributes.isCollapsed) { val summary = holder.expandView.resources.getQuantityString(R.plurals.membership_changes, attributes.mergeData.size, attributes.mergeData.size) holder.summaryView.text = summary @@ -60,52 +52,28 @@ abstract class MergedHeaderItem : BaseEventItem() { view.visibility = View.GONE } } - holder.separatorView.visibility = View.GONE - holder.expandView.setText(R.string.merged_events_expand) } else { holder.avatarListView.visibility = View.INVISIBLE holder.summaryView.visibility = View.GONE - holder.separatorView.visibility = View.VISIBLE - holder.expandView.setText(R.string.merged_events_collapse) } // No read receipt for this item holder.readReceiptsView.isVisible = false } - override fun getEventIds(): List { - return if (attributes.isCollapsed) { - attributes.mergeData.map { it.eventId } - } else { - emptyList() - } - } - - data class Data( - val localId: Long, - val eventId: String, - val userId: String, - val memberName: String, - val avatarUrl: String? - ) - - fun Data.toMatrixItem() = MatrixItem.UserItem(userId, memberName, avatarUrl) - - data class Attributes( - val isCollapsed: Boolean, - val mergeData: List, - val avatarRenderer: AvatarRenderer, - val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null, - val onCollapsedStateChanged: (Boolean) -> Unit - ) - - class Holder : BaseHolder(STUB_ID) { - val expandView by bind(R.id.itemMergedExpandTextView) + class Holder : BasedMergedItem.Holder(STUB_ID) { val summaryView by bind(R.id.itemMergedSummaryTextView) - val separatorView by bind(R.id.itemMergedSeparatorView) val avatarListView by bind(R.id.itemMergedAvatarListView) } companion object { private const val STUB_ID = R.id.messageContentMergedHeaderStub } + + data class Attributes( + override val isCollapsed: Boolean, + override val mergeData: List, + override val avatarRenderer: AvatarRenderer, + override val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null, + override val onCollapsedStateChanged: (Boolean) -> Unit + ) : BasedMergedItem.Attributes } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedRoomCreationItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedRoomCreationItem.kt new file mode 100644 index 0000000000..81050194a8 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedRoomCreationItem.kt @@ -0,0 +1,115 @@ +/* + * 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.riotx.features.home.room.detail.timeline.item + +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.RelativeLayout +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.riotx.R +import im.vector.riotx.features.home.AvatarRenderer +import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController + +@EpoxyModelClass(layout = R.layout.item_timeline_event_base_noinfo) +abstract class MergedRoomCreationItem : BasedMergedItem() { + + @EpoxyAttribute + override lateinit var attributes: Attributes + + override fun getViewType() = STUB_ID + + override fun bind(holder: Holder) { + super.bind(holder) + + if (attributes.isCollapsed) { + val data = distinctMergeData.firstOrNull() + + val summary = holder.expandView.resources.getString(R.string.room_created_summary_item, + data?.memberName ?: data?.userId ?: "") + holder.summaryView.text = summary + holder.summaryView.visibility = View.VISIBLE + holder.avatarView.visibility = View.VISIBLE + if (data != null) { + holder.avatarView.visibility = View.VISIBLE + attributes.avatarRenderer.render(data.toMatrixItem(), holder.avatarView) + } else { + holder.avatarView.visibility = View.GONE + } + + if (attributes.hasEncryptionEvent) { + holder.encryptionTile.isVisible = true + holder.encryptionTile.updateLayoutParams { + this.marginEnd = leftGuideline + } + if (attributes.isEncryptionAlgorithmSecure) { + holder.e2eTitleTextView.text = holder.expandView.resources.getString(R.string.encryption_enabled) + holder.e2eTitleDescriptionView.text = holder.expandView.resources.getString(R.string.encryption_enabled_tile_description) + holder.e2eTitleDescriptionView.textAlignment = View.TEXT_ALIGNMENT_CENTER + holder.e2eTitleTextView.setCompoundDrawablesWithIntrinsicBounds( + ContextCompat.getDrawable(holder.view.context, R.drawable.ic_shield_black), + null, null, null + ) + } else { + holder.e2eTitleTextView.text = holder.expandView.resources.getString(R.string.encryption_not_enabled) + holder.e2eTitleDescriptionView.text = holder.expandView.resources.getString(R.string.encryption_unknown_algorithm_tile_description) + holder.e2eTitleTextView.setCompoundDrawablesWithIntrinsicBounds( + ContextCompat.getDrawable(holder.view.context, R.drawable.ic_shield_warning), + null, null, null + ) + } + } else { + holder.encryptionTile.isVisible = false + } + } else { + holder.avatarView.visibility = View.INVISIBLE + holder.summaryView.visibility = View.GONE + holder.encryptionTile.isGone = true + } + // No read receipt for this item + holder.readReceiptsView.isVisible = false + } + + class Holder : BasedMergedItem.Holder(STUB_ID) { + val summaryView by bind(R.id.itemNoticeTextView) + val avatarView by bind(R.id.itemNoticeAvatarView) + val encryptionTile by bind(R.id.creationEncryptionTile) + + val e2eTitleTextView by bind(R.id.itemVerificationDoneTitleTextView) + val e2eTitleDescriptionView by bind(R.id.itemVerificationDoneDetailTextView) + } + + companion object { + private const val STUB_ID = R.id.messageContentMergedCreationStub + } + + data class Attributes( + override val isCollapsed: Boolean, + override val mergeData: List, + override val avatarRenderer: AvatarRenderer, + override val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null, + override val onCollapsedStateChanged: (Boolean) -> Unit, + val hasEncryptionEvent : Boolean, + val isEncryptionAlgorithmSecure: Boolean + ) : BasedMergedItem.Attributes +} diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/VerificationRequestConclusionItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/StatusTileTimelineItem.kt similarity index 81% rename from vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/VerificationRequestConclusionItem.kt rename to vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/StatusTileTimelineItem.kt index 2b28e15cab..f9ea2a71df 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/VerificationRequestConclusionItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/StatusTileTimelineItem.kt @@ -31,7 +31,7 @@ import im.vector.riotx.features.home.room.detail.timeline.MessageColorProvider import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController @EpoxyModelClass(layout = R.layout.item_timeline_event_base_state) -abstract class VerificationRequestConclusionItem : AbsBaseMessageItem() { +abstract class StatusTileTimelineItem : AbsBaseMessageItem() { override val baseAttributes: AbsBaseMessageItem.Attributes get() = attributes @@ -47,11 +47,17 @@ abstract class VerificationRequestConclusionItem : AbsBaseMessageItem { this.marginEnd = leftGuideline } - val title = if (attributes.isPositive) R.string.sas_verified else R.string.verification_conclusion_warning - holder.titleView.text = holder.view.context.getString(title) - holder.descriptionView.text = "${attributes.informationData.memberName} (${attributes.informationData.senderId})" - val startDrawable = if (attributes.isPositive) R.drawable.ic_shield_trusted else R.drawable.ic_shield_warning + holder.titleView.text = attributes.title + holder.descriptionView.text = attributes.description + holder.descriptionView.textAlignment = View.TEXT_ALIGNMENT_CENTER + + val startDrawable = when (attributes.shieldUIState) { + ShieldUIState.GREEN -> R.drawable.ic_shield_trusted + ShieldUIState.BLACK -> R.drawable.ic_shield_black + ShieldUIState.RED -> R.drawable.ic_shield_warning + } + holder.titleView.setCompoundDrawablesWithIntrinsicBounds( ContextCompat.getDrawable(holder.view.context, startDrawable), null, null, null @@ -75,9 +81,9 @@ abstract class VerificationRequestConclusionItem : AbsBaseMessageItem + + diff --git a/vector/src/main/res/layout/item_timeline_event_merged_room_creation_stub.xml b/vector/src/main/res/layout/item_timeline_event_merged_room_creation_stub.xml new file mode 100644 index 0000000000..3ed840954f --- /dev/null +++ b/vector/src/main/res/layout/item_timeline_event_merged_room_creation_stub.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/item_timeline_event_verification_done_stub.xml b/vector/src/main/res/layout/item_timeline_event_status_tile_stub.xml similarity index 100% rename from vector/src/main/res/layout/item_timeline_event_verification_done_stub.xml rename to vector/src/main/res/layout/item_timeline_event_status_tile_stub.xml diff --git a/vector/src/main/res/values/strings_riotX.xml b/vector/src/main/res/values/strings_riotX.xml index 29e57d1133..606a1b95f6 100644 --- a/vector/src/main/res/values/strings_riotX.xml +++ b/vector/src/main/res/values/strings_riotX.xml @@ -87,6 +87,13 @@ Setting a Message Password lets you secure & unlock encrypted messages and trust.\n\nIf you don’t want to set a Message Password, generate a Message Key instead. Setting a Message Password lets you secure & unlock encrypted messages and trust. + + Encryption enabled + Messages in this room are end-to-end encrypted. Learn more & verify users in their profile. + Encryption not enabled + The encryption used by this room is not supported + + %s created and configured the room.