Hide encryption history + FTUE flow (#839)

* First attempt at implementing encrypted history banner and removing old UTDs

* Get the right behavior in the timeline

* Implement the designs

* Extract post-processing logic, add tests

* Add encryption banner to timeline screenshots

* Create FTUE feature to handle welcome screen and analytics

* Move classes to their own packages, add tests for `DefaultFtueState`.

* Remove unnecessary private MutableStateFlow

* Move some FTUE related methods and classes back to the `impl` module

* Handle back press at each FTUE step

* Remove unneeded `TestScope` receiver for `createState` in tests.

* Use light & dark previews for the banner view.

* Move color customization from `TextStyle` to `Text` component.

* Rename `InfoList` design components, use them in `AnalyticsOptInView` too.

* Cleanup MatrixClient.

* Fix copy&paste error

Co-authored-by: Benoit Marty <benoit@matrix.org>

* Fix typo

* Fix Maestro tests

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
Co-authored-by: Benoit Marty <benoit@matrix.org>
This commit is contained in:
Jorge Martin Espinosa 2023-07-17 18:34:36 +02:00 committed by GitHub
parent b42343fd3c
commit 2488432805
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
62 changed files with 1714 additions and 123 deletions

View file

@ -157,6 +157,7 @@ class RustMatrixClient constructor(
coroutineDispatchers = dispatchers,
systemClock = clock,
roomContentForwarder = roomContentForwarder,
sessionData = sessionStore.getSession(sessionId.value)!!,
)
}

View file

@ -45,6 +45,7 @@ import org.matrix.rustcomponents.sdk.ClientBuilder
import org.matrix.rustcomponents.sdk.Session
import org.matrix.rustcomponents.sdk.use
import java.io.File
import java.util.Date
import javax.inject.Inject
import org.matrix.rustcomponents.sdk.AuthenticationService as RustAuthenticationService
@ -208,4 +209,5 @@ private fun Session.toSessionData() = SessionData(
refreshToken = refreshToken,
homeserverUrl = homeserverUrl,
slidingSyncProxy = slidingSyncProxy,
loginTimestamp = Date(),
)

View file

@ -42,6 +42,8 @@ import io.element.android.libraries.matrix.impl.room.location.toInner
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import io.element.android.libraries.matrix.impl.timeline.backPaginationStatusFlow
import io.element.android.libraries.matrix.impl.timeline.timelineDiffFlow
import io.element.android.libraries.sessionstorage.api.SessionData
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -73,6 +75,7 @@ class RustMatrixRoom(
private val coroutineDispatchers: CoroutineDispatchers,
private val systemClock: SystemClock,
private val roomContentForwarder: RoomContentForwarder,
private val sessionData: SessionData,
) : MatrixRoom {
override val roomId = RoomId(innerRoom.id())
@ -91,7 +94,8 @@ class RustMatrixRoom(
matrixRoom = this,
innerRoom = innerRoom,
roomCoroutineScope = roomCoroutineScope,
dispatcher = roomDispatcher
dispatcher = roomDispatcher,
lastLoginTimestamp = sessionData.loginTimestamp,
)
}

View file

