diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/LearnMoreConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/LearnMoreConfig.kt index c0c152142e..855586892a 100644 --- a/appconfig/src/main/kotlin/io/element/android/appconfig/LearnMoreConfig.kt +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/LearnMoreConfig.kt @@ -13,4 +13,5 @@ object LearnMoreConfig { const val DEVICE_VERIFICATION_URL: String = "https://element.io/help#encryption-device-verification" const val SECURE_BACKUP_URL: String = "https://element.io/help#encryption5" const val IDENTITY_CHANGE_URL: String = "https://element.io/help#encryption18" + const val HISTORY_VISIBLE_URL: String = "https://element.io/en/help#e2ee-history-sharing" } diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index ad6562a83c..eb8aff66ed 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -68,6 +68,7 @@ dependencies { implementation(libs.jsoup) implementation(libs.androidx.constraintlayout) implementation(libs.androidx.constraintlayout.compose) + implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.ui) implementation(libs.sigpwned.emoji4j) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index e912722c6d..a5b3106b3d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -32,6 +32,7 @@ import io.element.android.features.messages.api.timeline.HtmlConverterProvider import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleState import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState import io.element.android.features.messages.impl.link.LinkState import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvent @@ -107,6 +108,7 @@ class MessagesPresenter( @Assisted private val timelinePresenter: Presenter, private val timelineProtectionPresenter: Presenter, private val identityChangeStatePresenter: Presenter, + private val historyVisibleStatePresenter: Presenter, private val linkPresenter: Presenter, @Assisted private val actionListPresenter: Presenter, private val customReactionPresenter: Presenter, @@ -158,6 +160,7 @@ class MessagesPresenter( val timelineState = timelinePresenter.present() val timelineProtectionState = timelineProtectionPresenter.present() val identityChangeState = identityChangeStatePresenter.present() + val historyVisibleState = historyVisibleStatePresenter.present() val actionListState = actionListPresenter.present() val linkState = linkPresenter.present() val customReactionState = customReactionPresenter.present() @@ -278,6 +281,7 @@ class MessagesPresenter( timelineState = timelineState, timelineProtectionState = timelineProtectionState, identityChangeState = identityChangeState, + historyVisibleState = historyVisibleState, linkState = linkState, actionListState = actionListState, customReactionState = customReactionState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index 9faf2f69eb..b9d86a6597 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -10,6 +10,7 @@ package io.element.android.features.messages.impl import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState import io.element.android.features.messages.impl.actionlist.ActionListState +import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleState import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState import io.element.android.features.messages.impl.link.LinkState import io.element.android.features.messages.impl.messagecomposer.MessageComposerState @@ -40,6 +41,7 @@ data class MessagesState( val timelineState: TimelineState, val timelineProtectionState: TimelineProtectionState, val identityChangeState: IdentityChangeState, + val historyVisibleState: HistoryVisibleState, val linkState: LinkState, val actionListState: ActionListState, val customReactionState: CustomReactionState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index 3a077e6cf0..7831265fd0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -14,7 +14,10 @@ import io.element.android.features.messages.api.timeline.voicemessages.composer. import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessagePreviewState import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.anActionListState +import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleState +import io.element.android.features.messages.impl.crypto.historyvisible.aHistoryVisibleState import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState +import io.element.android.features.messages.impl.crypto.identity.aRoomMemberIdentityStateChange import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState import io.element.android.features.messages.impl.link.LinkState import io.element.android.features.messages.impl.link.aLinkState @@ -48,6 +51,7 @@ import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown import io.element.android.libraries.textcomposer.model.aTextEditorStateRich import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -83,6 +87,19 @@ open class MessagesStateProvider : PreviewParameterProvider { timelineItems = aTimelineItemList(aTimelineItemTextContent()), ) ), + aMessagesState( + composerState = aMessageComposerState(textEditorState = aTextEditorStateMarkdown()), + identityChangeState = anIdentityChangeState(listOf(aRoomMemberIdentityStateChange())) + ), + aMessagesState( + composerState = aMessageComposerState(textEditorState = aTextEditorStateMarkdown()), + historyVisibleState = aHistoryVisibleState(showAlert = true) + ), + aMessagesState( + composerState = aMessageComposerState(textEditorState = aTextEditorStateMarkdown()), + identityChangeState = anIdentityChangeState(listOf(aRoomMemberIdentityStateChange())), + historyVisibleState = aHistoryVisibleState(showAlert = true) + ) ) } @@ -103,6 +120,7 @@ fun aMessagesState( ), timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(), identityChangeState: IdentityChangeState = anIdentityChangeState(), + historyVisibleState: HistoryVisibleState = aHistoryVisibleState(), linkState: LinkState = aLinkState(), readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(), actionListState: ActionListState = anActionListState(), @@ -125,6 +143,7 @@ fun aMessagesState( voiceMessageComposerState = voiceMessageComposerState, timelineProtectionState = timelineProtectionState, identityChangeState = identityChangeState, + historyVisibleState = historyVisibleState, linkState = linkState, timelineState = timelineState, readReceiptBottomSheetState = readReceiptBottomSheetState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index a37710843d..5479836bb3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -53,6 +53,7 @@ import io.element.android.features.messages.api.timeline.voicemessages.composer. import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListView import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleStateView import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStateView import io.element.android.features.messages.impl.link.LinkEvents import io.element.android.features.messages.impl.link.LinkView @@ -486,10 +487,17 @@ private fun MessagesViewComposerBottomSheetContents( // Do not show the identity change if user is composing a Rich message or is seeing suggestion(s). if (state.composerState.suggestions.isEmpty() && state.composerState.textEditorState is TextEditorState.Markdown) { - IdentityChangeStateView( - state = state.identityChangeState, - onLinkClick = onLinkClick, - ) + if (state.identityChangeState.roomMemberIdentityStateChanges.isNotEmpty()) { + IdentityChangeStateView( + state = state.identityChangeState, + onLinkClick = onLinkClick, + ) + } else { + HistoryVisibleStateView( + state = state.historyVisibleState, + onLinkClick = onLinkClick, + ) + } } val verificationViolation = state.identityChangeState.roomMemberIdentityStateChanges.firstOrNull { it.identityState == IdentityState.VerificationViolation diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleAcknowledgementRepository.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleAcknowledgementRepository.kt new file mode 100644 index 0000000000..1fa992fc3e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleAcknowledgementRepository.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.historyvisible + +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.androidutils.hash.hash +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface HistoryVisibleAcknowledgementRepository { + fun hasAcknowledged(roomId: RoomId): Flow + suspend fun setAcknowledged(roomId: RoomId, value: Boolean) +} + +@ContributesBinding(SessionScope::class) +class DefaultHistoryVisibleAcknowledgementRepository( + sessionId: SessionId, + preferenceDataStoreFactory: PreferenceDataStoreFactory, +) : HistoryVisibleAcknowledgementRepository { + val store = + sessionId.value.hash().take(16).let { hash -> + preferenceDataStoreFactory.create("elementx_historyvisible_$hash") + } + + override fun hasAcknowledged(roomId: RoomId): Flow { + return store.data.map { prefs -> + val acknowledged = prefs[booleanPreferencesKey(roomId.value)] ?: false + acknowledged + } + } + + override suspend fun setAcknowledged(roomId: RoomId, value: Boolean) { + store.edit { prefs -> + prefs[booleanPreferencesKey(roomId.value)] = value + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleEvent.kt new file mode 100644 index 0000000000..775d9c00d4 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleEvent.kt @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.historyvisible + +sealed interface HistoryVisibleEvent { + data object Acknowledge : HistoryVisibleEvent +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleState.kt new file mode 100644 index 0000000000..3f980eb086 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleState.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.historyvisible + +data class HistoryVisibleState( + val showAlert: Boolean, + val eventSink: (HistoryVisibleEvent) -> Unit, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStatePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStatePresenter.kt new file mode 100644 index 0000000000..d76e567b3c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStatePresenter.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.historyvisible + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +@Inject +class HistoryVisibleStatePresenter( + private val featureFlagService: FeatureFlagService, + private val repository: HistoryVisibleAcknowledgementRepository, + private val room: JoinedRoom, +) : Presenter { + @Composable + override fun present(): HistoryVisibleState { + val isFeatureEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.EnableKeyShareOnInvite).collectAsState(initial = false) + val roomInfo by room.roomInfoFlow.collectAsState() + // Implicitly assume the alert is initially acknowledged to avoid flashes in UI. + val acknowledged by repository.hasAcknowledged(room.roomId).collectAsState(initial = true) + + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(roomInfo.historyVisibility, acknowledged) { + if (roomInfo.historyVisibility == RoomHistoryVisibility.Joined && acknowledged) { + repository.setAcknowledged(room.roomId, false) + } + } + + fun handleEvent(event: HistoryVisibleEvent) { + when (event) { + is HistoryVisibleEvent.Acknowledge -> coroutineScope.setAcknowledged(room.roomId, true) + } + } + + return HistoryVisibleState( + showAlert = isFeatureEnabled && roomInfo.historyVisibility != RoomHistoryVisibility.Joined && roomInfo.isEncrypted == true && !acknowledged, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.setAcknowledged(roomId: RoomId, value: Boolean) = launch { + repository.setAcknowledged(roomId, value) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStateProvider.kt new file mode 100644 index 0000000000..752abdc76b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStateProvider.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.historyvisible + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +class HistoryVisibleStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aHistoryVisibleState(showAlert = true), + ) +} + +internal fun aHistoryVisibleState( + showAlert: Boolean = false, + eventSink: (HistoryVisibleEvent) -> Unit = {}, +) = HistoryVisibleState( + showAlert, + eventSink = eventSink, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStateView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStateView.kt new file mode 100644 index 0000000000..9c08c9d101 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStateView.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.historyvisible + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.appconfig.LearnMoreConfig +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertLevel +import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun HistoryVisibleStateView( + state: HistoryVisibleState, + onLinkClick: (String, Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + if (!state.showAlert) { + return + } + + ComposerAlertMolecule( + modifier = modifier, + avatar = null, + showIcon = true, + level = ComposerAlertLevel.Info, + content = buildAnnotatedString { + val learnMoreStr = stringResource(CommonStrings.action_learn_more) + val fullText = stringResource(CommonStrings.crypto_history_visible, learnMoreStr) + append(fullText) + val learnMoreStartIndex = fullText.lastIndexOf(learnMoreStr) + addStyle( + style = SpanStyle( + textDecoration = TextDecoration.Underline, + fontWeight = FontWeight.Bold, + color = ElementTheme.colors.textPrimary + ), + start = learnMoreStartIndex, + end = learnMoreStartIndex + learnMoreStr.length, + ) + addLink( + url = LinkAnnotation.Url( + url = LearnMoreConfig.HISTORY_VISIBLE_URL, + linkInteractionListener = { + onLinkClick(LearnMoreConfig.HISTORY_VISIBLE_URL, true) + } + ), + start = learnMoreStartIndex, + end = learnMoreStartIndex + learnMoreStr.length, + ) + }, + submitText = stringResource(CommonStrings.action_dismiss), + onSubmitClick = { state.eventSink(HistoryVisibleEvent.Acknowledge) }, + ) +} + +@PreviewsDayNight +@Composable +internal fun HistoryVisibleStateViewPreview( + @PreviewParameter(HistoryVisibleStateProvider::class) state: HistoryVisibleState, +) = ElementPreview { + HistoryVisibleStateView( + state = state, + onLinkClick = { _, _ -> }, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/MessagesViewWithHistoryVisiblePreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/MessagesViewWithHistoryVisiblePreview.kt new file mode 100644 index 0000000000..07cf5170d3 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/MessagesViewWithHistoryVisiblePreview.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.historyvisible + +import androidx.compose.runtime.Composable +import io.element.android.features.messages.impl.MessagesView +import io.element.android.features.messages.impl.aMessagesState +import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown + +@PreviewsDayNight +@Composable +internal fun MessagesViewWithHistoryVisiblePreview() = ElementPreview { + MessagesView( + state = aMessagesState( + composerState = aMessageComposerState( + textEditorState = aTextEditorStateMarkdown( + initialText = "", + initialFocus = false, + ) + ), + historyVisibleState = aHistoryVisibleState(showAlert = true), + ), + onBackClick = {}, + onRoomDetailsClick = {}, + onEventContentClick = { _, _ -> false }, + onUserDataClick = {}, + onLinkClick = { _, _ -> }, + onSendLocationClick = {}, + onCreatePollClick = {}, + onJoinCallClick = {}, + onViewAllPinnedMessagesClick = {}, + knockRequestsBannerView = {} + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindsModule.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindsModule.kt index a345e09fa2..a88dbb1b49 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindsModule.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindsModule.kt @@ -11,6 +11,8 @@ package io.element.android.features.messages.impl.di import dev.zacsweers.metro.BindingContainer import dev.zacsweers.metro.Binds import dev.zacsweers.metro.ContributesTo +import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleState +import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleStatePresenter import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStatePresenter import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailurePresenter @@ -61,4 +63,7 @@ interface MessagesBindsModule { @Binds fun bindIdentityChangeStatePresenter(presenter: IdentityChangeStatePresenter): Presenter + + @Binds + fun bindHistoryVisibleStatePresenter(presenter: HistoryVisibleStatePresenter): Presenter } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 852e2504b2..de2c8a81c9 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -17,6 +17,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.anActionListState import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.crypto.historyvisible.aHistoryVisibleState import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState import io.element.android.features.messages.impl.fixtures.aMessageEvent import io.element.android.features.messages.impl.link.aLinkState @@ -1355,6 +1356,7 @@ class MessagesPresenterTest { timelinePresenter = { aTimelineState(eventSink = timelineEventSink) }, timelineProtectionPresenter = { aTimelineProtectionState() }, identityChangeStatePresenter = { anIdentityChangeState() }, + historyVisibleStatePresenter = { aHistoryVisibleState() }, linkPresenter = { aLinkState() }, actionListPresenter = { anActionListState(eventSink = actionListEventSink) }, customReactionPresenter = { aCustomReactionState() }, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/FakeHistoryVisibleAcknowledgementRepository.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/FakeHistoryVisibleAcknowledgementRepository.kt new file mode 100644 index 0000000000..faf21720fa --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/FakeHistoryVisibleAcknowledgementRepository.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.historyvisible + +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class FakeHistoryVisibleAcknowledgementRepository( + private val acknowledgements: MutableMap> = mutableMapOf() +) : HistoryVisibleAcknowledgementRepository { + override fun hasAcknowledged(roomId: RoomId): Flow { + return acknowledgements.getOrPut(roomId) { + MutableStateFlow(false) + } + } + + override suspend fun setAcknowledged(roomId: RoomId, value: Boolean) { + val flow = acknowledgements.getOrPut(roomId) { + MutableStateFlow(value) + } + flow.emit(value) + } + + companion object { + /** + * Create the repository with a pre-existing entry. + */ + fun withRoom(roomId: RoomId, acknowledged: Boolean = false): FakeHistoryVisibleAcknowledgementRepository { + return FakeHistoryVisibleAcknowledgementRepository( + mutableMapOf( + roomId to MutableStateFlow(acknowledged) + ) + ) + } + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStatePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStatePresenterTest.kt new file mode 100644 index 0000000000..b6619851e6 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStatePresenterTest.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.crypto.historyvisible + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.awaitLastSequentialItem +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class HistoryVisibleStatePresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - not visible if feature disabled`() = runTest { + val room = FakeJoinedRoom() + room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Joined, isEncrypted = true)) + val presenter = createHistoryVisibleStatePresenter(room, enabled = false, acknowledged = false) + presenter.test { + assertThat(awaitLastSequentialItem().showAlert).isFalse() + } + } + + @Test + fun `present - initial with room shared, unencrypted`() = runTest { + val room = FakeJoinedRoom() + room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, isEncrypted = false)) + val presenter = createHistoryVisibleStatePresenter(room) + presenter.test { + assertThat(awaitLastSequentialItem().showAlert).isFalse() + } + } + + @Test + fun `present - initial with room joined, encrypted`() = runTest { + val room = FakeJoinedRoom() + room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Joined, isEncrypted = false)) + val presenter = createHistoryVisibleStatePresenter(room) + presenter.test { + assertThat(awaitLastSequentialItem().showAlert).isFalse() + } + } + + @Test + fun `present - initial with room shared, encrypted, unacknowledged`() = runTest { + val room = FakeJoinedRoom() + room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, isEncrypted = true)) + val presenter = createHistoryVisibleStatePresenter(room, acknowledged = false) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.showAlert).isFalse() + val nextState = awaitItem() + assertThat(nextState.showAlert).isTrue() + } + } + + @Test + fun `present - initial with room shared, encrypted, acknowledged`() = runTest { + val room = FakeJoinedRoom() + room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, isEncrypted = true)) + val presenter = createHistoryVisibleStatePresenter(room, acknowledged = true) + presenter.test { + assertThat(awaitLastSequentialItem().showAlert).isFalse() + } + } + + @Test + fun `present - transition from joined + unencrypted, to shared + encrypted`() = runTest { + val room = FakeJoinedRoom() + val featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.EnableKeyShareOnInvite.key to true)) + val repository = FakeHistoryVisibleAcknowledgementRepository() + + room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Joined, isEncrypted = false)) + + val presenter = HistoryVisibleStatePresenter( + featureFlagService, + repository, + room, + ) + + presenter.test { + // emitted by the feature flag service(?) + assertThat(awaitItem().showAlert).isFalse() + + // emitted state from room info assignment + assertThat(awaitItem().showAlert).isFalse() + + // room is marked as encrypted + room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Joined, isEncrypted = true)) + assertThat(awaitItem().showAlert).isFalse() + + // room history visibility is changed to shared + room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, isEncrypted = true)) + assertThat(awaitItem().showAlert).isTrue() + + // alert is acknowledged + repository.setAcknowledged(room.roomId, true) + assertThat(awaitItem().showAlert).isFalse() + } + } + + private fun createHistoryVisibleStatePresenter( + room: JoinedRoom = FakeJoinedRoom(), + enabled: Boolean = true, + acknowledged: Boolean = false + ): HistoryVisibleStatePresenter { + return HistoryVisibleStatePresenter( + room = room, + featureFlagService = FakeFeatureFlagService(mapOf("feature.enableKeyShareOnInvite" to enabled)), + repository = FakeHistoryVisibleAcknowledgementRepository.withRoom(room.roomId, acknowledged) + ) + } +} diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt index b4f15d8d96..32f2908cf7 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt @@ -97,6 +97,7 @@ class KonsistPreviewTest { "MessageComposerViewVoicePreview", "MessagesReactionButtonAddPreview", "MessagesReactionButtonExtraPreview", + "MessagesViewWithHistoryVisiblePreview", "MessagesViewWithIdentityChangePreview", "PendingMemberRowWithLongNamePreview", "PinUnlockViewInAppPreview", diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.historyvisible_HistoryVisibleStateView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.historyvisible_HistoryVisibleStateView_Day_0_en.png new file mode 100644 index 0000000000..d19a140456 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.historyvisible_HistoryVisibleStateView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:96bc0c555188cee4b3f3e3e0f28f944a4225e27b9a1069edf2b10a2993ee3080 +size 26078 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.historyvisible_HistoryVisibleStateView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.historyvisible_HistoryVisibleStateView_Day_1_en.png new file mode 100644 index 0000000000..d19a140456 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.historyvisible_HistoryVisibleStateView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:96bc0c555188cee4b3f3e3e0f28f944a4225e27b9a1069edf2b10a2993ee3080 +size 26078 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.historyvisible_HistoryVisibleStateView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.historyvisible_HistoryVisibleStateView_Night_0_en.png new file mode 100644 index 0000000000..d21a12dd64 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.historyvisible_HistoryVisibleStateView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a103daf8447cb571a0c5010d062a7790aaa0f3dce30f052bf86884708f4881a5 +size 28769 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.historyvisible_HistoryVisibleStateView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.historyvisible_HistoryVisibleStateView_Night_1_en.png new file mode 100644 index 0000000000..d21a12dd64 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.historyvisible_HistoryVisibleStateView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a103daf8447cb571a0c5010d062a7790aaa0f3dce30f052bf86884708f4881a5 +size 28769 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.historyvisible_MessagesViewWithHistoryVisible_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.historyvisible_MessagesViewWithHistoryVisible_Day_0_en.png new file mode 100644 index 0000000000..22d05e61fa --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.historyvisible_MessagesViewWithHistoryVisible_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9682cdd7e08e32591fa47f56da5e6ef4cc3a777700931c2c86e9b802a9cf25cb +size 67342 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.historyvisible_MessagesViewWithHistoryVisible_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.historyvisible_MessagesViewWithHistoryVisible_Day_1_en.png new file mode 100644 index 0000000000..22d05e61fa --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.historyvisible_MessagesViewWithHistoryVisible_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9682cdd7e08e32591fa47f56da5e6ef4cc3a777700931c2c86e9b802a9cf25cb +size 67342 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.historyvisible_MessagesViewWithHistoryVisible_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.historyvisible_MessagesViewWithHistoryVisible_Night_0_en.png new file mode 100644 index 0000000000..b2bb8ac3fc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.historyvisible_MessagesViewWithHistoryVisible_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d20c04c6385e675bd71d93d2d5d832509b1300a27708245755f263392b33bc8a +size 69457 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.historyvisible_MessagesViewWithHistoryVisible_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.historyvisible_MessagesViewWithHistoryVisible_Night_1_en.png new file mode 100644 index 0000000000..b2bb8ac3fc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.crypto.historyvisible_MessagesViewWithHistoryVisible_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d20c04c6385e675bd71d93d2d5d832509b1300a27708245755f263392b33bc8a +size 69457 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_10_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_10_en.png new file mode 100644 index 0000000000..826ba15d61 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_10_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e0581c343abffc4a71db827cdc5f8b183525daed8b17b80cd6b511cb70ae9a05 +size 66428 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_11_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_11_en.png new file mode 100644 index 0000000000..ca2c69a01b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_11_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:97065ad20dc15bad0e6be815df8a5bd8b0411d11e7189e50a7abd13438f60f4b +size 69292 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_12_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_12_en.png new file mode 100644 index 0000000000..826ba15d61 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_12_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e0581c343abffc4a71db827cdc5f8b183525daed8b17b80cd6b511cb70ae9a05 +size 66428 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_10_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_10_en.png new file mode 100644 index 0000000000..57fac247d9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_10_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5d618df3faa09281e59897b7f478d12f5a0d907b47f58747607e1de8cbc6e61 +size 68146 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_11_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_11_en.png new file mode 100644 index 0000000000..34738a39fb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_11_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f65782549d9ae258584413de54a016705b705f05fd653b48c302b62dc3cd0c71 +size 70718 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_12_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_12_en.png new file mode 100644 index 0000000000..57fac247d9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_12_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5d618df3faa09281e59897b7f478d12f5a0d907b47f58747607e1de8cbc6e61 +size 68146