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

@ -23,6 +23,8 @@ appId: ${APP_ID}
- inputText: ${PASSWORD}
- pressKey: Enter
- tapOn: "Continue"
- runFlow: ../assertions/assertWelcomeScreenDisplayed.yaml
- tapOn: "Continue"
- runFlow: ../assertions/assertAnalyticsDisplayed.yaml
- tapOn: "Not now"
- runFlow: ../assertions/assertHomeDisplayed.yaml

View file

@ -0,0 +1,6 @@
appId: ${APP_ID}
---
- extendedWaitUntil:
visible:
id: "welcome_screen-title"
timeout: 10_000

View file

@ -54,6 +54,8 @@ dependencies {
implementation(projects.tests.uitests)
implementation(libs.coil)
implementation(projects.features.ftue.api)
implementation(projects.services.apperror.impl)
implementation(projects.services.appnavstate.api)
implementation(projects.services.analytics.api)

View file

@ -19,6 +19,8 @@ package io.element.android.appnav
import android.os.Parcelable
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
@ -41,7 +43,6 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.appnav.loggedin.LoggedInNode
import io.element.android.appnav.room.RoomFlowNode
import io.element.android.appnav.room.RoomLoadedFlowNode
import io.element.android.features.analytics.api.AnalyticsEntryPoint
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.invitelist.api.InviteListEntryPoint
import io.element.android.features.networkmonitor.api.NetworkMonitor
@ -49,6 +50,8 @@ import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.roomlist.api.RoomListEntryPoint
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
@ -64,13 +67,10 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.ui.di.MatrixUIBindings
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@ -81,14 +81,14 @@ class LoggedInFlowNode @AssistedInject constructor(
private val roomListEntryPoint: RoomListEntryPoint,
private val preferencesEntryPoint: PreferencesEntryPoint,
private val createRoomEntryPoint: CreateRoomEntryPoint,
private val analyticsOptInEntryPoint: AnalyticsEntryPoint,
private val appNavigationStateService: AppNavigationStateService,
private val verifySessionEntryPoint: VerifySessionEntryPoint,
private val inviteListEntryPoint: InviteListEntryPoint,
private val analyticsService: AnalyticsService,
private val ftueEntryPoint: FtueEntryPoint,
private val coroutineScope: CoroutineScope,
private val networkMonitor: NetworkMonitor,
private val notificationDrawerManager: NotificationDrawerManager,
private val ftueState: FtueState,
snackbarDispatcher: SnackbarDispatcher,
) : BackstackNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack(
@ -99,19 +99,6 @@ class LoggedInFlowNode @AssistedInject constructor(
plugins = plugins
) {
private fun observeAnalyticsState() {
analyticsService.didAskUserConsent()
.distinctUntilChanged()
.onEach { isConsentAsked ->
if (isConsentAsked) {
backstack.removeLast(NavTarget.AnalyticsOptIn)
} else {
backstack.push(NavTarget.AnalyticsOptIn)
}
}
.launchIn(lifecycleScope)
}
interface Callback : Plugin {
fun onOpenBugReport() = Unit
}
@ -136,7 +123,7 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun onBuilt() {
super.onBuilt()
observeAnalyticsState()
lifecycle.subscribe(
onCreate = {
plugins<LifecycleCallback>().forEach { it.onFlowCreated(id, inputs.matrixClient) }
@ -146,6 +133,10 @@ class LoggedInFlowNode @AssistedInject constructor(
// TODO We do not support Space yet, so directly navigate to main space
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
loggedInFlowProcessor.observeEvents(coroutineScope)
if (ftueState.shouldDisplayFlow.value) {
backstack.push(NavTarget.Ftue)
}
},
onResume = {
syncService.startSync()
@ -209,7 +200,7 @@ class LoggedInFlowNode @AssistedInject constructor(
object InviteList : NavTarget
@Parcelize
object AnalyticsOptIn : NavTarget
object Ftue : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -306,8 +297,13 @@ class LoggedInFlowNode @AssistedInject constructor(
.callback(callback)
.build()
}
NavTarget.AnalyticsOptIn -> {
analyticsOptInEntryPoint.createNode(this, buildContext)
NavTarget.Ftue -> {
ftueEntryPoint.nodeBuilder(this, buildContext)
.callback(object : FtueEntryPoint.Callback {
override fun onFtueFlowFinished() {
backstack.pop()
}
}).build()
}
}
}
@ -335,7 +331,11 @@ class LoggedInFlowNode @AssistedInject constructor(
transitionHandler = rememberDefaultTransitionHandler(),
)
PermanentChild(navTarget = NavTarget.Permanent)
val isFtueDisplayed by ftueState.shouldDisplayFlow.collectAsState()
if (!isFtueDisplayed) {
PermanentChild(navTarget = NavTarget.Permanent)
}
}
}

View file

@ -246,7 +246,7 @@ koverMerged {
name = "Check code coverage of states"
target = kotlinx.kover.api.VerificationTarget.CLASS
overrideClassFilter {
includes += "*State"
includes += "^*State$"
excludes += "io.element.android.libraries.matrix.api.timeline.item.event.OtherState$*"
excludes += "io.element.android.libraries.matrix.api.timeline.item.event.EventSendState$*"
excludes += "io.element.android.libraries.matrix.api.room.RoomMembershipState*"
@ -262,6 +262,8 @@ koverMerged {
excludes += "io.element.android.libraries.maplibre.compose.CameraPositionState*"
excludes += "io.element.android.libraries.maplibre.compose.SaveableCameraPositionState"
excludes += "io.element.android.libraries.maplibre.compose.SymbolState*"
excludes += "io.element.android.features.ftue.api.state.*"
excludes += "io.element.android.features.ftue.impl.welcome.state.*"
}
bound {
minValue = 90

View file

@ -16,12 +16,11 @@
package io.element.android.features.analytics.impl
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
@ -48,6 +47,8 @@ import androidx.compose.ui.unit.dp
import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.molecules.InfoListItem
import io.element.android.libraries.designsystem.atomic.molecules.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@ -60,6 +61,7 @@ import io.element.android.libraries.designsystem.theme.temporaryColorBgSpecial
import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf
@Composable
fun AnalyticsOptInView(
@ -69,6 +71,16 @@ fun AnalyticsOptInView(
) {
LogCompositions(tag = "Analytics", msg = "Root")
val eventSink = state.eventSink
fun onTermsAccepted() {
eventSink(AnalyticsOptInEvents.EnableAnalytics(true))
}
fun onTermsDeclined() {
eventSink(AnalyticsOptInEvents.EnableAnalytics(false))
}
BackHandler(onBack = ::onTermsDeclined)
HeaderFooterPage(
modifier = modifier
.fillMaxSize()
@ -76,7 +88,13 @@ fun AnalyticsOptInView(
.imePadding(),
header = { AnalyticsOptInHeader(state, onClickTerms) },
content = { AnalyticsOptInContent() },
footer = { AnalyticsOptInFooter(eventSink) })
footer = {
AnalyticsOptInFooter(
onTermsAccepted = ::onTermsAccepted,
onTermsDeclined = ::onTermsDeclined,
)
}
)
}
@Composable
@ -114,6 +132,19 @@ private fun AnalyticsOptInHeader(
}
}
@Composable
private fun CheckIcon(modifier: Modifier = Modifier) {
Icon(
modifier = Modifier
.size(20.dp)
.background(color = MaterialTheme.colorScheme.background, shape = CircleShape)
.padding(2.dp),
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = ElementTheme.colors.textActionAccent,
)
}
@Composable
private fun AnalyticsOptInContent(
modifier: Modifier = Modifier,
@ -125,80 +156,45 @@ private fun AnalyticsOptInContent(
verticalBias = -0.4f
)
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
AnalyticsOptInContentRow(
text = stringResource(id = R.string.screen_analytics_prompt_data_usage),
idx = 0
)
AnalyticsOptInContentRow(
text = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing),
idx = 1
)
AnalyticsOptInContentRow(
text = stringResource(id = R.string.screen_analytics_prompt_settings),
idx = 2
)
}
}
}
@Composable
private fun AnalyticsOptInContentRow(
text: String,
idx: Int,
modifier: Modifier = Modifier,
) {
val radius = 14.dp
val bgShape = when (idx) {
0 -> RoundedCornerShape(topStart = radius, topEnd = radius)
2 -> RoundedCornerShape(bottomStart = radius, bottomEnd = radius)
else -> RoundedCornerShape(0.dp)
}
Row(
modifier = modifier
.fillMaxWidth()
.background(
color = ElementTheme.colors.temporaryColorBgSpecial,
shape = bgShape,
)
.padding(vertical = 12.dp, horizontal = 20.dp),
) {
Icon(
modifier = Modifier
.size(20.dp)
.background(color = MaterialTheme.colorScheme.background, shape = CircleShape)
.padding(2.dp),
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = ElementTheme.colors.textActionAccent,
)
Text(
modifier = Modifier.padding(start = 16.dp),
text = text,
style = ElementTheme.typography.fontBodyMdMedium,
color = MaterialTheme.colorScheme.primary,
InfoListOrganism(
items = persistentListOf(
InfoListItem(
message = stringResource(id = R.string.screen_analytics_prompt_data_usage),
iconComposable = { CheckIcon() },
),
InfoListItem(
message = stringResource(id = R.string.screen_analytics_prompt_third_party_sharing),
iconComposable = { CheckIcon() },
),
InfoListItem(
message = stringResource(id = R.string.screen_analytics_prompt_settings),
iconComposable = { CheckIcon() },
),
),
textStyle = ElementTheme.typography.fontBodyMdMedium,
iconTint = ElementTheme.colors.textPrimary,
backgroundColor = ElementTheme.colors.temporaryColorBgSpecial
)
}
}
@Composable
private fun AnalyticsOptInFooter(
eventSink: (AnalyticsOptInEvents) -> Unit,
onTermsAccepted: () -> Unit,
onTermsDeclined: () -> Unit,
modifier: Modifier = Modifier,
) {
ButtonColumnMolecule(
modifier = modifier,
) {
Button(
onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(true)) },
onClick = onTermsAccepted,
modifier = Modifier.fillMaxWidth(),
) {
Text(text = stringResource(id = CommonStrings.action_ok))
}
TextButton(
onClick = { eventSink(AnalyticsOptInEvents.EnableAnalytics(false)) },
onClick = onTermsDeclined,
modifier = Modifier.fillMaxWidth(),
) {
Text(text = stringResource(id = CommonStrings.action_not_now))

View file

@ -0,0 +1,27 @@
/*
* 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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.ftue.api"
}
dependencies {
implementation(projects.libraries.architecture)
}

View file

@ -0,0 +1,36 @@
/*
* 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.features.ftue.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
interface FtueEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun callback(callback: Callback): NodeBuilder
fun build(): Node
}
interface Callback : Plugin {
fun onFtueFlowFinished()
}
}

View file

@ -0,0 +1,23 @@
/*
* 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.features.ftue.api.state
import kotlinx.coroutines.flow.StateFlow
interface FtueState {
val shouldDisplayFlow: StateFlow<Boolean>
}

View file

@ -0,0 +1,55 @@
/*
* 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.
*/
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
alias(libs.plugins.ksp)
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.ftue.impl"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
api(projects.features.ftue.api)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.testtags)
implementation(projects.features.analytics.api)
implementation(projects.services.analytics.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.analytics.test)
ksp(libs.showkase.processor)
}

View file

@ -0,0 +1,46 @@
/*
* 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.features.ftue.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultFtueEntryPoint @Inject constructor() : FtueEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): FtueEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : FtueEntryPoint.NodeBuilder {
override fun callback(callback: FtueEntryPoint.Callback): FtueEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<FtueFlowNode>(buildContext, plugins)
}
}
}
}

View file

@ -0,0 +1,154 @@
/*
* 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.features.ftue.impl
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.navigation.backpresshandlerstrategies.BaseBackPressHandlerStrategy
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot
import com.bumble.appyx.navmodel.backstack.operation.replace
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.analytics.api.AnalyticsEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.impl.state.DefaultFtueState
import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.ftue.impl.welcome.WelcomeNode
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class)
class FtueFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val ftueState: DefaultFtueState,
private val analyticsEntryPoint: AnalyticsEntryPoint,
private val analyticsService: AnalyticsService,
) : BackstackNode<FtueFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Placeholder,
savedStateMap = buildContext.savedStateMap,
backPressHandler = NoOpBackstackHandlerStrategy<NavTarget>(),
),
buildContext = buildContext,
plugins = plugins,
) {
sealed interface NavTarget : Parcelable {
@Parcelize
object Placeholder : NavTarget
@Parcelize
object WelcomeScreen : NavTarget
@Parcelize
object AnalyticsOptIn : NavTarget
}
private val callback = plugins.filterIsInstance<FtueEntryPoint.Callback>().firstOrNull()
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(onCreate = {
lifecycleScope.launch { moveToNextStep() }
})
analyticsService.didAskUserConsent()
.drop(1) // We only care about consent passing from not asked to asked state
.onEach { didAskUserConsent ->
if (didAskUserConsent) {
lifecycleScope.launch { moveToNextStep() }
}
}
.launchIn(lifecycleScope)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Placeholder -> {
createNode<PlaceholderNode>(buildContext)
}
NavTarget.WelcomeScreen -> {
val callback = object : WelcomeNode.Callback {
override fun onContinueClicked() {
ftueState.setWelcomeScreenShown()
lifecycleScope.launch { moveToNextStep() }
}
}
createNode<WelcomeNode>(buildContext, listOf(callback))
}
NavTarget.AnalyticsOptIn -> {
analyticsEntryPoint.createNode(this, buildContext)
}
}
}
private suspend fun moveToNextStep() {
when (ftueState.getNextStep()) {
is FtueStep.WelcomeScreen -> {
backstack.newRoot(NavTarget.WelcomeScreen)
}
is FtueStep.AnalyticsOptIn -> {
backstack.replace(NavTarget.AnalyticsOptIn)
}
null -> callback?.onFtueFlowFinished()
}
}
@Composable
override fun View(modifier: Modifier) {
Children(
navModel = backstack,
modifier = modifier,
transitionHandler = rememberDefaultTransitionHandler(),
)
}
@ContributesNode(AppScope::class)
class PlaceholderNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : Node(buildContext, plugins = plugins)
}
private class NoOpBackstackHandlerStrategy<NavTarget : Any> : BaseBackPressHandlerStrategy<NavTarget, BackStack.State>() {
override val canHandleBackPressFlow: StateFlow<Boolean> = MutableStateFlow(true)
override fun onBackPressed() {
// No-op
}
}

View file

@ -0,0 +1,89 @@
/*
* 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.features.ftue.impl.state
import androidx.annotation.VisibleForTesting
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.ftue.impl.welcome.state.WelcomeScreenState
import io.element.android.libraries.di.AppScope
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultFtueState @Inject constructor(
private val coroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService,
private val welcomeScreenState: WelcomeScreenState,
) : FtueState {
override val shouldDisplayFlow = MutableStateFlow(isAnyStepIncomplete())
init {
analyticsService.didAskUserConsent()
.onEach { updateState() }
.launchIn(coroutineScope)
}
fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
when (currentStep) {
null -> if (shouldDisplayWelcomeScreen()) FtueStep.WelcomeScreen else getNextStep(
FtueStep.WelcomeScreen
)
FtueStep.WelcomeScreen -> if (needsAnalyticsOptIn()) FtueStep.AnalyticsOptIn else getNextStep(
FtueStep.AnalyticsOptIn
)
FtueStep.AnalyticsOptIn -> null
}
private fun isAnyStepIncomplete(): Boolean {
return listOf(
shouldDisplayWelcomeScreen(),
needsAnalyticsOptIn()
).any { it }
}
private fun needsAnalyticsOptIn(): Boolean {
// We need this function to not be suspend, so we need to load the value through runBlocking
return runBlocking { analyticsService.didAskUserConsent().first().not() }
}
private fun shouldDisplayWelcomeScreen(): Boolean {
return welcomeScreenState.isWelcomeScreenNeeded()
}
fun setWelcomeScreenShown() {
welcomeScreenState.setWelcomeScreenShown()
updateState()
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun updateState() {
shouldDisplayFlow.value = isAnyStepIncomplete()
}
}
sealed interface FtueStep {
object WelcomeScreen : FtueStep
object AnalyticsOptIn : FtueStep
}

View file

@ -0,0 +1,54 @@
/*
* 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.features.ftue.impl.welcome
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope
@ContributesNode(AppScope::class)
class WelcomeNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val buildMeta: BuildMeta,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onContinueClicked()
}
private fun onContinueClicked() {
plugins.filterIsInstance<Callback>().forEach { it.onContinueClicked() }
}
@Composable
override fun View(modifier: Modifier) {
WelcomeView(
applicationName = buildMeta.applicationName,
onContinueClicked = ::onContinueClicked,
modifier = modifier
)
}
}

View file

@ -0,0 +1,128 @@
/*
* 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.features.ftue.impl.welcome
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddComment
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.NewReleases
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.element.android.features.ftue.impl.R
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize
import io.element.android.libraries.designsystem.atomic.molecules.InfoListItem
import io.element.android.libraries.designsystem.atomic.molecules.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.persistentListOf
@Composable
fun WelcomeView(
applicationName: String,
modifier: Modifier = Modifier,
onContinueClicked: () -> Unit,
) {
BackHandler(onBack = onContinueClicked)
OnBoardingPage(
modifier = modifier
.systemBarsPadding()
.fillMaxSize(),
content = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(78.dp))
ElementLogoAtom(size = ElementLogoAtomSize.Medium)
Spacer(modifier = Modifier.height(32.dp))
Text(
modifier = Modifier.testTag(TestTags.welcomeScreenTitle),
text = stringResource(R.string.screen_welcome_title, applicationName),
style = ElementTheme.typography.fontHeadingLgBold,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.screen_welcome_subtitle),
style = ElementTheme.typography.fontBodyMdRegular,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(40.dp))
InfoListOrganism(
items = listItems(),
textStyle = ElementTheme.typography.fontBodyMdMedium,
iconTint = ElementTheme.colors.iconSecondary,
backgroundColor = ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.7f),
)
Spacer(modifier = Modifier.height(32.dp))
}
},
footer = {
Button(modifier = Modifier.fillMaxWidth(), onClick = onContinueClicked) {
Text(text = stringResource(CommonStrings.action_continue))
}
Spacer(modifier = Modifier.height(32.dp))
}
)
}
@Composable
private fun listItems() = persistentListOf(
InfoListItem(
message = stringResource(R.string.screen_welcome_bullet_1),
iconVector = Icons.Outlined.NewReleases,
),
InfoListItem(
message = stringResource(R.string.screen_welcome_bullet_2),
iconVector = Icons.Outlined.Lock,
),
InfoListItem(
message = stringResource(R.string.screen_welcome_bullet_3),
iconVector = Icons.Outlined.AddComment,
),
)
@DayNightPreviews
@Composable
internal fun WelcomeViewPreview() {
ElementPreview {
WelcomeView(applicationName = "Element X", onContinueClicked = {})
}
}

View file

@ -0,0 +1,43 @@
/*
* 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.features.ftue.impl.welcome.state
import android.content.SharedPreferences
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.DefaultPreferences
import io.element.android.libraries.di.SingleIn
import javax.inject.Inject
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class AndroidWelcomeScreenState @Inject constructor(
@DefaultPreferences private val sharedPreferences: SharedPreferences,
): WelcomeScreenState {
companion object {
private const val IS_WELCOME_SCREEN_SHOWN = "is_welcome_screen_shown"
}
override fun isWelcomeScreenNeeded(): Boolean {
return sharedPreferences.getBoolean(IS_WELCOME_SCREEN_SHOWN, false).not()
}
override fun setWelcomeScreenShown() {
sharedPreferences.edit().putBoolean(IS_WELCOME_SCREEN_SHOWN, true).apply()
}
}

View file

@ -0,0 +1,22 @@
/*
* 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.features.ftue.impl.welcome.state
interface WelcomeScreenState {
fun isWelcomeScreenNeeded(): Boolean
fun setWelcomeScreenShown()
}

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_welcome_bullet_1">"Calls, location sharing, search and more will be added later this year."</string>
<string name="screen_welcome_bullet_2">"Message history for encrypted rooms wont be available in this update."</string>
<string name="screen_welcome_bullet_3">"Wed love to hear from you, let us know what you think via the settings page."</string>
<string name="screen_welcome_button">"Let\'s go!"</string>
<string name="screen_welcome_subtitle">"Heres what you need to know:"</string>
<string name="screen_welcome_title">"Welcome to %1$s!"</string>
</resources>

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.features.ftue.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.analytics.test.FakeAnalyticsService
import io.element.android.features.ftue.impl.state.DefaultFtueState
import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.ftue.impl.welcome.state.FakeWelcomeState
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultFtueStateTests {
@Test
fun `given any check being false, should display flow is true`() = runTest {
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState(coroutineScope)
assertThat(state.shouldDisplayFlow.value).isTrue()
// Cleanup
coroutineScope.cancel()
}
@Test
fun `given all checks being true, should display flow is false`() = runTest {
val welcomeState = FakeWelcomeState()
val analyticsService = FakeAnalyticsService()
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState(coroutineScope, welcomeState, analyticsService)
welcomeState.setWelcomeScreenShown()
analyticsService.setDidAskUserConsent()
state.updateState()
assertThat(state.shouldDisplayFlow.value).isFalse()
// Cleanup
coroutineScope.cancel()
}
@Test
fun `traverse flow`() = runTest {
val welcomeState = FakeWelcomeState()
val analyticsService = FakeAnalyticsService()
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState(coroutineScope, welcomeState, analyticsService)
val steps = mutableListOf<FtueStep?>()
// First step, welcome screen
steps.add(state.getNextStep(steps.lastOrNull()))
welcomeState.setWelcomeScreenShown()
// Second step, analytics opt in
steps.add(state.getNextStep(steps.lastOrNull()))
analyticsService.setDidAskUserConsent()
// Final step (null)
steps.add(state.getNextStep(steps.lastOrNull()))
assertThat(steps).containsExactly(
FtueStep.WelcomeScreen,
FtueStep.AnalyticsOptIn,
null, // Final state
)
// Cleanup
coroutineScope.cancel()
}
@Test
fun `if a check for a step is true, start from the next one`() = runTest {
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val analyticsService = FakeAnalyticsService()
val state = createState(coroutineScope = coroutineScope, analyticsService = analyticsService)
state.setWelcomeScreenShown()
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
analyticsService.setDidAskUserConsent()
assertThat(state.getNextStep(FtueStep.WelcomeScreen)).isNull()
// Cleanup
coroutineScope.cancel()
}
private fun createState(
coroutineScope: CoroutineScope,
welcomeState: FakeWelcomeState = FakeWelcomeState(),
analyticsService: AnalyticsService = FakeAnalyticsService()
) = DefaultFtueState(coroutineScope, analyticsService, welcomeState)
}

View file

@ -0,0 +1,30 @@
/*
* 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.features.ftue.impl.welcome.state
class FakeWelcomeState : WelcomeScreenState {
private var isWelcomeScreenNeeded = true
override fun isWelcomeScreenNeeded(): Boolean {
return isWelcomeScreenNeeded
}
override fun setWelcomeScreenShown() {
isWelcomeScreenNeeded = false
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View file

@ -64,6 +64,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
@ -72,6 +73,8 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.theme.ElementTheme
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.launch
@Composable

View file

@ -18,10 +18,11 @@ package io.element.android.features.messages.impl.timeline.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineEncryptedHistoryBannerView
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
@Composable
@ -32,5 +33,7 @@ fun TimelineItemVirtualRow(
when (virtual.model) {
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier)
TimelineItemReadMarkerModel -> return
is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(modifier)
}
}

View file

@ -0,0 +1,69 @@
/*
* 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.features.messages.impl.timeline.components.virtual
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.R
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.theme.ElementTheme
@Composable
fun TimelineEncryptedHistoryBannerView(modifier: Modifier = Modifier) {
Row(
modifier = modifier
.padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 32.dp)
.clip(MaterialTheme.shapes.small)
.border(1.dp, ElementTheme.colors.borderInfoSubtle, MaterialTheme.shapes.small)
.background(ElementTheme.colors.bgInfoSubtle)
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = "Info",
tint = ElementTheme.colors.iconInfoPrimary
)
Text(
text = stringResource(R.string.screen_room_encrypted_history_banner),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textInfoPrimary
)
}
}
@DayNightPreviews
@Composable
internal fun TimelineEncryptedHistoryBannerViewPreview() {
ElementTheme {
TimelineEncryptedHistoryBannerView()
}
}

View file

@ -45,7 +45,6 @@ class TimelineItemsFactory @Inject constructor(
private val virtualItemFactory: TimelineItemVirtualFactory,
private val timelineItemGrouper: TimelineItemGrouper,
) {
private val timelineItems = MutableStateFlow(persistentListOf<TimelineItem>())
private val timelineItemsCache = arrayListOf<TimelineItem?>()

View file

@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.timeline.factories.virtual
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemEncryptedHistoryBannerVirtualModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel
import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemVirtualModel
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
@ -30,8 +31,13 @@ class TimelineItemVirtualFactory @Inject constructor(
fun create(
virtualTimelineItem: MatrixTimelineItem.Virtual,
): TimelineItem.Virtual {
val id = if (virtualTimelineItem.virtual is VirtualTimelineItem.EncryptedHistoryBanner) {
"encrypted_history_banner"
} else {
virtualTimelineItem.uniqueId.toString()
}
return TimelineItem.Virtual(
id = virtualTimelineItem.uniqueId.toString(),
id = id,
model = virtualTimelineItem.computeModel()
)
}
@ -40,6 +46,7 @@ class TimelineItemVirtualFactory @Inject constructor(
return when (val inner = virtual) {
is VirtualTimelineItem.DayDivider -> daySeparatorFactory.create(inner)
is VirtualTimelineItem.ReadMarker -> TimelineItemReadMarkerModel
is VirtualTimelineItem.EncryptedHistoryBanner -> TimelineItemEncryptedHistoryBannerVirtualModel
}
}
}

View file

@ -0,0 +1,21 @@
/*
* 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.features.messages.impl.timeline.model.virtual
object TimelineItemEncryptedHistoryBannerVirtualModel : TimelineItemVirtualModel {
override val type: String = "TimelineItemEncryptedHistoryBannerVirtualModel"
}

View file

@ -0,0 +1,170 @@
/*
* 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.designsystem.atomic.atoms
import android.graphics.BlurMaskFilter
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.graphics.ClipOp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.R
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.theme.ElementTheme
@Composable
fun ElementLogoAtom(
size: ElementLogoAtomSize,
modifier: Modifier = Modifier,
) {
val outerSize = when (size) {
ElementLogoAtomSize.Large -> 158.dp
ElementLogoAtomSize.Medium -> 120.dp
}
val logoSize = when (size) {
ElementLogoAtomSize.Large -> 110.dp
ElementLogoAtomSize.Medium -> 83.5.dp
}
val cornerRadius = when(size) {
ElementLogoAtomSize.Large -> 44.dp
ElementLogoAtomSize.Medium -> 33.dp
}
val borderWidth = when (size) {
ElementLogoAtomSize.Large -> 1.dp
ElementLogoAtomSize.Medium -> 0.38.dp
}
val blur = if (isSystemInDarkTheme()) {
160.dp
} else {
24.dp
}
//box-shadow: 0px 6.075949668884277px 24.30379867553711px 0px #1B1D2280;
val shadowColor = if (isSystemInDarkTheme()) {
Color.Black.copy(alpha = 0.4f)
} else {
Color(0x401B1D22)
}
val backgroundColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.2f) else Color.White.copy(alpha = 0.4f)
val borderColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.8f) else Color.White.copy(alpha = 0.4f)
Box(
modifier = modifier
.size(outerSize)
.border(borderWidth, borderColor, RoundedCornerShape(cornerRadius)),
contentAlignment = Alignment.Center,
) {
Box(
Modifier
.size(outerSize)
.shapeShadow(
color = shadowColor,
cornerRadius = cornerRadius,
blurRadius = 32.dp,
offsetY = 8.dp,
)
)
Box(
Modifier
.clip(RoundedCornerShape(cornerRadius))
.size(outerSize)
.background(backgroundColor)
.blur(blur)
)
Image(
modifier = Modifier.size(logoSize),
painter = painterResource(id = R.drawable.element_logo),
contentDescription = null
)
}
}
enum class ElementLogoAtomSize {
Medium,
Large
}
@Composable
@DayNightPreviews
internal fun ElementLogoAtomPreview() {
ElementPreview {
Box(
Modifier
.size(170.dp)
.background(ElementTheme.colors.bgSubtlePrimary))
ElementLogoAtom(ElementLogoAtomSize.Large)
}
}
fun Modifier.shapeShadow(
color: Color = Color.Black,
cornerRadius: Dp = 0.dp,
offsetX: Dp = 0.dp,
offsetY: Dp = 0.dp,
blurRadius: Dp = 0.dp,
) = then(
drawBehind {
drawIntoCanvas { canvas ->
val path = Path().apply {
addRoundRect(RoundRect(Rect(Offset.Zero, size), CornerRadius(cornerRadius.toPx())))
}
clipPath(path, ClipOp.Difference) {
val paint = Paint()
val frameworkPaint = paint.asFrameworkPaint()
if (blurRadius != 0.dp) {
frameworkPaint.maskFilter = (BlurMaskFilter(blurRadius.toPx(), BlurMaskFilter.Blur.NORMAL))
}
frameworkPaint.color = color.toArgb()
val leftPixel = offsetX.toPx()
val topPixel = offsetY.toPx()
val rightPixel = size.width + topPixel
val bottomPixel = size.height + leftPixel
canvas.drawRect(
left = leftPixel,
top = topPixel,
right = rightPixel,
bottom = bottomPixel,
paint = paint,
)
}
}
}
)

View file

@ -0,0 +1,113 @@
/*
* 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.designsystem.atomic.atoms
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun InfoListItemMolecule(
message: @Composable () -> Unit,
position: InfoListItemPosition,
backgroundColor: Color,
modifier: Modifier = Modifier,
icon: @Composable () -> Unit = {},
) {
val radius = 14.dp
val backgroundShape = remember(position) {
when (position) {
InfoListItemPosition.Single -> RoundedCornerShape(radius)
InfoListItemPosition.Top -> RoundedCornerShape(topStart = radius, topEnd = radius)
InfoListItemPosition.Middle -> RoundedCornerShape(0.dp)
InfoListItemPosition.Bottom -> RoundedCornerShape(bottomStart = radius, bottomEnd = radius)
}
}
Row(
modifier = modifier
.fillMaxWidth()
.background(
color = backgroundColor,
shape = backgroundShape,
)
.padding(vertical = 12.dp, horizontal = 20.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
icon()
message()
}
}
@DayNightPreviews
@Composable
fun InfoListItemMoleculePreview() {
ElementPreview {
val color = if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray
Column(
modifier = Modifier.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
InfoListItemMolecule(
message = { Text("A single item") },
icon = { Icon(imageVector = Icons.Default.Info, contentDescription = null) },
position = InfoListItemPosition.Single,
backgroundColor = color,
)
InfoListItemMolecule(
message = { Text("A top item") },
icon = { Icon(imageVector = Icons.Default.Info, contentDescription = null) },
position = InfoListItemPosition.Top,
backgroundColor = color,
)
InfoListItemMolecule(
message = { Text("A middle item") },
icon = { Icon(imageVector = Icons.Default.Info, contentDescription = null) },
position = InfoListItemPosition.Middle,
backgroundColor = color,
)
InfoListItemMolecule(
message = { Text("A bottom item") },
icon = { Icon(imageVector = Icons.Default.Info, contentDescription = null) },
position = InfoListItemPosition.Bottom,
backgroundColor = color,
)
}
}
}
enum class InfoListItemPosition {
Top,
Middle,
Bottom,
Single,
}

View file

@ -0,0 +1,79 @@
/*
* 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.designsystem.atomic.molecules
import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.atomic.atoms.InfoListItemMolecule
import io.element.android.libraries.designsystem.atomic.atoms.InfoListItemPosition
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import kotlinx.collections.immutable.ImmutableList
@Composable
fun InfoListOrganism(
items: ImmutableList<InfoListItem>,
backgroundColor: Color,
modifier: Modifier = Modifier,
iconTint: Color = LocalContentColor.current,
textStyle: TextStyle = LocalTextStyle.current,
verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(4.dp),
) {
Column(
modifier = modifier,
verticalArrangement = verticalArrangement,
) {
for ((index, item) in items.withIndex()) {
val position = when {
items.size == 1 -> InfoListItemPosition.Single
index == 0 -> InfoListItemPosition.Top
index == items.size - 1 -> InfoListItemPosition.Bottom
else -> InfoListItemPosition.Middle
}
InfoListItemMolecule(
message = { Text(item.message, style = textStyle) },
icon = {
if (item.iconId != null) {
Icon(resourceId = item.iconId, contentDescription = null, tint = iconTint)
} else if (item.iconVector != null) {
Icon(imageVector = item.iconVector, contentDescription = null, tint = iconTint)
} else {
item.iconComposable()
}
},
position = position,
backgroundColor = backgroundColor,
)
}
}
}
data class InfoListItem(
val message: String,
@DrawableRes val iconId: Int? = null,
val iconVector: ImageVector? = null,
val iconComposable: @Composable () -> Unit = {},
)

View file

@ -41,12 +41,14 @@ import io.element.android.libraries.theme.ElementTheme
*
* Ref: https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=133-5427&t=5SHVppfYzjvkEywR-0
* @param modifier Classical modifier.
* @param contentAlignment horizontal alignment of the contents.
* @param footer optional footer.
* @param content main content.
*/
@Composable
fun OnBoardingPage(
modifier: Modifier = Modifier,
contentAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
footer: @Composable () -> Unit = {},
content: @Composable () -> Unit = {},
) {
@ -78,6 +80,7 @@ fun OnBoardingPage(
.weight(1f)
.padding(horizontal = 24.dp)
.fillMaxWidth(),
horizontalAlignment = contentAlignment,
) {
content()
}

View file

@ -0,0 +1,26 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="110dp"
android:height="110dp"
android:viewportWidth="110"
android:viewportHeight="110">
<path
android:pathData="M55,110C85.38,110 110,85.38 110,55C110,24.62 85.38,0 55,0C24.62,0 0,24.62 0,55C0,85.38 24.62,110 55,110Z"
android:fillColor="#0DBD8B"
android:fillType="evenOdd"/>
<path
android:pathData="M44.94,25.63C44.94,23.41 46.75,21.61 48.97,21.61C64.05,21.61 76.27,33.81 76.27,48.85C76.27,51.07 74.47,52.87 72.25,52.87C70.02,52.87 68.22,51.07 68.22,48.85C68.22,38.25 59.6,29.65 48.97,29.65C46.75,29.65 44.94,27.85 44.94,25.63Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="M84.36,44.83C86.59,44.83 88.39,46.63 88.39,48.85C88.39,63.9 76.17,76.1 61.09,76.1C58.87,76.1 57.06,74.3 57.06,72.08C57.06,69.86 58.87,68.06 61.09,68.06C71.72,68.06 80.34,59.46 80.34,48.85C80.34,46.63 82.14,44.83 84.36,44.83Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="M65.12,84.37C65.12,86.59 63.32,88.39 61.09,88.39C46.01,88.39 33.79,76.19 33.79,61.15C33.79,58.93 35.59,57.13 37.82,57.13C40.04,57.13 41.85,58.93 41.85,61.15C41.85,71.75 50.46,80.35 61.09,80.35C63.32,80.35 65.12,82.15 65.12,84.37Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="M25.63,65.17C23.41,65.17 21.61,63.37 21.61,61.15C21.61,46.1 33.83,33.9 48.91,33.9C51.13,33.9 52.94,35.7 52.94,37.92C52.94,40.14 51.13,41.94 48.91,41.94C38.28,41.94 29.66,50.54 29.66,61.15C29.66,63.37 27.86,65.17 25.63,65.17Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
</vector>

View file

@ -31,9 +31,7 @@ import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import kotlinx.coroutines.TimeoutCancellationException
import java.io.Closeable
import kotlin.time.Duration
interface MatrixClient : Closeable {
val sessionId: SessionId

View file

@ -24,4 +24,5 @@ sealed interface VirtualTimelineItem {
object ReadMarker : VirtualTimelineItem
object EncryptedHistoryBanner : VirtualTimelineItem
}

View file

@ -41,4 +41,8 @@ dependencies {
implementation("net.java.dev.jna:jna:5.13.0@aar")
implementation(libs.androidx.datastore.preferences)
implementation(libs.serialization.json)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(projects.libraries.matrix.test)
}

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,
)
}

View file

@ -16,11 +16,14 @@
package io.element.android.libraries.sessionstorage.api
import java.util.Date
data class SessionData(
val userId: String,
val deviceId: String,
val accessToken: String,
val refreshToken: String?,
val homeserverUrl: String,
val slidingSyncProxy: String?
val slidingSyncProxy: String?,
val loginTimestamp: Date?,
)

View file

@ -48,5 +48,7 @@ dependencies {
}
sqldelight {
database("SessionDatabase") {}
database("SessionDatabase") {
verifyMigrations = true
}
}

View file

@ -17,19 +17,22 @@
package io.element.android.libraries.sessionstorage.impl
import io.element.android.libraries.sessionstorage.api.SessionData
import java.util.Date
import io.element.android.libraries.matrix.session.SessionData as DbSessionData
internal fun SessionData.toDbModel(): io.element.android.libraries.matrix.session.SessionData {
return io.element.android.libraries.matrix.session.SessionData(
internal fun SessionData.toDbModel(): DbSessionData {
return DbSessionData(
userId = userId,
deviceId = deviceId,
accessToken = accessToken,
refreshToken = refreshToken,
homeserverUrl = homeserverUrl,
slidingSyncProxy = slidingSyncProxy,
loginTimestamp = loginTimestamp?.time,
)
}
internal fun io.element.android.libraries.matrix.session.SessionData.toApiModel(): SessionData {
internal fun DbSessionData.toApiModel(): SessionData {
return SessionData(
userId = userId,
deviceId = deviceId,
@ -37,5 +40,6 @@ internal fun io.element.android.libraries.matrix.session.SessionData.toApiModel(
refreshToken = refreshToken,
homeserverUrl = homeserverUrl,
slidingSyncProxy = slidingSyncProxy,
loginTimestamp = loginTimestamp?.let { Date(it) }
)
}

View file

@ -4,9 +4,11 @@ CREATE TABLE SessionData (
accessToken TEXT NOT NULL,
refreshToken TEXT,
homeserverUrl TEXT NOT NULL,
slidingSyncProxy TEXT
slidingSyncProxy TEXT,
loginTimestamp INTEGER
);
selectFirst:
SELECT * FROM SessionData LIMIT 1;
@ -17,7 +19,7 @@ selectByUserId:
SELECT * FROM SessionData WHERE userId = ?;
insertSessionData:
INSERT INTO SessionData(userId, deviceId, accessToken, refreshToken, homeserverUrl, slidingSyncProxy) VALUES ?;
INSERT INTO SessionData VALUES ?;
removeSession:
DELETE FROM SessionData WHERE userId = ?;

View file

@ -0,0 +1,8 @@
CREATE TABLE SessionData (
userId TEXT NOT NULL PRIMARY KEY,
deviceId TEXT NOT NULL,
accessToken TEXT NOT NULL,
refreshToken TEXT,
homeserverUrl TEXT NOT NULL,
slidingSyncProxy TEXT
);

View file

@ -0,0 +1 @@
ALTER TABLE SessionData ADD COLUMN loginTimestamp INTEGER;

View file

@ -35,7 +35,8 @@ class DatabaseSessionStoreTests {
accessToken = "accessToken",
refreshToken = "refreshToken",
homeserverUrl = "homeserverUrl",
slidingSyncProxy = null
slidingSyncProxy = null,
loginTimestamp = null,
)
@Before

View file

@ -42,6 +42,11 @@ object TestTags {
* Room list / Home screen.
*/
val homeScreenSettings = TestTag("home_screen-settings")
/**
* Welcome screen.
*/
val welcomeScreenTitle = TestTag("welcome_screen-title")
}

View file

@ -176,12 +176,6 @@
<string name="screen_share_open_osm_maps">"In OpenStreetMap öffnen"</string>
<string name="screen_share_this_location_action">"Diesen Ort teilen"</string>
<string name="screen_view_location_title">"Standort"</string>
<string name="screen_welcome_bullet_1">"Anrufe, Standortfreigabe, Suche und mehr werden später in diesem Jahr hinzugefügt."</string>
<string name="screen_welcome_bullet_2">"Der Nachrichtenverlauf für verschlüsselte Räume wird in diesem Update nicht verfügbar sein."</string>
<string name="screen_welcome_bullet_3">"Wir würden uns freuen, wenn du uns über die Einstellungsseite deine Meinung mitteilst."</string>
<string name="screen_welcome_button">"Los geht\'s!"</string>
<string name="screen_welcome_subtitle">"Folgendes musst du wissen:"</string>
<string name="screen_welcome_title">"Willkommen bei %1$s!"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Erkennungsschwelle"</string>
<string name="settings_title_general">"Allgemein"</string>

View file

@ -178,11 +178,6 @@
<string name="screen_share_open_google_maps">"Ouvrir dans Google Maps"</string>
<string name="screen_share_open_osm_maps">"Ouvrir dans OpenStreetMap"</string>
<string name="screen_share_this_location_action">"Partager cette position"</string>
<string name="screen_welcome_bullet_2">"Lhistorique des messages pour les salons chiffrés ne sera pas disponible dans cette mise à jour."</string>
<string name="screen_welcome_bullet_3">"Nous serions ravis davoir votre avis, nhésitez pas à nous le partager via la page des paramètres."</string>
<string name="screen_welcome_button">"Cest parti !"</string>
<string name="screen_welcome_subtitle">"Voici ce quil faut savoir :"</string>
<string name="screen_welcome_title">"Bienvenue sur %1$s !"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Seuil de détection"</string>
<string name="settings_title_general">"Général"</string>

View file

@ -183,12 +183,6 @@
<string name="screen_share_open_osm_maps">"Otvoriť v OpenStreetMap"</string>
<string name="screen_share_this_location_action">"Zdieľajte túto polohu"</string>
<string name="screen_view_location_title">"Poloha"</string>
<string name="screen_welcome_bullet_1">"Hovory, zdieľanie polohy, vyhľadávanie a ďalšie funkcie pribudnú neskôr v tomto roku."</string>
<string name="screen_welcome_bullet_2">"História správ pre zašifrované miestnosti nebude v tejto aktualizácii k dispozícii."</string>
<string name="screen_welcome_bullet_3">"Radi by sme od vás počuli, dajte nám vedieť, čo si myslíte, prostredníctvom stránky nastavení."</string>
<string name="screen_welcome_button">"Poďme na to!"</string>
<string name="screen_welcome_subtitle">"Tu je to, čo potrebujete vedieť:"</string>
<string name="screen_welcome_title">"Vitajte v %1$s!"</string>
<string name="settings_rageshake">"Zúrivé potrasenie"</string>
<string name="settings_rageshake_detection_threshold">"Prahová hodnota detekcie"</string>
<string name="settings_title_general">"Všeobecné"</string>

View file

@ -182,12 +182,6 @@
<string name="screen_share_open_osm_maps">"Open in OpenStreetMap"</string>
<string name="screen_share_this_location_action">"Share this location"</string>
<string name="screen_view_location_title">"Location"</string>
<string name="screen_welcome_bullet_1">"Calls, location sharing, search and more will be added later this year."</string>
<string name="screen_welcome_bullet_2">"Message history for encrypted rooms wont be available in this update."</string>
<string name="screen_welcome_bullet_3">"Wed love to hear from you, let us know what you think via the settings page."</string>
<string name="screen_welcome_button">"Let\'s go!"</string>
<string name="screen_welcome_subtitle">"Heres what you need to know:"</string>
<string name="screen_welcome_title">"Welcome to %1$s!"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Detection threshold"</string>
<string name="settings_title_general">"General"</string>

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:76a68f2fc93894d6f9d9caea02546766c55664c0e53ba9506c6c32df058f5823
size 303608

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f24bb3e40dd8c02037bd9d4523726ec0a0b1a283d23a8ca143973b0e9ee673c6
size 408318

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2947531c19a0ac9a7e35c3f2a394f6eb805427e1ad296d22b7d8b5cbb2428e07
size 20947

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6f88eb992060d5b41ce3200bdc48d4fe6accaeda857d1ca08cb65ed8235798f7
size 20266

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:51616fee6314d06981ce18d654c166d8e941be3264578c89e479a2a1267caa65
size 19226

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6a7f455414ed06ec16785049bc3e99fa312a89599d24bcda0dc611c390e10c73
size 18734

View file

@ -113,6 +113,12 @@
"includeRegex": [
"screen_analytics_prompt.*"
]
},
{
"name": ":features:ftue:impl",
"includeRegex": [
"screen_welcome_.*"
]
}
]
}