@ -21,19 +21,23 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessageMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper
import kotlinx.coroutines.CompletableDeferred
import io.element.android.libraries.matrix.impl.timeline.postprocessor.TimelineEncryptedHistoryPostProcessor
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.BackPaginationStatus
@ -43,6 +47,7 @@ import org.matrix.rustcomponents.sdk.TimelineDiff
import org.matrix.rustcomponents.sdk.TimelineItem
import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean
import java.util.Date
private const val INITIAL_MAX_SIZE = 50
@ -51,6 +56,7 @@ class RustMatrixTimeline(
private val matrixRoom: MatrixRoom,
private val innerRoom: Room,
private val dispatcher: CoroutineDispatcher,
private val lastLoginTimestamp: Date?,
) : MatrixTimeline {
private val initLatch = CompletableDeferred<Unit>()
@ -63,6 +69,12 @@ class RustMatrixTimeline(
MatrixTimeline.PaginationState(hasMoreToLoadBackwards = true, isBackPaginating = false)
)
private val encryptedHistoryPostProcessor = TimelineEncryptedHistoryPostProcessor(
lastLoginTimestamp = lastLoginTimestamp,
isRoomEncrypted = matrixRoom.isEncrypted,
paginationStateFlow = _paginationState,
)
private val timelineItemFactory = MatrixTimelineItemMapper(
fetchDetailsForEvent = this::fetchDetailsForEvent,
roomCoroutineScope = roomCoroutineScope,
@ -81,8 +93,11 @@ class RustMatrixTimeline(
override val paginationState: StateFlow<MatrixTimeline.PaginationState> = _paginationState.asStateFlow()
@OptIn(FlowPreview::class)
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
override val timelineItems: Flow<List<MatrixTimelineItem>> = _timelineItems.sample(50)
.mapLatest { items ->
encryptedHistoryPostProcessor.process(items)
}
internal suspend fun postItems(items: List<TimelineItem>) {
// Split the initial items in multiple list as there is no pagination in the cached data, so we can post timelineItems asap.
@ -100,6 +115,12 @@ class RustMatrixTimeline(
internal fun postPaginationStatus(status: BackPaginationStatus) {
_paginationState.getAndUpdate { currentPaginationState ->
if (hasEncryptionHistoryBanner()) {
return@getAndUpdate currentPaginationState.copy(
isBackPaginating = false,
hasMoreToLoadBackwards = false,
)
}
when (status) {
BackPaginationStatus.IDLE -> {
currentPaginationState.copy(
@ -159,4 +180,10 @@ class RustMatrixTimeline(
fun getItemById(eventId: EventId): MatrixTimelineItem.Event? {
return _timelineItems.value.firstOrNull { (it as? MatrixTimelineItem.Event)?.eventId == eventId } as? MatrixTimelineItem.Event
}
private fun hasEncryptionHistoryBanner(): Boolean {
val firstItem = _timelineItems.value.firstOrNull()
return firstItem is MatrixTimelineItem.Virtual
&& firstItem.virtual is VirtualTimelineItem.EncryptedHistoryBanner
}
}

View file

@ -0,0 +1,74 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.matrix.impl.timeline.postprocessor
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.getAndUpdate
import java.util.Date
import java.util.UUID
class TimelineEncryptedHistoryPostProcessor(
private val lastLoginTimestamp: Date?,
private val isRoomEncrypted: Boolean,
private val paginationStateFlow: MutableStateFlow<MatrixTimeline.PaginationState>,
) {
fun process(items: List<MatrixTimelineItem>): List<MatrixTimelineItem> {
if (!isRoomEncrypted || lastLoginTimestamp == null) return items
val filteredItems = replaceWithEncryptionHistoryBannerIfNeeded(items)
// Disable back pagination
val wasFiltered = filteredItems !== items
if (wasFiltered) {
paginationStateFlow.getAndUpdate {
it.copy(
isBackPaginating = false,
hasMoreToLoadBackwards = false
)
}
}
return filteredItems
}
private fun replaceWithEncryptionHistoryBannerIfNeeded(list: List<MatrixTimelineItem>): List<MatrixTimelineItem> {
var lastEncryptedHistoryBannerIndex = -1
for ((i, item) in list.withIndex()) {
if (isItemEncryptionHistory(item)) {
lastEncryptedHistoryBannerIndex = i
}
}
return if (lastEncryptedHistoryBannerIndex >= 0) {
val sublist = list.drop(lastEncryptedHistoryBannerIndex + 1).toMutableList()
sublist.add(0, MatrixTimelineItem.Virtual(0L, VirtualTimelineItem.EncryptedHistoryBanner))
sublist
} else {
list
}
}
private fun isItemEncryptionHistory(item: MatrixTimelineItem): Boolean {
if ((item as? MatrixTimelineItem.Virtual)?.virtual is VirtualTimelineItem.EncryptedHistoryBanner) {
return true
}
val timestamp = (item as? MatrixTimelineItem.Event)?.event?.timestamp ?: return false
return timestamp <= lastLoginTimestamp!!.time
}
}

View file

@ -0,0 +1,115 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.matrix.impl.timeline.postprocessor
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import io.element.android.libraries.matrix.test.room.anEventTimelineItem
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Test
import java.util.Date
class TimelineEncryptedHistoryPostProcessorTest {
private val defaultLastLoginTimestamp = Date(1689061264L)
@Test
fun `given an unencrypted room, nothing is done`() {
val processor = createPostProcessor(isRoomEncrypted = false)
val items = listOf(
MatrixTimelineItem.Event(0L, anEventTimelineItem())
)
assertThat(processor.process(items)).isSameInstanceAs(items)
}
@Test
fun `given a null lastLoginTimestamp, nothing is done`() {
val processor = createPostProcessor(lastLoginTimestamp = null)
val items = listOf(
MatrixTimelineItem.Event(0L, anEventTimelineItem())
)
assertThat(processor.process(items)).isSameInstanceAs(items)
}
@Test
fun `given an empty list, nothing is done`() {
val processor = createPostProcessor()
val items = emptyList<MatrixTimelineItem>()
assertThat(processor.process(items)).isSameInstanceAs(items)
}
@Test
fun `given a list with no items before lastLoginTimestamp, nothing is done`() {
val processor = createPostProcessor()
val items = listOf(
MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time + 1))
)
assertThat(processor.process(items)).isSameInstanceAs(items)
}
@Test
fun `given a list with an item with equal timestamp as lastLoginTimestamp, it's replaced`() {
val processor = createPostProcessor()
val items = listOf(
MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time))
)
assertThat(processor.process(items))
.isEqualTo(listOf(MatrixTimelineItem.Virtual(0L, VirtualTimelineItem.EncryptedHistoryBanner)))
}
@Test
fun `given a list with an item with a lower timestamp than lastLoginTimestamp, it's replaced`() {
val processor = createPostProcessor()
val items = listOf(
MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time - 1))
)
assertThat(processor.process(items)).isEqualTo(
listOf(MatrixTimelineItem.Virtual(0L, VirtualTimelineItem.EncryptedHistoryBanner))
)
}
@Test
fun `given a list with several with lower or equal timestamps than lastLoginTimestamp, they're replaced and the user can't back paginate`() {
val paginationStateFlow = MutableStateFlow(MatrixTimeline.PaginationState(hasMoreToLoadBackwards = true, isBackPaginating = false))
val processor = createPostProcessor(paginationStateFlow = paginationStateFlow)
val items = listOf(
MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time - 1)),
MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time)),
MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time + 1)),
)
assertThat(processor.process(items)).isEqualTo(
listOf(
MatrixTimelineItem.Virtual(0L, VirtualTimelineItem.EncryptedHistoryBanner),
MatrixTimelineItem.Event(0L, anEventTimelineItem(timestamp = defaultLastLoginTimestamp.time + 1))
)
)
assertThat(paginationStateFlow.value).isEqualTo(MatrixTimeline.PaginationState(hasMoreToLoadBackwards = false, isBackPaginating = false))
}
private fun createPostProcessor(
lastLoginTimestamp: Date? = defaultLastLoginTimestamp,
isRoomEncrypted: Boolean = true,
paginationStateFlow: MutableStateFlow<MatrixTimeline.PaginationState> =
MutableStateFlow(MatrixTimeline.PaginationState(hasMoreToLoadBackwards = true, isBackPaginating = false))
) = TimelineEncryptedHistoryPostProcessor(
lastLoginTimestamp = lastLoginTimestamp,
isRoomEncrypted = isRoomEncrypted,
paginationStateFlow = paginationStateFlow,
)
}