diff --git a/.maestro/tests/account/login.yaml b/.maestro/tests/account/login.yaml index 70a9b956ef..6126e34459 100644 --- a/.maestro/tests/account/login.yaml +++ b/.maestro/tests/account/login.yaml @@ -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 diff --git a/.maestro/tests/assertions/assertWelcomeScreenDisplayed.yaml b/.maestro/tests/assertions/assertWelcomeScreenDisplayed.yaml new file mode 100644 index 0000000000..73e8e78ef5 --- /dev/null +++ b/.maestro/tests/assertions/assertWelcomeScreenDisplayed.yaml @@ -0,0 +1,6 @@ +appId: ${APP_ID} +--- +- extendedWaitUntil: + visible: + id: "welcome_screen-title" + timeout: 10_000 diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index aa21c1f6d1..459acdeac6 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -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) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index 3380dd91db..48a0743447 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -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( 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().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) + } } } diff --git a/build.gradle.kts b/build.gradle.kts index 02c3ca3043..722e83fe87 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt index b9fe17d237..ba6d84ae74 100644 --- a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt +++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt @@ -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)) diff --git a/features/ftue/api/build.gradle.kts b/features/ftue/api/build.gradle.kts new file mode 100644 index 0000000000..9fd36026b9 --- /dev/null +++ b/features/ftue/api/build.gradle.kts @@ -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) +} diff --git a/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/FtueEntryPoint.kt b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/FtueEntryPoint.kt new file mode 100644 index 0000000000..649a327f6e --- /dev/null +++ b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/FtueEntryPoint.kt @@ -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() + } +} diff --git a/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/state/FtueState.kt b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/state/FtueState.kt new file mode 100644 index 0000000000..2c19d4e3a7 --- /dev/null +++ b/features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/state/FtueState.kt @@ -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 +} diff --git a/features/ftue/impl/build.gradle.kts b/features/ftue/impl/build.gradle.kts new file mode 100644 index 0000000000..0dee792464 --- /dev/null +++ b/features/ftue/impl/build.gradle.kts @@ -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) +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/DefaultFtueEntryPoint.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/DefaultFtueEntryPoint.kt new file mode 100644 index 0000000000..9c2f74f072 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/DefaultFtueEntryPoint.kt @@ -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() + + return object : FtueEntryPoint.NodeBuilder { + + override fun callback(callback: FtueEntryPoint.Callback): FtueEntryPoint.NodeBuilder { + plugins += callback + return this + } + + override fun build(): Node { + return parentNode.createNode(buildContext, plugins) + } + } + } +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt new file mode 100644 index 0000000000..0ff9c80d46 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt @@ -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, + private val ftueState: DefaultFtueState, + private val analyticsEntryPoint: AnalyticsEntryPoint, + private val analyticsService: AnalyticsService, +) : BackstackNode( + backstack = BackStack( + initialElement = NavTarget.Placeholder, + savedStateMap = buildContext.savedStateMap, + backPressHandler = NoOpBackstackHandlerStrategy(), + ), + 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().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(buildContext) + } + NavTarget.WelcomeScreen -> { + val callback = object : WelcomeNode.Callback { + override fun onContinueClicked() { + ftueState.setWelcomeScreenShown() + lifecycleScope.launch { moveToNextStep() } + } + } + createNode(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, + ) : Node(buildContext, plugins = plugins) +} + +private class NoOpBackstackHandlerStrategy : BaseBackPressHandlerStrategy() { + override val canHandleBackPressFlow: StateFlow = MutableStateFlow(true) + + override fun onBackPressed() { + // No-op + } +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt new file mode 100644 index 0000000000..39b100808f --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt @@ -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 +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeNode.kt new file mode 100644 index 0000000000..f4e0d9f640 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeNode.kt @@ -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, + private val buildMeta: BuildMeta, +) : Node(buildContext, plugins = plugins) { + + interface Callback : Plugin { + fun onContinueClicked() + } + + private fun onContinueClicked() { + plugins.filterIsInstance().forEach { it.onContinueClicked() } + } + + @Composable + override fun View(modifier: Modifier) { + WelcomeView( + applicationName = buildMeta.applicationName, + onContinueClicked = ::onContinueClicked, + modifier = modifier + ) + } + +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt new file mode 100644 index 0000000000..ccb55494b8 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt @@ -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 = {}) + } +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/AndroidWelcomeScreenState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/AndroidWelcomeScreenState.kt new file mode 100644 index 0000000000..c482b4e744 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/AndroidWelcomeScreenState.kt @@ -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() + } +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/WelcomeScreenState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/WelcomeScreenState.kt new file mode 100644 index 0000000000..0e5f79d7c1 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/WelcomeScreenState.kt @@ -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() +} diff --git a/features/ftue/impl/src/main/res/values/localazy.xml b/features/ftue/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..17999e7158 --- /dev/null +++ b/features/ftue/impl/src/main/res/values/localazy.xml @@ -0,0 +1,9 @@ + + + "Calls, location sharing, search and more will be added later this year." + "Message history for encrypted rooms won’t be available in this update." + "We’d love to hear from you, let us know what you think via the settings page." + "Let\'s go!" + "Here’s what you need to know:" + "Welcome to %1$s!" + diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt new file mode 100644 index 0000000000..ce1683e8e5 --- /dev/null +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt @@ -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() + + // 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) + +} diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeState.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeState.kt new file mode 100644 index 0000000000..198d79115a --- /dev/null +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeState.kt @@ -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 + } +} diff --git a/features/login/impl/src/main/res/drawable/onboarding_icon_light.png b/features/login/impl/src/main/res/drawable/onboarding_icon_light.png new file mode 100644 index 0000000000..ffd8631c47 Binary files /dev/null and b/features/login/impl/src/main/res/drawable/onboarding_icon_light.png differ diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index ee7a57c70e..949be6ed85 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -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 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt index 820560c2a2..d6b1c06f54 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt @@ -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) } } + diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineEncryptedHistoryBannerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineEncryptedHistoryBannerView.kt new file mode 100644 index 0000000000..055e4bb876 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineEncryptedHistoryBannerView.kt @@ -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() + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt index f607a0e034..aa9786c945 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt @@ -45,7 +45,6 @@ class TimelineItemsFactory @Inject constructor( private val virtualItemFactory: TimelineItemVirtualFactory, private val timelineItemGrouper: TimelineItemGrouper, ) { - private val timelineItems = MutableStateFlow(persistentListOf()) private val timelineItemsCache = arrayListOf() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemVirtualFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemVirtualFactory.kt index cca1786bf8..6178b1dee7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemVirtualFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/virtual/TimelineItemVirtualFactory.kt @@ -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 } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemEncryptedHistoryBannerVirtualModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemEncryptedHistoryBannerVirtualModel.kt new file mode 100644 index 0000000000..442aed5734 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemEncryptedHistoryBannerVirtualModel.kt @@ -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" +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt new file mode 100644 index 0000000000..19a1798f74 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt @@ -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, + ) + } + } + } +) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/InfoListItemMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/InfoListItemMolecule.kt new file mode 100644 index 0000000000..6b20c96880 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/InfoListItemMolecule.kt @@ -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, +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/InfoListOrganism.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/InfoListOrganism.kt new file mode 100644 index 0000000000..60c61d99fc --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/InfoListOrganism.kt @@ -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, + 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 = {}, +) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/OnBoardingPage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/OnBoardingPage.kt index 511bed24b5..ec3ee92be8 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/OnBoardingPage.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/OnBoardingPage.kt @@ -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() } diff --git a/libraries/designsystem/src/main/res/drawable/element_logo.xml b/libraries/designsystem/src/main/res/drawable/element_logo.xml new file mode 100644 index 0000000000..0101c0d541 --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/element_logo.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 67e9e622b7..747de5f554 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -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 diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/virtual/VirtualTimelineItem.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/virtual/VirtualTimelineItem.kt index ed761a3d43..11fd8b9c63 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/virtual/VirtualTimelineItem.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/virtual/VirtualTimelineItem.kt @@ -24,4 +24,5 @@ sealed interface VirtualTimelineItem { object ReadMarker : VirtualTimelineItem + object EncryptedHistoryBanner : VirtualTimelineItem } diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts index e18d13c2b2..7786a3ee3f 100644 --- a/libraries/matrix/impl/build.gradle.kts +++ b/libraries/matrix/impl/build.gradle.kts @@ -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) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 8798e01f98..d7195ebf11 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -157,6 +157,7 @@ class RustMatrixClient constructor( coroutineDispatchers = dispatchers, systemClock = clock, roomContentForwarder = roomContentForwarder, + sessionData = sessionStore.getSession(sessionId.value)!!, ) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index bdb87b298c..0600e96f3b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -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(), ) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index 9eced2d5cb..0b886af8ed 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -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, ) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt index 2c245c7164..e213fb623c 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustMatrixTimeline.kt @@ -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() @@ -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 = _paginationState.asStateFlow() - @OptIn(FlowPreview::class) + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) override val timelineItems: Flow> = _timelineItems.sample(50) + .mapLatest { items -> + encryptedHistoryPostProcessor.process(items) + } internal suspend fun postItems(items: List) { // 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 + } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessor.kt new file mode 100644 index 0000000000..ca5c7342f8 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessor.kt @@ -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, +) { + + fun process(items: List): List { + 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): List { + 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 + } + +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessorTest.kt new file mode 100644 index 0000000000..91f0bc1883 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessorTest.kt @@ -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() + 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 = + MutableStateFlow(MatrixTimeline.PaginationState(hasMoreToLoadBackwards = true, isBackPaginating = false)) + ) = TimelineEncryptedHistoryPostProcessor( + lastLoginTimestamp = lastLoginTimestamp, + isRoomEncrypted = isRoomEncrypted, + paginationStateFlow = paginationStateFlow, + ) +} diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt index f2eb5847f7..cc106f960a 100644 --- a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt @@ -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?, ) diff --git a/libraries/session-storage/impl/build.gradle.kts b/libraries/session-storage/impl/build.gradle.kts index cd42a18402..698bfcf230 100644 --- a/libraries/session-storage/impl/build.gradle.kts +++ b/libraries/session-storage/impl/build.gradle.kts @@ -48,5 +48,7 @@ dependencies { } sqldelight { - database("SessionDatabase") {} + database("SessionDatabase") { + verifyMigrations = true + } } diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt index fd8a42ad6f..dbb42a8451 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt @@ -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) } ) } diff --git a/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq b/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq index ea8471a36a..c3123f2ffb 100644 --- a/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq +++ b/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq @@ -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 = ?; diff --git a/libraries/session-storage/impl/src/main/sqldelight/migrations/0.sqm b/libraries/session-storage/impl/src/main/sqldelight/migrations/0.sqm new file mode 100644 index 0000000000..396a8f28dd --- /dev/null +++ b/libraries/session-storage/impl/src/main/sqldelight/migrations/0.sqm @@ -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 +); diff --git a/libraries/session-storage/impl/src/main/sqldelight/migrations/1.sqm b/libraries/session-storage/impl/src/main/sqldelight/migrations/1.sqm new file mode 100644 index 0000000000..3ee7762585 --- /dev/null +++ b/libraries/session-storage/impl/src/main/sqldelight/migrations/1.sqm @@ -0,0 +1 @@ +ALTER TABLE SessionData ADD COLUMN loginTimestamp INTEGER; diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt index 28b9dfba50..fc24c5a011 100644 --- a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTests.kt @@ -35,7 +35,8 @@ class DatabaseSessionStoreTests { accessToken = "accessToken", refreshToken = "refreshToken", homeserverUrl = "homeserverUrl", - slidingSyncProxy = null + slidingSyncProxy = null, + loginTimestamp = null, ) @Before diff --git a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt index d702c797a8..d832a6168d 100644 --- a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt +++ b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt @@ -42,6 +42,11 @@ object TestTags { * Room list / Home screen. */ val homeScreenSettings = TestTag("home_screen-settings") + + /** + * Welcome screen. + */ + val welcomeScreenTitle = TestTag("welcome_screen-title") } diff --git a/libraries/ui-strings/src/main/res/values-de/translations.xml b/libraries/ui-strings/src/main/res/values-de/translations.xml index 768097f37c..10694181da 100644 --- a/libraries/ui-strings/src/main/res/values-de/translations.xml +++ b/libraries/ui-strings/src/main/res/values-de/translations.xml @@ -176,12 +176,6 @@ "In OpenStreetMap öffnen" "Diesen Ort teilen" "Standort" - "Anrufe, Standortfreigabe, Suche und mehr werden später in diesem Jahr hinzugefügt." - "Der Nachrichtenverlauf für verschlüsselte Räume wird in diesem Update nicht verfügbar sein." - "Wir würden uns freuen, wenn du uns über die Einstellungsseite deine Meinung mitteilst." - "Los geht\'s!" - "Folgendes musst du wissen:" - "Willkommen bei %1$s!" "Rageshake" "Erkennungsschwelle" "Allgemein" diff --git a/libraries/ui-strings/src/main/res/values-fr/translations.xml b/libraries/ui-strings/src/main/res/values-fr/translations.xml index 2ff0ae3914..e3fe11dfdc 100644 --- a/libraries/ui-strings/src/main/res/values-fr/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml @@ -178,11 +178,6 @@ "Ouvrir dans Google Maps" "Ouvrir dans OpenStreetMap" "Partager cette position" - "L’historique des messages pour les salons chiffrés ne sera pas disponible dans cette mise à jour." - "Nous serions ravis d’avoir votre avis, n’hésitez pas à nous le partager via la page des paramètres." - "C’est parti !" - "Voici ce qu’il faut savoir :" - "Bienvenue sur %1$s !" "Rageshake" "Seuil de détection" "Général" diff --git a/libraries/ui-strings/src/main/res/values-sk/translations.xml b/libraries/ui-strings/src/main/res/values-sk/translations.xml index d75afca6a4..336ae9144c 100644 --- a/libraries/ui-strings/src/main/res/values-sk/translations.xml +++ b/libraries/ui-strings/src/main/res/values-sk/translations.xml @@ -183,12 +183,6 @@ "Otvoriť v OpenStreetMap" "Zdieľajte túto polohu" "Poloha" - "Hovory, zdieľanie polohy, vyhľadávanie a ďalšie funkcie pribudnú neskôr v tomto roku." - "História správ pre zašifrované miestnosti nebude v tejto aktualizácii k dispozícii." - "Radi by sme od vás počuli, dajte nám vedieť, čo si myslíte, prostredníctvom stránky nastavení." - "Poďme na to!" - "Tu je to, čo potrebujete vedieť:" - "Vitajte v %1$s!" "Zúrivé potrasenie" "Prahová hodnota detekcie" "Všeobecné" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 94dceaaa7e..c73284e700 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -182,12 +182,6 @@ "Open in OpenStreetMap" "Share this location" "Location" - "Calls, location sharing, search and more will be added later this year." - "Message history for encrypted rooms won’t be available in this update." - "We’d love to hear from you, let us know what you think via the settings page." - "Let\'s go!" - "Here’s what you need to know:" - "Welcome to %1$s!" "Rageshake" "Detection threshold" "General" diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-D-0_1_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-D-0_1_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d0e4757049 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-D-0_1_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:76a68f2fc93894d6f9d9caea02546766c55664c0e53ba9506c6c32df058f5823 +size 303608 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-N-0_2_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-N-0_2_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..68ee3f3801 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.ftue.impl.welcome_null_DefaultGroup_WelcomeViewPreview-N-0_2_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f24bb3e40dd8c02037bd9d4523726ec0a0b1a283d23a8ca143973b0e9ee673c6 +size 408318 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-D-7_8_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-D-7_8_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..45593d6af2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-D-7_8_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c806ee0293d94ab1c882b0f0ceaac2c7ac69d587f47cc22b4d16bc331351a2e +size 14711 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-N-7_9_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-N-7_9_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..c74bbe95f8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl.timeline.components.virtual_null_DefaultGroup_TimelineEncryptedHistoryBannerViewPreview-N-7_9_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77c97c6c99943858530219234356459a7f0a88774332a4f11cd738b92ee15c91 +size 14200 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomPreview-D_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomPreview-D_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..d903b752b0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomPreview-D_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2947531c19a0ac9a7e35c3f2a394f6eb805427e1ad296d22b7d8b5cbb2428e07 +size 20947 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomPreview-N_1_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomPreview-N_1_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..bd9fca6c0f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_ElementLogoAtomPreview-N_1_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6f88eb992060d5b41ce3200bdc48d4fe6accaeda857d1ca08cb65ed8235798f7 +size 20266 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_InfoListItemMoleculePreview-D_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_InfoListItemMoleculePreview-D_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..15308b30bc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_InfoListItemMoleculePreview-D_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:51616fee6314d06981ce18d654c166d8e941be3264578c89e479a2a1267caa65 +size 19226 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_InfoListItemMoleculePreview-N_1_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_InfoListItemMoleculePreview-N_1_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..b3af060ee1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.libraries.designsystem.atomic.atoms_null_DefaultGroup_InfoListItemMoleculePreview-N_1_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6a7f455414ed06ec16785049bc3e99fa312a89599d24bcda0dc611c390e10c73 +size 18734 diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 3feadf7a7a..fce6b317b5 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -113,6 +113,12 @@ "includeRegex": [ "screen_analytics_prompt.*" ] + }, + { + "name": ":features:ftue:impl", + "includeRegex": [ + "screen_welcome_.*" + ] } ] }