From 280b7e32e0ad5408275b777f04f3da75d81f049a Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Mon, 17 Jul 2023 18:34:36 +0200 Subject: [PATCH] Hide encryption history + FTUE flow (#839) * First attempt at implementing encrypted history banner and removing old UTDs * Get the right behavior in the timeline * Implement the designs * Extract post-processing logic, add tests * Add encryption banner to timeline screenshots * Create FTUE feature to handle welcome screen and analytics * Move classes to their own packages, add tests for `DefaultFtueState`. * Remove unnecessary private MutableStateFlow * Move some FTUE related methods and classes back to the `impl` module * Handle back press at each FTUE step * Remove unneeded `TestScope` receiver for `createState` in tests. * Use light & dark previews for the banner view. * Move color customization from `TextStyle` to `Text` component. * Rename `InfoList` design components, use them in `AnalyticsOptInView` too. * Cleanup MatrixClient. * Fix copy&paste error Co-authored-by: Benoit Marty * Fix typo * Fix Maestro tests --------- Co-authored-by: ElementBot Co-authored-by: Benoit Marty --- .maestro/tests/account/login.yaml | 2 + .../assertWelcomeScreenDisplayed.yaml | 6 + appnav/build.gradle.kts | 2 + .../android/appnav/LoggedInFlowNode.kt | 48 ++--- build.gradle.kts | 4 +- .../analytics/impl/AnalyticsOptInView.kt | 116 ++++++------ features/ftue/api/build.gradle.kts | 27 +++ .../features/ftue/api/FtueEntryPoint.kt | 36 ++++ .../features/ftue/api/state/FtueState.kt | 23 +++ features/ftue/impl/build.gradle.kts | 55 ++++++ .../ftue/impl/DefaultFtueEntryPoint.kt | 46 +++++ .../features/ftue/impl/FtueFlowNode.kt | 154 ++++++++++++++++ .../ftue/impl/state/DefaultFtueState.kt | 89 +++++++++ .../features/ftue/impl/welcome/WelcomeNode.kt | 54 ++++++ .../features/ftue/impl/welcome/WelcomeView.kt | 128 +++++++++++++ .../state/AndroidWelcomeScreenState.kt | 43 +++++ .../impl/welcome/state/WelcomeScreenState.kt | 22 +++ .../impl/src/main/res/values/localazy.xml | 9 + .../ftue/impl/DefaultFtueStateTests.kt | 115 ++++++++++++ .../impl/welcome/state/FakeWelcomeState.kt | 30 ++++ .../res/drawable/onboarding_icon_light.png | Bin 0 -> 44244 bytes .../messages/impl/timeline/TimelineView.kt | 3 + .../components/TimelineItemVirtualRow.kt | 5 +- .../TimelineEncryptedHistoryBannerView.kt | 69 +++++++ .../factories/TimelineItemsFactory.kt | 1 - .../virtual/TimelineItemVirtualFactory.kt | 9 +- ...eItemEncryptedHistoryBannerVirtualModel.kt | 21 +++ .../atomic/atoms/ElementLogoAtom.kt | 170 ++++++++++++++++++ .../atomic/atoms/InfoListItemMolecule.kt | 113 ++++++++++++ .../atomic/molecules/InfoListOrganism.kt | 79 ++++++++ .../atomic/pages/OnBoardingPage.kt | 3 + .../src/main/res/drawable/element_logo.xml | 26 +++ .../libraries/matrix/api/MatrixClient.kt | 2 - .../item/virtual/VirtualTimelineItem.kt | 1 + libraries/matrix/impl/build.gradle.kts | 4 + .../libraries/matrix/impl/RustMatrixClient.kt | 1 + .../auth/RustMatrixAuthenticationService.kt | 2 + .../matrix/impl/room/RustMatrixRoom.kt | 6 +- .../impl/timeline/RustMatrixTimeline.kt | 29 ++- .../TimelineEncryptedHistoryPostProcessor.kt | 74 ++++++++ ...melineEncryptedHistoryPostProcessorTest.kt | 115 ++++++++++++ .../sessionstorage/api/SessionData.kt | 5 +- .../session-storage/impl/build.gradle.kts | 4 +- .../sessionstorage/impl/SessionDataMapper.kt | 10 +- .../libraries/matrix/session/SessionData.sq | 6 +- .../impl/src/main/sqldelight/migrations/0.sqm | 8 + .../impl/src/main/sqldelight/migrations/1.sqm | 1 + .../impl/DatabaseSessionStoreTests.kt | 3 +- .../android/libraries/testtags/TestTags.kt | 5 + .../src/main/res/values-de/translations.xml | 6 - .../src/main/res/values-fr/translations.xml | 5 - .../src/main/res/values-sk/translations.xml | 6 - .../src/main/res/values/localazy.xml | 6 - ...ViewPreview-D-0_1_null,NEXUS_5,1.0,en].png | 3 + ...ViewPreview-N-0_2_null,NEXUS_5,1.0,en].png | 3 + ...ViewPreview-D-7_8_null,NEXUS_5,1.0,en].png | 3 + ...ViewPreview-N-7_9_null,NEXUS_5,1.0,en].png | 3 + ...goAtomPreview-D_0_null,NEXUS_5,1.0,en].png | 3 + ...goAtomPreview-N_1_null,NEXUS_5,1.0,en].png | 3 + ...leculePreview-D_0_null,NEXUS_5,1.0,en].png | 3 + ...leculePreview-N_1_null,NEXUS_5,1.0,en].png | 3 + tools/localazy/config.json | 6 + 62 files changed, 1714 insertions(+), 123 deletions(-) create mode 100644 .maestro/tests/assertions/assertWelcomeScreenDisplayed.yaml create mode 100644 features/ftue/api/build.gradle.kts create mode 100644 features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/FtueEntryPoint.kt create mode 100644 features/ftue/api/src/main/kotlin/io/element/android/features/ftue/api/state/FtueState.kt create mode 100644 features/ftue/impl/build.gradle.kts create mode 100644 features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/DefaultFtueEntryPoint.kt create mode 100644 features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt create mode 100644 features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt create mode 100644 features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeNode.kt create mode 100644 features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt create mode 100644 features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/AndroidWelcomeScreenState.kt create mode 100644 features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/state/WelcomeScreenState.kt create mode 100644 features/ftue/impl/src/main/res/values/localazy.xml create mode 100644 features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt create mode 100644 features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeState.kt create mode 100644 features/login/impl/src/main/res/drawable/onboarding_icon_light.png create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineEncryptedHistoryBannerView.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemEncryptedHistoryBannerVirtualModel.kt create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/ElementLogoAtom.kt create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/InfoListItemMolecule.kt create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/InfoListOrganism.kt create mode 100644 libraries/designsystem/src/main/res/drawable/element_logo.xml create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessor.kt create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/TimelineEncryptedHistoryPostProcessorTest.kt create mode 100644 libraries/session-storage/impl/src/main/sqldelight/migrations/0.sqm create mode 100644 libraries/session-storage/impl/src/main/sqldelight/migrations/1.sqm create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 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 0000000000000000000000000000000000000000..ffd8631c4777248a3eff841ae579fa91a724bc07 GIT binary patch literal 44244 zcmV)VK(D`vP)%0DYo!WJ}dXl=U ztEE~}_1w{6Ct=Xz&S{^^=RVcnD6^HB&aGs=livF&u6v?q zU%_0fOj;}6!N|Qiw+hp(a@+=8NHjA09`|FOV0JAZ6O9~e-<1dWs)K%H8I_lIj?(eE zUj94D_EBbvM()j@>RRRJvwcSP8=l|(Mj5AkZ{_z=-d4W8TSYRDyc@sTyB_^rR$;`K zm1MloA42UJY#I4c{m=gF&kpUP(;<$LKbHEL>)%gUYtq_Pu-e`n&JG+ueHW ztxlG?EcN*7ufINLA3n2g``Wqf7~8jeO*;qY<8jz;^n53}F594D`|uhkqt(u1wA!`o zK1bgX`|a7YCo8{0+g^Ug;<({w`wdrHhxlA= zD7$y>_7f8mIos?zIVQLLp@oG7-N!y(bImpW@y8z@e&#ryLCzJG_bY#Y;J^Vr#;(IT zZ@J|b-bXH-Z(ew@+LFh5zjX>Gc&w~yi<?il4=jnF4o@;QA9Bb#*?Qec_zEiecFZX*- ziIK|;D%Z_yf`JK*8Ouy*($n5OzX{AXDk)*cGE?TqqqX{bts*hkJrk}=#w)24t3u-? zS}{ACbSpEMZ4(uU!8bgK&F)Br+i$-;vVmg**Y?}CZCkW&-+10=Je7C5J7rLAlI3T0 za5@>BT?V!c>fYs-`x|5+KT@o}W&3vj!V5192f)64`|`_WdwI$B^0rQ=qt}HIVK|ny z&*+2OF)$AdXxp33X3jR4pM5xwZMWmNcX`g(wz2;M4?LjPxZ#Eybel0M=eBdQ&4};# z)Od>b(v@R(72hl~c-XECn2p~59m0fn2{SUe$)uN~Q54I(teTc4%)In_AE(u@ZVXsr z#C6Mn6}Jwlq*B^v7DH7;KLm!%OVVwHZ3giJnpzl`?p|Go=Twr&On#^kuA90q*ii3jAk9isZp=s@l~{&?bK7Rm$F?0icCbI&BR+G!i!Z)-_C9AUHlGglZj6xW^~Uz_n<*A5vqzT zjORv&MR_tUOTm)QGY<=IuPa&?60c;h4PPX7o7n)R{mT2SkZz_rmQ@;NQS2ZX z%WRfo$383S-!73N7l}IfIaLRXfs)%mw?W9@XFJ=HB+ZQDiSa*ivmxFUb%nP(iIYqgrLLwvSt+BtbfwzJI`-R_@t zdxjE|58n^(ZhCq;GX~d*N#3DJRmfCZnAEwe++_!Q*=I$2FlmX_jM~e(*}G+%(a3G1@PK^lG;}`$IOU`u)DQL2Ta{V{P8NSs8?Z$bcUV1}knjO;i0Wc2XvTDT?Db zmtz_59BY%raDUFjcO1vQ#kCyk9ADm#rERy*Foj*i7=-iLz1TUq_R*t9^YZ<%fBD|+ zoOWG~wMoPFsaC7Hk1;co0~3i%aM42#J(RuXHLuZQ<@d;#z>4S1j44%hEq)VIrLl{< z@>}h_`zf)dmsNi3MmIv(%4NngDkke;*j$;`6A*J=7_W>-mhHJ!_R}Tmr0M--NFHKv z*z9OF8nf@+Vq<*Awi`w5dVXjF6+d`%-+lM#ATfz1K!Zm*l^JbWYA~n=Lur+!FwG$b ziC!P6#CAT(sP`hi+?L|rY;ZGh%jR?0=`my$`>wnX=VM(|&76Jz z?(hCCx9@mHkt!L?AkX1#JD-ti<#U$F?vZoacSq%z&BZbHd0X)uFdq|+F}Co$ysOmD z&CR7uOvaqNORim(qAd9hOfuT}4`C0MY)Mo3k%autOt5AT8d^Hyjxj*St!5AA$H%X( zKu_0-&Ku*67{SNUYCs8OKBWRKSLE@~p_vHKW81c8q5xX#a&LX>Mg9|l+(A1^KP=2< z>K4q#42_x=m?{h)8-NT_+3p^D?6BOY48YBa>@)3cuxC?=pO*p7zLSGN1QTXi=2H{;!x$y9jA z)P2anAl7%I$+%!PU<<-bxOmKWxr(pQR`U^;#d!x{)|~Om_T0$!;Wl2Z+^aM;|>ZFFvSeNHL@jj5j+Q z%fKIxW@qR0hhd#%6wS*3ofOHM`ey`;VL%(eF)e>{GB_v3#-dElQE9g`2D1!On1eyA z%*Q(0jLHlzgBqsQXJemdpM5sJ^wLXpJK&FUGm+$th3x)FeU^a!{A1|v(1}V{JcXc3Paj;dDp0_W}lj5L=qa6C8A8Q z8Ft{^cRwPgMnFz+>Da!7>nC5$*ij&+m&%|W*Fh|7Tu|gC(DBoqK4v+j)6lu}ubfCejR-#PED9xo@6{bI5U-J+m>aKCj%5 zhxz8`htd(Im+NKWKBrxHzru8~L^GHcDG7T%{q$mnRK(Uan-i(XIyRw!JEoGtjNvza zLM0;-P~AL$HKLa8#qa2X53}SeR2$GH@UrSzrV+qy%^2^Tx)aKb#zT!nnJDU1W;R;_ zoX88aS<4I5tmgrp1WlY1RBX|y(d`70EPxS(;rZbrW>FacLqLMro>`wCQU*uV=`=*0 zn-f3S4pE~PWH2@5m}Zo?PL>@#>L+A?^yQddFB8`S4Az$YD_NckbC&EK23V`rl38>t zYqc`pmI2rll~W889q_ZstnbbCqGDjK_{Fh}+HKh`ywfUFOP)zax8t0wE-k@Os`BPx zG8vOW-VvtA1WeilQv^nn?E{gz#;EfAZcrPzrGrZ_kG^ObuzDYIdI!Jq?&Tiv<+-$x zy856l6U}@Ev&nY87onY^zQ!FnPA z#%3gt?B9`7HoYH@@_bRG1Us}3yR6ZW>$Y6CcPz7aD3{aiowlq*-Mq%bQ!|97pvU#_Zg?oI7gE_hW9X_`M1S zU($c&*|+JEiGv9zFnrT9DR@6AHs+Fi9`h{iH)JB>{U9;J1hA;KD60C?Vnd>;iU`hU z|1Q|iaXgy`JMkb0s@|haC>axK*F z?824|CRD1fNP_9e`>qV6ZoVV~z0quVKIeI(cn4kmQ3U|PIWNa{!V8goeOG4I7VF`C z@qI%XuU$NeFrZwo(b%lp`0m&-VLZNSwnW|Q=JgsU>%~~iS8>P}t1Ghm&nlg3iMQ(c&33c+ z$kNM*%CV2;8hGG=MUS9+A{cVcR7%#d-Eb+uXv~yH*eFmr1A-rqANkp57lm;K<*+JI zYp`L$iA?8hM?4 zicd0lWx$K95wJk6+mz3xos}8vgRJCrVVqu17%nhNp4a4K0D5Gw$ykVlsXC7DJ2K!K z9M;wIIFbK(V|dJbh9>1?KfN}Csar6T+^-vc<~d}5H41gBTimlSUaQrWdoG&}O`S|Q zyi+^_pKX9Po10<0FcArJa2x4e6F-+f>1zi;J=B6M(CIVjIGc z=q(1!;Cn2CF>EtH1_m@2gn1=qW)5Y{HSvDn%f+{)XGXX?E6$MH} zg^CQ6Kz(E##zTV1Z|YIKkYb_&=1tViX0Ga*jCkM3jmdIvk|@uY0gi4Movv;SCWD?$ z^?FUt+s_&J-p}X85bVQ}?cp8tOMXnw)j+L^L~Sm9=n!_*?;nzxerwGBwHhjN&n@p` z?|1o}oR53xc08YrF(0OsLcJ$RAo^g)E0#qIY%6Kvtm5xXOY|)l9Au7Ez%AA7QhAv z2w+Y;SI?Yiy5>vdn6i7qobmYBD^c4C??Hqx7}v%a2WM68vp5* zRnW7_d}p=hN|C5IWUw++Ybd5XtX>lpQ^f!9`XQ}`AAYCqr$P7P538_tbEjGwN!rRsM3)^Do%nZtt9byWjwB?m6-Lc zmPOS{C9YPNAb6n3hR(Y;37A z)$2w02z$K=oqTZ%VdG=l+c*gz41U~QI;QsSoe^zn!51l7CiPq~7X6*extYEI{f2{g z6nHrsbk-DhZ0&EP!m17G5PSlUJb7zD)*=$}U zOw$pAyB|538pqSXd^nTso&uQCh>&b)T943-`03ME^3l(nM{kSIaxD%) z`pV04gF?E>GiLR0e^gk?`@Np4iBw)3EM`bz-c@hDr|2hAQziX$2)OpWrl_$&Xf_H- z%XPb|G6ywvtdNALqo|_Mq^uV%65J`cHeBJutSr^^+0b&3eDZ$vGtv+H4~b{?UkDK# z&$0aoMFZ`WNngJUIhT)=)lPj~Ze)17m6mgE}nahw$m`5DnvFP!5UA;RJA}SUZ_KRwHL>TLsZ^_I!H?Z;IF?=0IH_daOtv>G9gzfcb6t^4Y0SFF$cjh_T^A;(sLYI_sz`C& zhLoG{D%u)#vpGRlHRHgh;u`@$tBywgruxGeiy5L=5gh1hkzO5WY)f#usLk zY1_xigov$UiuC23t`}`mCY&sM7W)kQWvtqYy0c5nYFv>cN(Mvl^Ah9cFZ3#KwQO(MqAB&H5W-D6ZialH>-|S{ep;r$;O1J zP)Xh%EQRCbPsDZ>B|aLsffFMqUpO+KyQ;$c(E>BQ5FEmHask#|R{Veq0loCJ*~v^<3=NpYIPp z`wTED1P91F2-itd%sAgpBqCwWHdjW%L zAf|vxG8y)=g;Z{`p5;qNQ+3^l`%rB)P|-L(D)ZS?o`{Acx^X_f5c|KIolRXjts#Th zkfLfT769{EUgM6pyd`>I+qV3z2QSO+U%21z+o5!JsBPml)VD=B zZcKfAf#Le%o~X*TRULCMAa)6#*#{>t+na*x4+edg_$1H!99tc}E10Y5s&bx+?2DYOAW(SAQeS zE!S@gbJ#hI(d-=InP9kzXTo3!0}cuXmu;%@CQ*{rn7LeFSW$dcb(WWu4~hT^-7ZX2 z=PCQ5=rB#>5LE`-q!4Ubp>=OwJ zTB$6N1;SVouMtS+LBa_EvoZoIGzvzL&q|?!8uDq`{g#kBg0inrJKM89^A5zG6{}wVLXlY zFtP#4I?PlsV{YHEl<%EwgK-xY<}=QNSsdPHFp)`UWjp79QEkcfxV|xZajoLq105W; zf9byLy1H&u-o`phyDm$aXv+7Uk0hSzbMUU1M8bKh;k$x4*;e=U(s5D7q=yvqBJI?+ ztxq}+gNh%}_hC@%%7#irTor<|U~uPX@03pX>S8ws#JD=6nnvhnN&48<(#%9hGwz={>)F?Rt> zx1&flh3QDmVLy;5)Jg)|Cq>GQUDAvSAR!tQc3iWWh~2h1-n_Yn?L_8yu-TGojE_tt zR0XpL1QJ_=KVdvY;P?}jMvenDR4b=~JtPc>R1~|Qi`^IZ7W+{opr8O5WP`_IOo|4U zsm+ur`WWml`7w1Jl56OaFsFc2%JE@cZ9CboBA|jCCx#OGT(M0gu&j!`DBC#~$FMHj z^?agwaZeTdlN&3}y>h$l$E1MW#<@gEu7+bGy>?;u>9yp!bSWmF?i=D=J~=s zWW(o@QLfw4D4e%1-}Sh5nuhcA@cW8=&hMAjlc?EDqxqC=f?zlLF)BIZ=i-Z-XhYG_qfRp=AV4{S z;~8rJc1hClJ9c!4UQ$hm@rd1ra3}=C3DwQ=*mp01Udq8HA;O2{yr&_MBIr>RMEITg zQ%~*I7sEr6fQo+(hq(FXa1$WyV6Nbo3rHhO7a-+CBTR@dWP7zbS!shx%6<~N+~}OIC~62R~3e)4e>PwRZ}h@Qn4ozaH`SN?L9BZ zsBh*?Px@3$a&1N^9Pz8zfbw3{{}&`+I}2%6Tk`q&#cccbN^bds=5)8fYaoz!Y^<-gD0Z8JxR4*;HLk^Ox;QOa74P zgJREV1~sz~>Jft)rehzAsYKe~_!hNNBwaP;rH)T%dT{1ukiE0EiG>={6W#*1tB@=Zs z3`jr?j5hYo<$IVcXOO#~Yjwhnj96jUULEl5#og!o+@yY%Cp4{J+Cl^s>(bkjLH4jZz|JSUd?KGf3cU3 z*T-YAfyG7PMRk-5$d3>;BE~}2^s4^L%_L6zq<-9wD+9KBGE27PWVJ-_oyn*&m`fKI ziH_knYx^LaGj*@D+i6DDNv{_|2}LtX1$0rDPBC^fD>{WV6s=3+Jgs`#og=xRMx|T&*al zbi`K$V=~AYkllVSlXE&4T2wJtm&?kK$vT~Z{O}QKTPl*dVhqpVXVBujC+;ywPC-X2 zbr;n@Jq2xynZ>Ly8AeZ5mJN*tOj+Ga4xktGWdVrh_ z$;_OY0fNMx5Zxm0BFCF1$I5;6MM}lt2j}9#lUbkJL)hm7@dkLgqpV>3ls_gD7Z@;d zeKE%?F3Vv=(F;ZOlpT6m5w#NKvE294QXgexiCsMomT(2jGXW4}&p^&giEu&SI0udVu3NwRqKmi9On&{>U$ec{ z8lMp9eNm&K&f^$?IzHa2m9n(;J?f|}y#u-bby>yjbV*dUWOSQM7#V-|S$^My@qjd8 z`TX+-UszgNSUh%YZvM+({>t-vKKcjGGiiwo%@W}lOaN5PR4Efx73uB?2~0f^c5;qJ zRL2jQh%3j5OCd_zqVLpOk#DK=7UgCjzn}1yBTQS|KJ9KEg0mN8toT>}7Ln#+#8(#c zOk7fvPF2x#R@BYP?5xHF%lOK!xZ(<=o8PgpkimS{@7hIRoVp*Va6l1a%WgNVZZs6q z`xnQ1XLQs^w1Y0ofQp6OSV35b%3h!S>}Nf-CS6g13NQpoLfBU*r;rX^129f-ni4{U zpH3X%X+0`bF-0p0V*-%LWG-~IuE(j1>e$Oga)~X6q^sr?wcUcsH}F6suCU|8P*GbB zMC0A>e%JP!Z~l%OF4?i;l8LdgnlOF6U>N+e1iBn9mPgzMoemMtj3=N~tZ$i}o9l^B zwQ+2Aw!8P?N51^zlYjTzU3Y!(aikzNYgMF|_+5k%EVof zf{w~mDa`NH9>ng$<Pdi-=0cqPh08171^ zGh}4h;7BHAIzgc(WO-CT6zWtCbUG6VT~I91MLp_QN5kMdJ|!kF3-CYDB!UA*3Xb$t z+JSnBghPGHNVfEidOaR=(ETeS;Noca>s~wlqd)r3|Nhr~-K%zpYC9o63ciZR-3DW= z7449}%S4(^fpG7A^wGcl*dKrVf#3W6j~)=!8kJMkQdG)fG_kyH5dQ|PVW^eY2Y*v*m+4o6L4x#7X`+vh-nu`U=2U>vp;pi&ENTs8)s&=G82E^ zZ7|w;A*AB#jvP6%^~+y==)NEP!GHQE!c=JzP*E{$VMehJWhk&+%mzRim@vU{FEeZI z`^tpmOlE_@;S{C>_3T9B`MB=@gyFAKrkteK=?gEUem2s&FsR5dA(HV;Z+esB5Bv8w z^BZm$&t)uU_uLaI*AdfM%4UF8i|0ITdNB&B8kog$MItMJN=WUw#%uARLgE#blr`$Z z%E9$|<3-86c1$8#JW&ENd>V?X$V|J$Dw zE{RN4(zJ)v%f!mgJ82h{6V)?|5@E(clpzUGJCkH7?IR5Zpo+$Y;cz>MmymEK^8RED zs@=M{SW98N?c2BKl$Aj8v7OR{EG@bvxD`V6aoh{#m`;Cun}O42zSCm7yi|^_xWF*u zpq$vAqX;om<{Zsn&r2=D*mgP$ct(*czUa4Y42Z5U58ZFSw!cCC-Wv1Jv0E+lxq7c#*m<~xslu4se zuM!mjvx#o+>vDdvu;YLC?|$JMzIgv%|L7av_(nn?KH@f*?n0y^tyc5TzV%zbW#Q3B zANv1(&wFmWEDlgfMU0MWJ*oqd#0s8?q6nylNm8k_WQQndr&;RH5fB*NsWoGNvPr41Y^5sCnfUW=f zOTYAgy#1pe-SaN}y>;U45sIT}Q@yMkTew?@N~#hlq2or!NE>`$Do$l=HHw7WESnRJ zi{$CdQxnuG$1y$$&L@frKQkeXR5ByH@dHmirMzOL0!ipD1B-5@U;C7FCaPwhudDz) z1XWDcq*S`q!6zJyOamHpnCoC_D)`q3WE~6+DZ|aq4q}Q~)dp3~#?A%7tZD98kaWaR z%IrrBpfFj_hfHk|b20_C+o+YXxCGvL$-n=_U-;+EM&r-ihJ<^$(iSmQ-Y(KK10mtb6@V@$DTFJMZ4i&`oaa#UPt!0-}3^xQ2g4m*>P)7 zSQkbFXrXBCOE08{Isu|k zdXI?kG~FudWlwx+<54o!k1o=xU(cd9$2iCd3rdoK0Fh+*Q@5k7x;hL<#Q>Cf@zuqk zmtxmxv_H=#26pui{@^43SX4~z=|Q(auOxc?SHALae)GHD^Sh!NC1ub>BwI>k+uU3? ziQ`TJVAAhvfOu+tz99W7!c;v)Fay|9&B*Dad?3tbLDhn4W}%G44LNmy@bxj?T?kZK zQ7mbd^%K%bZ9L0OJ5{llJMX+nJEkJ(KpU;zC6aJ@dSZADs%4N}4%6{j9OLRXAUHQ8 z=CX$VUA0VsSeXuKCvO3!!ygx-QKHJHKK19f3)3BS8%*~~qz7O7+E?%YvycDrdqWyj zrYTu~c%a`|)}tB$RkAfrN%+xI+Jo;VC$RyS{BqoH?n43oInDYc6Nb29>^%ZKu=O;P z{qjZaqX>>+E#hoBLh5EaRUGFj!IiSHSazBh5h|KvclX^h5w-03!b)r-?Shm11&_3& zMh1R53dSPoShC+@T8pR)N#(A(yr5hrtsw1s!(6(ohkZBj1MC?QK{NA;_<;M=tcpknBloY4TdG=7q#Up%sftx!KBB7tS_mmi6rm4kL{BMlf9nuvamx{0D>BtjMki&w0#LDa zUGR^!Hzp<%2P4vs&sK1jnTI4pW(`r3L$>)`kakpK)|!_2qHoL06}ov6g5yo72d#P4Xv?iOBwf%qepA zLLJz9hqZ*td{X$kPb&6KY2%G<3Zj+%V_^f%#ZdWK`*If!Nc_nPcYwC z|KyK9_C0ytkImoL?+akpUj#3p=vCCJjEP3o(Hc9H2+8VMM8lU>PJZ83D7IGVx(8!)Wx0a(=RwN9(Bpa`+YTAhdl6jN=zy~%(l;@k9+ZGw9 z!p&ijMiK{l9fgHA;ILAZbO*FXH7alJ+qQ#5EV`AS|X##AWp0npOz&Wb5i6$iwW zRt(4HvwA?dx>^@std*;2SAEr0S4Bl%#i3wftmBXVzz5W+9u>8PoJF#{XdBPaOxDY_ zRLaCGuvgp%h+hj0K6Xs>=ioL7H7|1?P|Q@b4Um@ng;21b+Iy;A zBK7)t{GQw1ed!zC@cJ3IfnILVsp-MDyyeC#zx!S9yi_B2qgX=>izp`_3R>k9&!VW6 zRt+ZzU(j;j+!Aa(&3>0LJ1hQX)(L~;#5mLuEU2Nl7N|^(t#{zS0Rj%;$BP zeX3bhm6y<-(CHFm<>!9x{dc_ajk~8vb|%6} z$5bU#z%B&ai?&tGhFTZnG1ZD?)3Nn$7{}v2Iq3me7}plZ8Tv6UYGrCZUvkMMLxhvh zg|s`R)H@+t=A{C)cb3{}vBW=jtkepbfKnJw^y~^TnTO;?$NSBg)>xu1s~ds*Ix-Iy zlWIIs9g90zN4%a(xG}ZNo0ilQG%BoT+92ekAN|O?cJICc^>RbS+@Ld|El)i0ch|n{ zZQuT1iEJUXjQ@yiX+%_`?@)*(^^rLHG|FWxqkwKdnNP;G8e7QU!?7N1*^C z-3Fa09l84IuWP>l=l?%%a08KgGPD?9qAU7d-czuJni5sa9nsjxU~p8IQ_Za9k$M1v z8pw|p_jS`gnV!bwpfz-;$qCZ|EQ0EP-~%6sgy~lFT_~GttI>&UykUXbVw2iZV`r+N0|u;N%KAk&U-vZ!7DtYu8?X z#~m~I!Go7+kSGa)h+aChpnh7Tz-Jcr=c0r9&{QPzOInjci(-A=EHmhk)R1$2$^N>xRaQ2t9~(>{$nS!{s*`bW@dup z9EnFkS`5$w2RiYDILw(PGJ$2AA$B}89~9e8V=>zl{!S~cxCOGLahE-8I?9LFe09(n zL{B{N=#Pn*|5LX?8}t%M%%At%dh0*=~O5lDL|M>R?e4 zRYTu^qR&8<#~=Q0|MeZ;_HExv-0CaVw%c>?u^pe8-FMl6&cc?1y~VBVJZ(PLUDRH~ zJ(o87Y0@h1UyCd4x{EsvA9d<+wY|04II^uie)#&anf=#KZaaM2wrgL>F|Au3dide1 z-~R14|6ek(r)dwcB~HYK%P5!x6E+$YNg`)Y0G4<_i{5Nf7*3ceiKeHsig4!qd?lNi zSxy-u6GFy(_KAJ_@|y%;;X+mhko8gx&NE^>Kbm)E(heshNTyW{16UzhwbN1|=A2%9xWjt#*k%_Z>tTHCju^pN=cC})2HDnN{72uV>efWWY`AU*@Pc0p7{qnQ- zzwYt*BbPnVK634$q_e}F1KLraeEjnIJw3)-@< zkW!C@Vqfyn3L*{N3#&=JlS#Z`r_4Qj_UL|~Hx&A`_)Hj&ECdUP&ax@7^<4G&=bz6T zjV;>eI*!MyT1zEe6gR>I=~KdF-6*2tX_{(R8Kfp%bAJ1`f8!nB@-5#&j?~L~hW^WA z`?mbYr|-M*Z^^BceM-}n90PyNgn zk$SbLp7yd{5~Xp11cM1_y+8q_0X5%y<5_Si^wX)SP!u<~7q(}uRwqT`aZ(axQ9A)x zZn@>^ORnvRhBL}*h~!Qy;Jo8R@W@BZKyAN=yMDyn9$C(^Ew7L>q$gDA>q-q-83@@QNTz*JnTUzJK$?r{4GFg%@tT5SR}7%HqK{-SWVmf5*M^4DRJapa0Q+@z39a zkC>vzw1p#c!P=JTc1Mn0O-#B5&T&m&%kvOIcCok?rZi(=S^-A(C0Yv~AP#lA$gW+t z9v9cVnls&x?oh4?4~Z*bg~TI94aUQT0IErt7j-fxFCULuVylV0CaPzh&YzU`jdHDv znS@O8tRs@WS?P+67t3btCaN`5vdVq;eg0ova>*rs;x2Gvu>RV<`)^rq>g0a!(7yS`zyH?%qJfzLv;eY*tv8@3uKbq(vY>uy33vfpCPdZj_h(Zol0{Kl z3*lPJF98=(Gqox;HCb-B;fCD$sf(>=u7(rVVmVIYm6%Rb9FTS}9b8^!I=CGplZ4ml z=_%g|0EtK1ad5NQ@UC-&?mk1OWM~X2Gfbs|>!HY53bVoFMl?c#ya-ycfacr#vSp%onYp;R;3lu zwp)D4syZH=Kd7C1+wG3zA`j`Ld-(8^6 z@9}rO^XAvV2Xtw#R9y&|4!L^tjE{P*SJ-6P@Q-SEA^fowEiH8fL$NEHInN7d8{ z3f-NahCNo3$Q2dG+$tdEDB}&gpO}ce?Y6ys_wL&x^w%ef#*!xS$ho6B^c7c3>WLQ@ z>G$2qX$;tCY^Lc*I6twNu0IsFsF?tomAIjeBX{lEb%VRWe0R+J@A=9{fBNO5R-P`w zfZr|8egR6qBbV;jajB}9v;)X1+L~K~rDfdrh3RT}&DV?g&1N$cL=wrT%+PMP#c$V) z(D1D6y9f+S*}Y`Hrg`}3PWH@p9WRk_Xv^+duW{SJI-VauPt;d?F^-|P6;ST#Wdbab-MJ;hfv zd`ghb&SJ+!4M5K{Z)wZ=F;8##X0s;Dc?`EhG&i>|L+Idm7o3BvoJjBwe0p@1O?U6! z?eDnbZP`sXJ!1VvhW*mZZiUq(UU?}8-m8WqCH&xQ{C5B=6oDZPNF3;e2wtMCZRE$h z!*)gWyzVD!lS8PMheNmKkL+?sAM#o)mhD}40TAldKe+#Q-uFspx-$K>yo(D-^_-g8 zR2M0y63@vPWGn`dC620NRXD@fs#O}TYtx*7RV*qHN(n&n^hCgD3LF_ZAe?sYojY&w zNIV+&@Yxc_iFqFN;?IyM#v2ZHcxQS#j zi-b#kBjaXsKi`{kV}K~~FhvSI4J7fCAT@cQT$p#=OT6Db_~?%JJ^DZISV(&pG_rRt z2-Oqd<+oq^u3uX3ZU+JJ81qHm95!B+$Z0pbG@I9>hOafNevrgoz=*sjDrHvR1PGH( z$=IgFm-z9>#-TSzmL$`D`}T%n7(}rgI&`<&z5C66X6D$ikKUd=drr@RUhegUeYbb7 zi@M!o>PkS(gjoPqw5So;=99s`BPWmIpyZZ;0nrL)hJIazA%eK%L^ROm?^Ep&d-{pv2;d8m(rqy97-^6OD=CEos`9+&H z9mt7TAuNXYOqw{rWN?ZMyu)KiqcJhbeW zD>ZX9TA^x|;>vtcRm@S|Y+{1o=tKDf5A-MkBT{X#fTa6$a4%|GBUHfZL!m zB70~5@w$KbIk*1k$hFsAdyy<33&9)OGGImU27)4rYJRF-7gH$`2YQ_KdKDStxC{=< zc-|_C9b>(zxp{OKLgERyl705G$3)et8Fm6f%|uPi z^TdGZ$^kvnD@Ue=3ECW|$DR^-!M6TC$vm#XxBAw7&$Sp0k^Q9%i z$NEFQ5gX56cG+c`lZTy0gcqS>k34dBz8b?B=?N@GvsAz~FPHP9(MYrT0*-Sn^*|{< z9EjuNy^u;F{*6xD&GP0JK{jh*&-L@J>v1z~;88XE`cN3LMnP(-s#$UCh+w)6^z!Tx zXy-qD=$<oZc{afI~27vR~$faG>9h68(xt>6D|Pq1 z7r%P=_s!Fnk*Mk}G@_2o4?XthH@kI4i8|25{22r@D8w=^``iv4KAZutplYJX;Zk5s z&xs_QE}iF!E3U{vHc>Sx_6(If2N{*mJ*;{=A`<^1AE7*F)i7^lg7pa1kf zJKv0l4MM>4da>(}ZohWj+t27281T2Ad)R&K(35VxlIS_@kG%G~{_XjWZh`UM_@;0E zFK~?@6=EDkniMM^`$ZxwjHWeN2pSRIN_%mf^#}7w)aht7GF%Y+iS)yzkRkQ3^XR33 zy7{3GeJCwmvK)%3j5ky@M-0KD58fdU(@`-RLY!jXu`cMQE(x6x@4sl53m$PSSC4{s zpsOKeG82$s=)}?s&~2rvF`5wJVs0JKdIM8VCh1N@@7Q{k`|a1f%RPMK?aH|8kpNqM z_>n*TL3f@>@jNqgk|bwM1WO7Zv|7!m?+1Ck2x62mEMqi8u+YYfCHv8`nK)%MKeoXe zFe&T!zD3$aPpCGe3sTD z5rvs>zU};4?X8JLq#ssF>>wOu4)<>0T^xEYcDNyy7LC z=UfqKROdULXuLJ9fF6-knxO3fPwKHqwp@lOvBi`$aUma1#gAfm)6 zvnmh66YvIUb7+R}O_7PNWIR7P za&oui0jcwR*pQ@1G~}VGRfTTVfF-aR20WjPhl=?>jy$#QG7aXEmmW_513o0o_hO{y zxu%aFdE(~tjdG@iBB&ZzVRA7(#f6aJ_(EvZY6y_7>m2R4yxH*W!<`)MPTU2GY+`c3 za2$K(l~+1SKnQPSG^@0bv7-6Uan;6FpqfQD%c07NKY}IY9*}r==q(L``hvDTrZ8z! zeEYeG-|fO^lX}BN930i9^`T%s7!G?B22AL2ejfdJKmFui-n<@N2X{U5C3m(6##?XF z50&%#_kQfW0$Fjq4EF~_Q~00~DvsGc8o{DnV+Eo^DT&;z8P*IH+wEDMET+Uh)S3q!t;c^_F#ir^nv z8sx=8xiGsFpw4-;@sM`wwMNQa&)lzw<=G;n-^btZJ?qL9K@FE*dFH^M;@CL3i`OA;?j%8%mz>K)kkai&bxY!U%FdwKU^;b|gizX(%++T9k##2T> z&9nyU>|?4XVPd!xSZ=4#ic<%8L~atu4oR@BX4AL)m>QzoXb{yk_6v!z19O@?+!94< zg`hjUXtYGGC} zk*;c{ph|_fUvr}N$nqy1|CBQ0 znb4Ftk@2$mE^Psxw?fOBX#um($7uo7Q z@oQ(8LW8-O05>xT>FqQY`Y6V z+MO=#>(09$7KsQ0vVJ;*+WA|;eCvVEo79VKz8?yPsM=DOSKcb>zt(D#-fTRjHoUUn zF|7z2v+4_Ifj8W6gAZBgp*K3q0y9|^#~6YurHZ$@q`@9xFJva*pCAYnl@rxc3`x~- zVLYuZpKgs>f@(H0&BK#fVp4OO^n3)JE>lsFcThOb{hEU-Aert0A!0R=Oe{}DFdfYI z@4tF_xu>X`zj*bxy7fT6_uQjv4|1hC>E}hEWs!P=e9#`KG^31P5Efm{PT&u4Wl1=% z)td3<$3xAemjbv4fznSt`J|%%c8Q|C=5c}2EQjDqxze_ujAk;c58-@3?HY240_UdaQHwb?3vcXP864AFSp|(9{^U!6)4l zp);Qe^F_@@!!y<~uOJG2QQ{Jbhc-x!P0)XE5xzeiRddCcmJ(!800hG z_yecKd>=dfq@b=O!ijz|S|RbunF&_*q3qA&WTL;iUAuN!S-+wBNC*uGPqVRE zOFo3HD}V4oA@vaFsF_+*)0xy;JG$qE$KK}7Gr>4WHY6F#x152*0T{V!*LR%Oz5p<| zBThv~y!9aUzA*Rf>)jesuFXpVdTHaaTHq0;rHN$AG4Fh{7aH-jwhoUsZ4Z|MD(7?0 zJ*U2IOf!pZhDL0>VGo5BsAhRbmZ+L0Hi1Y5R8fZ^Nw6R~2Ck?RU1kIHVA*qM;}wp` zkUn2&Ml>if@lLGfTBhTnW*!=J&s!M+B%CoEl5MK;;!25L(U>~zeUW@P<4;BFLFxgr zeCo*4TiqHFrBF=k!nZ88nL5zh0-QKp3L?=!HIaJA5t^5nKSAAR1r_MNH>pSZ9I?$1 zkt-&^M)O?DsAie@^dMAC2-}VdO02_l1W4n@GqToP++1Ftiiqs)_ZL7gqoUl3+ISYD z>4u>?Zq4Y|4?O%jcb@3xnX85j_fqLh9q2#t#HZY+PvP%dFH-MU_uv0bZjFdJKlDv- zL$n?OTtVV7Ey8q+W45EsVT9r~1Jx{>S&u&YDC((J8pGd>d1m33;n?MPO;_;Qafl;S zHV6@$*mn5w%7kHQ;x=z(zJt2SNqjFj&bdK+Bg`KB$$upW`tae`ioP(r{|2|lWKxcF zofj+NM(HaHFP!Ni?iZgrHPZQxnXBFTrbibKU+dP07=a|*xnkUha7I3PVvDtCvlvyf zp-!rsN2na>M)_EAJZdOQ)J&6hApb#?xCsBq-L!qN- zm3U>YZwjiJtDRT(=*7gYjfJLvv>qCUi2>$zVefaOU+%+}g>cSRARbvC4&5iWz% zbr+NgiFYcg_q`WiFOx&VolipDTq|@_T<3-{%+cd9aR&FN7xuVW7jD& zTeIw# zxsYT;ab0rBCFOX{%+yd^a)l7f&0An4G>?m1DL!lI2kL5KGFjn6;%^=f`BV4f~3QFYHK3#p6Sf5334#4LC9yubT9(}&qSRNdr1r? zga2$Yozce*J#o_SUo&z3CEnxRqiZ%vmVLY9K@!%*(|S+oX?7B@%O7_EvZai%IKswM_!N3#okxUDtibX)_%X4#W|LgW2ZRpi0=sPf-ZzR9Ooa!gem{aKF7K zlVsV>#kU=rr;bAcAlLJ8xK{KAZh1LoPD!G z;+5*ISQ4K`t^lrp+@Qx2mqwV3#cJwF$V892dDNTDTCF~%&xGG{-wzL=@ozXL>4`^F zxH-<(!kzHD&+T24ACgRO+y$Wl4!W z2Om4<{?^gRDYNeYSH^_77`Fjn>C5;Jz0fUkh4Ie#$CnK+h=kMav?zb$8{Zg}(t=hq zrIu06a+NXjH6R+z7Xi2esDgJMW<#%qka)q?YeX(1fYo02MY^qW_ZX4?JXoL;Vd#-}4 zKX-KBIiD;=QM9Vg!^&ZmX2?od(q89dQE;G(IaJI=O_6w3?nI=WZX;p9)J`&ZaJ}IP zC?uXMC6*hlEOunl&T2#8KsR3m*$I&OD^N9AB5sB2#p{1K7Vrb(a0Erw@;)#<0`rQ z9H^fAdkg1$vP8xdt0o9U5-hvb2Feq`rGTnQIg?KJXl|uWAeay#WHw&tk+)3XB(5m^ zP|E&UJv*WJa(IH>yLS%*Kdq*wshX@4tBKyrO{q&kC)A#^?DM6;AalbOU{z-inQO`S z^qkvz2m9@F`Xk=F^(sLFPpx9YaKH8ZUU$9;wDe5qEz>8Tx4wDPkOlzzaKK&&QVmZiuQ165pkSt)%v~XH>NWXnff5ba3KedCLs$u6_(izPT&a8Cc*^0P16Kdya)17M)tf3 zDkEXz(J5%hjvYgH0*DT(Ca9+FgmaOGln!?SdT>t*gVst64P;xSV7QQWAG>o+-K>Ak zA|Z%xJXLl)SW;Msqg~f zknqLyblnN8nN#AAI8D?`{@?)%4>Je4Fk56<=%r#uqlvJ(EQgG(h}Fc#v+M*=O)GX} z!O}*-nHVD28jZ$^nuIJ-Ije=^Jv>~9(Bz?e;*E6Yn7X3>x^qPDz4-c5?(-8*eDb^i zDJP=4_J8Swr2~KB@lPJN?NkzPU+2tJ*Y!eC6au6{H!Z44^Y98@FmQ$N$9i5zx(&-& z!gvTBTnF%unP3k+^iW3K6?g`1@sx?Ms}W+-vJ)(148o5`@?W{oOS^q2@5b66z0hZS z>U<0yf3FVoJQSD7n@wd-M0Fn5&wUl?UY?#aiFYdZ0ad`(V-pc89T*~H=GDJP1vAo! zM(I>xZ|9;*QQu|fT-5|$z@H4jX9c+Qi&7{V!h;NSm=B@SEL2heMG|36MHTZ)$1z%A z+3y`rm#=C|4lBNtH^QZeNto~U+iyqg7WD@Q7KI&I`F2!Bbl!!e7Bw2;tCI^QwF%K7 zQ@wflF7Ap=mvx2FDlu+`lx-Y8Sc4$xbJdIlraM(o@xOm+eb{xY5v2q9I~ko$s?bA= zXRq+CQEv?z$lySaea<*ei;4*3jQXseMX79q5oxT7%gg$|Hk+FTuSf+@tXjPl(&T)R zDyO9%;1$sj)lY`j`2P35UoWTs6yajfibU8_(w-JOLgGF1Oz3}3As2dpu@0(fdTuzO zc=N@9j>PK}?gRr}jx8+>)k$U+oamO57w6}vuIAaG)3NQ&ciiJkrBk7`4=o;Y7b+E* zF~f;pH8bFqcB12TScsYLiV(<-@5;FEpzSHXVU_myBWKmlMbAha=m@T&NVH-or84d- zRm~zDC^ujQ#)Er-NKr3o!%XB#xyl#}=zUvDKTvZ8p9kW3a8NjLrv>1QyouS0=^I|As>&r zLe!8IU{SZ@PAC_&g}_4yl*WOM)<}?a!B6I>*Zo$jtI0NRRmSj55GalQYO$ISViuy} zl1e@LmKrC(OGX+TZZ_}`t0-M2q~5A+x=K6b zR%Oh#0h>}Om#kciUIAP|>QQ45iANlphKzNxqNZjY?sLJN;00T$tY9*jZY?T})#6Iq zon<^FXYQG4b5CZC>&e)1G`SU_q&cKF;(F7+4*=JEiAy}7wCPQ_nmfC@?ACVc3cOyakl)QTOIQBCW_H5$1xB)Afl6T^_t z1WBW6VmPA5nrJ00h!(WyyGwc|O1B486Bh!tDwF|>#wF$}u~KS0^jegDsQdV=|A?nz zmep(@BL6AR_ue7|>or9|*^j^BwspsRMB|*whW*$%l;|}Vb=(?I@Zl|2y;FBW%@0eL z0Iq0eI;9^dXxm;nln_3IjNwiIT(Rw_nwAz+Vm!0+R&^&RS6T+TsG2z;V|e4Kxk8XM zswNl1YfqbI_M6c1ez*yi!6?FPv<0&`BCBInKVsjlS*+%{pv#*luk^KAATs?5R`ZF$ zkat}62De^F>%?4h>e$oYe(v0bzpa(TTh0$%&d0-@FkbYhS+4XjTKZx5MWJeH5A+*uxPc6G-G1G5*9~i~5TQ-1 zCKtu<>~z{L)TgNVq56vRT(FhIFLw)a7QeOzs^-Pj@%?U%X?C#Ew}onYGJEOfbx>%? z=qFxvI*t%K{XB8N8$+v zqQ#DICy*8d^A*x=SmMvrSJp>uJYAZq398A7OlRff5blI>m9cizR*8p`vDfS8!TFdE z(}4!Ud3M7_YbEVkmFimA3qu-}c&Ab&&qqAuYDB8f=@e%=*Lj3ntBoV>TvM`K-Vah9 z(CQCb&{oH4aEUb()$DY_Fj{hB8m(0>^i@VSk$5=KiPbFXYOc7f@!DuAsxLYDhIJzV zSDtv{37p}c8iHa-YEc*Z`_Zg2GurEVCisC%Zdajp8E-th{%P8n+a5P%oMAR37)6?dw6gomM(WKRdu6ydk z6KR){TmT9Gl&eV(k1T|FCq#bG|63?paGS}DZQ(h+v zYOTmcs;%HoD5;tOWO3)7b~Go>I^$W@uM>TDs)&$Ly8IlMQ#cjDaKCn%rQv^km3Ypx zO|PEVcFz42hNu%WTtu#9RFg)y_|R+zkUnQb1Fld{2zP>hFJsyy9?}lx0{<`~R#UK% zVK9oh5}?t@6{ze|@Yb{h$wn}!*?3$C$GOF7YDVrNX+fI{J+`L_grS;rA0$(+VLrSe zq6wnp&OLq8#3kp%#-m=!XsQpk4<-0U>2yl+oe$;%NTJy7nPkuJI|oIWMrqgh%sE#z z^E_Trc#H7~>fY8;mHE(}P^-;&B3HTsuw=q~0xW7cS(yi@#0MXcI)@*RKxtIXqD@zB zt^~WZUbp;VlnMvB`tdrQVDDP{StK1&PiBB2M5-m+JKp)~G8`*hmv@7Iq$us@T?m$L zMuYs^=i`0P#n(UcBai=&_PL1JWFYc~e{NRGDx5fA_*A9!NGLs%&aN+&?%44L@zuTg zwCzjO%eBfWylvaHYXFF)!?d6*QvtE!TT7Kgc+{t+{M`Hk84WJVn$hHBH7(n9)La4I zD9T@|1HIfG)=ueys_BQS=CF!xL6#O|pOJVVHp5#kiX0U|(ov(ag{(pK?$gq!)Ra=C z8)-q6W~FG+6(YUmfl{{yHCLdisB+YuL!uPlsZxJVReJq3YtWb6T7%)h$7OK3Oc%P& ze!{HY>H6~|&6v!o|*gA}nAt`U7`{ge_vyv_dqHD>0l=)Xk4|61S-_EjM;l zPfdU~vqx9dRBikxdNe8ZjYd-*s0XUKCPC6)KfdLe&&{s6J8uP@Dv}@R1CzX1{}@d6 z!@^+r1kaYeXiT>jcAe2TZQijalVn{c{=p#0C}9H1ZetT71HFPPO!^)+D-|JUhmx`I8k`8MJeAqBCwoW?NlR`c6CdTc~MU%1*Gc zkMtvR2XKXAwrnfFl~F(5(4;eoH%wcwo(ZxFIs@lYu+!gn-+jbtf^cF?W@6vb38?6y z&=9xcf?Mb&d7e$mLtx`28VQh+@ahJ6T-DrISKO#C^yD>>cvp>WZeJ$0!&-L?8l~TQ zZtqDQ)Yhl&Or_rGM4Oxmh2l(~UGtI^*mpbSw{mMlfGha%jPNx_wOAXhitj9HMlMbC z9YoGYz3fPLF288vBKbavirOm~DRO4-k-;N&Ui`=-k7OX7Pyvw+uW?)n_wo$^e;T-g z#Iw4Z_$okf@aX}i!s&SNNtMX5hR4Rkoj{lk`wt3#2+;s!CoH$>3I&g>?!{Wrb>rvX z&inXjIG*vRooUeYYbVYQFax8#_bY#R-s;4BSWJ}P(Kf>sL|TTvc@40PhnKxn9|i60Lx zx56$E@oL<}f);@3<^ucZO==y&f`C}7W3^#@dbY8Sv|GzW*dM+88_szLnp4r~^r^dC zt5Uzooe7;|M9W#w0^yRugF|G4xuYsPq%mKI!F6;(Ag(4!z2gz}Z7x%K-CS>DKf zG)`m65He;(k8FQ%4kVuNgz{9ucx6w)3I<%^PB89-0gEcQT36E~9%(^jpre9P-p>lY zj16x#6Z>s0CtkB&FQ96&&sRuPpO+uptr308rpu39wuVskiRg5?v!ad?!gadzVnfo9 zYDD0`aOb-k+SXd@8Ga+TMnr%e?X;LbCB`ZOc^Q8`bS`6vy4D&VF} zMA%qsFd}f)8bDc%{?XQ}|H7>)`P?KP<59*xV}IH2rXULwQ8%QaH$!w*yB(yQrNm+5 zkt%oDWtR#B9P$+7Xs`wITyu8&e`? z6MBc$y-K@kH3XR+U0TX2ahx@xM%*iq6+uYF7}em^yfP9kaIWd!T=`9Zeb3>?Z|Sc= z6|0jGj6w$bsZ>OZpFCCRcNmBAd>@qkM-y22?xBT)Div{Qkifd0>U9v&Sas~4d+r(f@usGx;>k`r|dn zn;!oizzTIOk~LH{A<)fTtt}5exbVWc>oIj2q}8RL`n>zaYrgfA`@(!A-kvI4EBM@g zPyK;JsGKTew<5g26XSp6YXP16P;JOPwMDZ0R}C~CW+BW!u2Ev?uQ8R&2^NG4(w?zZ+^>>C(w zOq0*sdDAyGO{?Dzk3Zj6^N^QU)U+5@l6K1VYoCFB>qTFG-a!_~I=`L{5yVWG*XcBc zgxO9fgl#?1&tHAhJ#LMuho5Vhhl-CmG^Uhso{3#v^2mZAH_PeAKpE5i({U3|v7TA|^T5@`^xg$~9NY4M#e0H{*u94>vVC zs~r^dJ3?=Yp3&MGNghyh@m#1Ty{4UEp&z~MjgPMfkOe~JJYD+=w3CppU%T$@r|jH% zArZd2wU%}es$Mx6yKpIhyhki4R13J$Rnxg6%U<4UHoX@?Gwc`QLJxG&29-Ck@dz0M z7b#lYc{frldPRA^)d=y3v?Gn(wpl?JE@nN@4H0eBH$`>%>vp=1Iu*5|ZUy`F`^!Dq z;y5(__e+kcA+!j51)tM!p0onO38;k zWTtA-P!(Xl(SY+pL>8zfrA|;IsW1i)2*V9?R7d;cnVJc+p>~$dxm9yfEqRaiH^2GK zh*1rPaY0IXkQ_=s5IVyjLDQXjEpNzQo?&1{GAr-^qB9G3fo>A7nGIqjWfsSgifu~P zyYoOuy$|kt;Jin&KrkInYlrDy(LX_KxmcJg7RDpWJ5H)oaXm}r z`#7GM$Rcqw#3U}s{*^{l7sp3R&jkJ|@}L#DiR!}-KlsCflK!b%WBQe6?z`sSJn@(B zbL)gI*FNLlb5=vY4DCD0idRg%S8Njsf{asFror1g7$0V3c%wf zmt3O!Te9ygd}lDU$Wb3t*U1FmQw|5$aHYs!)k zNJTWqx?mOB4c1i6G6BYr{ECeyQZBRdenndZBR^{6nY6R|e?uPxMk3|?&|eGWMi0P= zPTfI`)E2$ccaDfS_IXEdmXc^X%j!9c%-m7(E?$eOD<`J^w(A{t*Ve9T@hJ)BqlMPl z%=A8;u10H36LIax`9Ly_ia}pu2(LoH1cTLFVI7c#r4|`mD%@r+B!kmC0W}i>Sh1Gn zx8Hud9)qfBhTWF5?Ll1q?H*AIKi7ziS0Sqv!2zxnp zam`W9GX1w#-|`ph#l27_m=5Nnf#geJK#pAxAz?;HyI+@g?#?46{m_YcwCWv8Kk#{9 zGZVsgE$!EITGm8d3fZ>SHtiQOJeT;liRLnMjBqi`Gvb*I(KI@)l60~0xG&`-8m|c# zb7GK9!o!Tq$xc8QZZ@|@6#5j{#}}!v+OipOJzmsb8Ti7!s|fRn#7jeL+`6EYw0F0K}{{q4umtm9tDlx%}{>f9fv# zt6x#~0}OSlZmc1pb^Uauy;qum$xEFnw_G;;pdA?1+5nd=ZdaMo)KYV z>bryafKkQ9t3Lny^I4KK{H9BrKABH^CmCpS%08LtWGKjkEOnPM@hjvJJ>KzuPy7z=ZjD|4}581%jaefe4AUx6xw4wxgu{4Cps~jd!Y|uJ z&s~g($pqOsdUTKY`7>bZ(u| zuU`B1kA3@>e&^algU$|j0nt}X@~zX>&)9W;`iA%JaqEN#wnEkPDT#Jb?rS~|)e(Z5 zi!2+TdI3*h#6AK>q+KPC76lG0<~HKC7PoG7S8d%InM8yi@ZYhHjpvHw@{wX<%cUQd zZz%9W8Tv$Y8meg#*lD`ID0rl^N3OQ!VrbIUEV9s1HG8h-qQSB)7aIS`gQZDEYETs; zwl`Rt;+m&HpqwK4?wW|}>p~rRIS@>@e(btHB18c63ZrX?G+O?s7)Ev1(?YhB+Jd=7 zJx$$Z%oVj(v}un!?A%ws`qh+BN^8+YY%_nK`qWK2?h6&uK*!-d4E=bkr3PW+Q8RGc zwi_c(%E>JYeQpj_lZw(47FaC+%&|<+O(b%jstyOp){)FjjlfKuR7q*y= z=&8_d<01BcZnn_8t8rK{UJFoyrfl-L`i$Dr^P9zoNXKEemkP!o~c) zea~xFEuxfdhYwft&6^XEcyqaRE3(qmjmBnA6Tcn>kLt^gTV|3ZAun&BsppkE_K0&) zUrB1(>edl`&$jFK|LS$$zOH8J3z2w->yPQ&mV*ZmYa(5e0Is;alXNnlgs7E)Cnv8j zLZg>A9OD<6)bwLTeG{Y|r7bsa-r}}w**a`p2f9hr3hQ_R3RTm1X+=FFJrDnw!@GoR z4!M=m5n|#w>^$LoZy5?)h>I^QgxtJ|iAfEN*6v2hruVp zc|=teCDpCFM&K~tKi%=VUw0eyqKJ3LJ9O)hTJ!Vs3nXJ#Q8SamzN?0am2tWls}C=v zwH2je9VH(uV1yz_M~@y6ske{~23yq42C#wyz0r6ozvGUX+}sI8_Y*%_H2Sz!%=%Bk zo`5U6cdIinqYoHxDGXOIIVnCX*qB(hVckG6N_)MABelX9dms;}GYb`+3jfKIPyQV- zXj|Mmq+h<~AAEj;`OchpXS_SNKB*}P{h~#!;5e`OnvTsuDCsBKS-m8Tf*{HB1>b6A zp!+oNNoQv0n$?RAA3mZ<;s_-|D^WE+@PSRketCv$j!L{$gC)(Kuxr;|f8fAw_u0=L z<3wD567D|a*UiWuJQJpb#l-~?y>*`?Ew`91djh>8-aq!AFj1 zu(VxtGq0mbEw) ze_fU=kp2@@D-}%zij%04Wobnit|A~!rQh#G;!%jj9beo3wI~0jTw+~2u>bPdzAf+l z${)RNA?>Y8>3qHk^zxUk`v-TebEboCzWB0N{ZcK8Qc{6rYz{~VO5-dMUvrYiY0@7I zvQ4c`*+R0EM7@}_pR7{pr(#Bt<(APW0dNubvy6K_Yfrp-{f2yI=6YqenVG%0bqm7I z8)dxJGSDq82#yuWcds%X+pOo8L8?{;VmVlPNugKY=>n08)T^tC>xX5{P%~?JsKjeM z^?Y_dve3_z011BkweS3;4OheQG*fBr2fe%in2s>Vo{q&z#Y&NgmX>D3X!;^dj2L68 z<>8Y?7xUwtyjz8-@a|VN4TsN5s`A&wQv8}TJ}76DI`pC%j)*Z-RZr5Wc=q)YdwB+Q*+UOK{9x*-Af0Ap z$uwzN;sWFK#pPA^jhryDrf(^t8JI4VZ4Z^xXN7s={F-Z~Jw=Y5eDX=+mWRz-ckd2K z;>0(*GsKJvzou zhju*m)K{OA0iIQ9R3Z$A^So9x?IFAort7KyIV5$Djlpzdg&%KI9t2E<>I)faCPD~; z`B=gyU?aEPcH2XzqtUV z+>pd3oBU7z=~o`FQt3R6GF%B-?Ji10V@onBl#q3fu;_M!a9COz6Pv9q4A+)@7L@Ub z+Opb1jBD&WqE-sq&Uz-~7V~^ciC2Itc_~=B^zH6p2?*nEh?}5~f~HG=b_(-p?V}c6 z|1DaHcVV!QEe;m5f%x_6;;c->?hD!IK8SpodG`Cmm;+;VGW>BN0anyV>a6(C`-3q{j}MU&VoDuJ z7Z*eBDao&PE)v4g_Rs*a?+jRh%#Bbk_b}Rna)kpuc<5J#x4?Xq`P;eE3G9DkW#7I} zqHsxfbAEzyTuT54yl>KHyf z9P%>bgo@H%{o2>|?R?d%u=5VP3z%R)R58j2esJFdx2%WSoiB!9I12xs&$^dqnYKLg zw~s$UL(0fI?GS`tE)iE=9BGcZaz-pnC(K6zK;~s1VZf+bZHo`D;-5NpEGH$07F}Q) z*IaWA_(xupz7)p0$=!SJoq5rsYv>bSP1W=#xluj9ItKx;;6@;Hj4vO3^ikXgR9cE( zkmY!@sXf^ThYqPrVNlytX~<*MYDwIttB^R(yRuYgy0{i_D^Ocg@LHVd@f%dgz&`J+ccGc}!U zdU`tRbhi6u^97N5mt{M59?Oa%N6_eUNGD=E<5pEg3un5&{r27d*s+~d(Dg8$h~9|0 z@p5sEWJ$!Im&&p+sKsJ?id|O~7s*1D=k2Ic5h+&*9G7)*oe%^N2VWk+0a;Y};urts zM`mWWe$u@R2?qRwLyui^&kK*gZM`MhBHid6_%m02(_dfc>g0(?Fp1k<``S0$S*_NJ z!nH)KgnG+j1v>;d2k^;7( znra_O9O)DYD}WWtO7N@InvG9#uS1E4k%+`Qa-`0gB6M25qbn>4uU3a;mH^XE$8X*| zF3h>);c|rM8&wwubo4>j5xj+8nk3C*6oiZ3T&S{O;sDfBl8PZ5PQ8V+b8|Q*{)|{A1 zVh$~eYC0jyo*T^8vrU_-`GErmFwK*R827$ zy&;?Qc}DHTkxpVPhUS3-y=Z*ADKfh+%M3L%qRg47qSeL4G}^XpCO&%fNTl(bRYEpn zqFXeYg%a;Dbe?DN(@+21&x%z1h@g*S5Izv=Kpo+?k~SwCEIEwXy&(HfBox!S=6dTfc7M+N2+R0G$sVu>?SZ_r;`tY zwCiW%GJyO2nn<`~srjhe?OKM!6PH*b!X^XR2V^`Xwq2tD9YyM?6tsGptQWy3$1&dt zvlCpYj{+zSg^$EX;gtErrEutpCo<3<0av2Un>UNv3DOhin>NMyu{5+h)3@}R6f zXxo1G;G;Vpoj%{jA$v zlrdS&C~MLdMw@?rF(VZSr}_MRC?W--Ikm8m(^ZQiwrGVS>9V5fkDYJTn>v||S6t6Soso($oJwNtJl6o6-?normw}1WDzxtQOo=Y?f0Z^sh z5Zf+F=erAo$*CztT0}!j=MOF<(b#x47Ii@?6DsPpMdB?9u(Cy@o;4`JuH)R-U3ZFJ~;cny-=@mHzc#e_`&y2Os=fIpl(b;Wubq5z_84k#ZE*$}~ne z(Pf7Jm~l8*6#w$VAj4_Cd2`b>T}$IPvDIimDKLKSDfduh;sB~J*7<|!aGc*R5)Y;; z9Ot8rN>-&4My`xjzD3=nvNRzvx8Hud7IwjZ0g_|oAoPUdOm|aLu1Fio#@v#wQv?Yy zPpD0y^HLHbB^860LLdQ7^;WByzw@2%*(2w@%x%yHy#x~5?%m(_ec%7_(5pix_*OOd z^(doCwD$usmFlu==Dz85YzAS`wUoDvQtbpqi#XAASc0PM+?ED(*=3h$1lOKDp?cve zX+Wzo;RzXUq!8z@vNUK75|6k|B%TFLBZ3jd_&F9k9TmmlK_*1%iL`44@UGe1RHW25 zXt2P#=H?|Gn~4&(rJwloKmUyEzL9#fK`)lx^wU56v;U#etWr@d1rTBB2VMXYaUY0O z8w{4xg+kgjo1r;L2zm5*092}(1x0g0%ReP-@_`2)0Cc3x9|;5z@tFvx(c&tj5i3Tk zGM-5!w~7r&5f@_8ka%Sxc8z8x{%9qi&a|$kmUkzQi8fww+|TC3DcS4I%L6CrAR2@? zxg^mSQX8w~DLS~1kbL>i{M65W>G8*(B)wrH<9mb7l(q|h^k~_?0^QUKN*I(q(QiR^kbDG9i@uv}-aM_$O8jQXG}kyc zm4MAwGZc|&w$$F^;p@Jxg+zTKdUdUyS8KIA@gZg7U;pd>_wRJO-EVgrv_WS;jG1@6 z>%0FD6GnV-j6oSwBH`q(-R`756gAS|<|x8%HzzioNV{%V%RKOX08s+df!OmIEJIvr zDQ)m>xM5t;Ote37m|I}9QdwI(5uHwULSAaFryEc=F({`F9;6km28!a>plM~LGAm%o zqTVn!+inLxNcU(zmA?Szc14el2ifA%QW^~3P6Dc=(oYa1pOBv6voC)6EAv13(?9*I z;wQYxZO{gt1~FEC=4bA_OHG*SvPKn&x40fu$c*Id2DV)<5(XslWRvg6xEw3ee_*_4 z#7!W6WgYhbDQIw3F`8{iI+9+&JYYI}cPo<$P8yDR@-OXsYpiE!4&t?sKlGsw3CGj< z@_tlLErRr#*Stnu3(q~5`dsFii!YuLC;3tJdr%gl(by8#Y84#j5xQ@t{nC>dhq}yC zxD~{a9^yIcucE3fSR7 zNII!58;#&j5XO_wtt>>Z4CHe)B`6vJQLXkdbu#M3YKYj}9L{p>aW3OL3mP6pjSda7 z5kvzVmhyAU{Srrl2T^np@>VIA3S&irY)pcz*mW9)MkEW%R;yMSA5S%s z1%^wKTAXEz!gTehR!fBm$BEmF#PMDcOh%cmmkY!7G7X|0h`%q+v-sm5zvsQLfBkE5 zs=tz*!5eg9+VYjJJiOyOZh7bLhzUc|LF%dwl~HS}VA9~O8;IzO2B?<=LNACvkGd-3 zIaS0^GpVUU!fUMtK)6N5Zzl!KyyA*0a?8CV7#e{iY9`W78B9>kVMEuF5ATG`cT!ce z4A7X~8nV)_*H*JuEf*&_ZUxj$3m)CRy&_CF9`OK+i=o;yHlM(?nN=n0spwzG(6f&n zYbW8MqpZl+ql-&c6_r7dn)IFTeE08s^{Zbcj&lQS*`QI{^5~epNADQ68m0 zGqszt-Oy^OKxI0u40KoGT+?HV@EGlYi8CFy0y6^fo6Ha(oHH|({OD22p@oNPwOXD5 z{yQ?RwkW-|?H&$>a}v45kAs5ZRX{gpI+}qT z6MSM;d*AVAU9zsbt|=(!o3&arAS1S2xwDp#^*TRcsF=}dp_;`BS0wbI*W#@w5ZW4= z?3ce#;{9Sx78FUS@ompOD^gE@77`Dqr>j|~)6K|r@?)Y=ihF@ZmeC+qpoS@m+AN7E z=JlwaMQJx>7Q@W|-SpPC{+FjWm~VpuZDA~Y^Eco0A=+()_HU337txk#f|A2REX+;@6lV2qra8|Epd#71&?=dIx1@86TGi7OCeu zRswKAA>L$3W}`+UYeWrU!U<(UBwxLb-z5#q*GtnIZ~XfoM73}m%=b#B9Y{JE3;zY+ zM5krOhl~?7QM5Z%qNq8lWh_kB1j&@o{r+5vO^4>y>-DXEW==446wQd17J7nE%m_Ex zn5daoUU{Xf%NW0VZ^%R_yAMV?zn;ixUZzv-FtVR7Uknnd7g0HPVPUtwzuWaU&CEm~ zoJEfXPic37VZCUajx2SVl4!N*NvThgx_~S55kO0UW{OQ%jYyKsvOH?Fniay*asgZ7 zxIqe$rVa`Csmnk3qu>80Z+QLdxu*@Z^OZ%f7a%3SMQl3wty&FHDN$V=~XwVw712)vb~-yeC=zBY;qw=v}-3IS+DOK z&*=t)!<_G|l30Os8eznba$jU68=(nVDPFnRvb1<;YKh>G&SM9%IiwvbCQ(}!rDDQ|_{}zhF%N=3pzPFThHd_7c&B=yWJNAm5xoY7zY;ABGsYK`1oi8|c--YYjDs z6PPZ7Az{AYVCV05t0!-s?70&zCc8;Wz&ApZcjk{J!t|fsg3~9L1W@ zs_11De03`EbezaCSt|TP<7hu4?LjXldp%$DL_j+ zJw2`dKYA_j9d%mdPaNwFEuxepRrLYVb0Mge9r5>7LY)@D8hBv&PN@eS{H`@liIQll?c7OcGfAV*~@P+&4U^YR>H7X^GsSfG$w38-| za6@6m$wqzXGU)fCA_Q!Z#PMWCwR7wE6Wmu@G|~#`1`5AYLT)bZ37vith z(8p5wKD)oX)XMzqshVyCrGe=z=P3B~LP@jT^7+j&GwcAbP|l^L zrOd><{Hf@#R0ipoBAtDCVT6}WtJ;acqZ*X2`^=IVnb}weI<6u8WZS!Mea|2L*uVaX zPssWHSGOVkUJi7POw`}@;Sc}TXTJ3xy!}HDJn-Nw45OI_Q6cF%bk<5K$B;Kf(gmig zr2G+`(iGQ06b10avJU8R$H}v7!s9wHB0}ZihacAO3{2q(piuutY18(Y$?fG7$i!6r7YdN#WlD;x0>A*aR9af8EE35V_m}!wFsTtYV*2*6?gn}Eo4@(t@BF53 z`o?QTRrsQNg_Mza8POIQ6E}V7!3Q6E_j~^F9w&ZWOqSr+)hq+G;dCNS0-!RW8nl*R zZ)w3I@B*E{V?i%9FH~cPGmB2CRJN-5Gd-R85F)maVj6LAO7^kbS1=uRo!wZWYUbq> zgmS-;lNC5B(`nmy#ouw!EO38OXF{V{*gi4wc>ak`92`2O-WZ*jRSDTBt^Ys>NHHwWeBmjk<^4r$6|E z|Le!Ez4rQF{)Qq&1hptLR?_n1Z|ZWyT)`yfm+QnH#L*WX!@sx|e^)Np_?}I(p5?4ej?uDsX{OJ)b zZwaW*^R7TyW8#hPd387B;%?~WI2Yh)&nr=-5l4fLmf7~id;aE!|J!%G_SLWc zdvbj$Re#xSNWt|&m;^V9&v)tLAOFPX-v9o8`vAT;5ouY~S2aZ}?W#@ZG^Ad(*9CA> z$9TPw%N(q}I=Z-y#W=_TP`bHqjpfzkxx~3`niFc>Tw%P)$;$)O&nIH8vktC=v*yPo*xL4C05wdA%89zUd-whBv!Cta z%gZT_#7sh4Bf4wCoJ0elJs9)xH&d@0^Z7-&UL#ElKc6s{Oib0Z5_etC)v_uLG2{7e zC#nkb0m6tt6(EPi_IhRD23`OdVLSn1vx6)i zr%9iG^PAtCfAX1UqB-$E?hs{)Z^$ke{awJ z-~Gt%KPRH|Pk4_1{eS<_tH18+c3$-AS6}}Rr>CdtqSkbUA?q$kMEM{Z@^lHt zYP!(xkmVLStj--f)>~X$8a(sN{sW)*#Gn4@-FJWFIn9}?R9upVvU{~!Pzk$BU9G

j>@R^ol3h{z>|dTK3$P?b*GsI%5-$5YUN&>z8xN_P|2b~e)8I_<@ zy@?{U44qf+fq#m2McIcwbboZ~tvmBa>XeF@(eFTvWHzBHnI|mgIhn{Njvb4sOpWMA z+R01tq!aPxc|mv$v}jFS5$q)QnW&%gyMcNN#iFS$WJJHz>t58&JR`XQl~Zg+)J^R1 zNEFb#<|9|p^J#lN|0^H-&wnc?d@PUize*A$qcD3ONALWucW;0FYhHb^+|$I=)Wjx{ z*w<#66MJ=xb~F4a!*>S&#NX(9k{|y?z2Egh6mMN^yP(szp2*OksrHO(ROWg3C<|b03(F5AnF9y zb+DC2W71JCrO_A{$p3I2X?rcf1_ZSPuJAtA^qzhnmq934Rv5?o1;`B%EYs-rQ-7tzSij^{2;|P=LZ8}%tl3R zM5^!_Ll#J`R>|i1uoYP*vOQLIs`xadpEx}qGal-2@lIua&g(Ui((3t1&Dk1hdwG%e z#*v8*+tjh0RkriJ=-}ZPS(nLPHH3=pZ-rOG{O2k$#eDM0@OMFzNQd9+c@sIlUW-ye z`E;jB%;W+FrRODzFO(qWUr(^HY_E9Zt_s)S9C94@o2GrSujII1s)dMQAYD589{Q{_ z`)gMoF($Z^TwiSJ3kNmT<$`LN6xk;tAgY{$J2 zY`wzfgBfAIiHVef5A*o>`HaH2F-*54+F!8!V7@7_9kKs9!hBUEkMD_XEzUrJiHYeX z*0UM`W?}z{Q$rY~fxQPaMWK&FMVXNGBpE34F(6}sDu2E?SG$st{D$WKQ$D{?5 zsQS#vl zJ($7n-`30b$F+DaZb4RU>2#vdnIY_FjT<$Rgb7N%t9nHua}rd=cGDh-)oM-d0VZh) zn%GU9%$#)%ocG!+QX`Z^>QN&_{cr=2Z>oZczgN`D-avf6F7z_DvV5UJSUU@aiZ;o2 zb9p(Focp4T17;7>&U|#{HZa=`rYpe1;q#A-byt2jXF@Ml;th4&696tl=|`ubq9#TB zz~Z8}C;;mjZOq5I`T5YhvuxVq=VQC5d9kRVM89aivcQ-~H35<9DWHuqNv$@iQm)(W z_^#ZS7;GW|6?iFnHdG_KcC}TB1PS8-R29wp!B=TMOJl}@L5B>f9qVbbBD1(m#J&+% zl!5fAg;^Ug6zhpekV1+Tj2IZFsuHn$y#RMtR45cB4p$iL1;Z^%7rSQZx3KfD4CmrD z1}nB3*#scSpic`4XZNIJ9Oc5~QIThJ0k3dfxptL#SMEbzL{6{Ag!d#b2SB8%HB*se zWSJWY94Uo{`O_)!a3Xrqs^*D~H}Pv}@JK#p4v`tbmfP~@h&f_Cc{M~ zp#as`fbzXwJ9IDd0yYa14RTdGm!Ag*y{FzjO|#jiE((}-u&jcXiNRiGlAyi@$!Eu- zRt*NWe^MHt10TAUE;G&O=Z2`p;=&wCOBvLvT87Ws?KDW(O0_t~mV0wKW2m@ijs!jspS4FBhYNaC8!Zo}yo;+VZRvUBF zW*96DGW)JptGn4wJJ~u>7aNl8JxBSyrVo|Cdg7O(&NEJNaU-cs878YP4)G!Lh9)Ch za-8E-2NMiIF#=&UlWKZR%$+Ce^_;+dG7i{=lq<}maw&El24_ps8J%v(*s8=YWPe6w zC%cu2V4X4%hwi=`bSTG1a<4eisZWTg=MP;QpoPRkW~Sv})h)u1wXD*vsV~N6v|u=g z@G==-X*4b>#yDX@u{IXeLN*#?t&VxZhvZK_H<~F`%~Ar5I^q~iV;KW+LR33wXeaFg zvJIj}9O7x>Or7*NZ~@j*Z6Ye4d)a_`9vowT79}5P@UEcYnn^(;z(~L6JoS1l4f}^} ze8)BoeXHTf4K@D5Uo79%ho3no+vs@1xw9-E6pa-J;W*u2GdEZd*NnBhO`j+P0fOzC zcF+Aj4(V`@QB)^Uv~+#`>S=hE5Td6uo*Y+;>KeAkx$AWeU{~Ogcfx)w=~2M3{B^r> z9Wn>xUF$pU=)R#bWHV01BpWmY#S}@Wp08und5orq3Y0xut_0{gyUAr6^2V~Y5SrCY&`dE0F*?8re zneHXfOO<%T^9-lS5d+i}YG*+afz$@H%*}0!>Nkw%J-W;a7XrA*OysCk1_KgU&C7e? zg+$cOfld-gJ3`EmMAWy(=Qln+?&f7YwDJIo)ENmWDkzgfEy5mXXG>A7IuX_CVgrh^ z9$OIAQwH8p-OFUMlvy|zFft0yR2{0HeX$jpt&xn)N-H@3MLG)87gDFMg;4pvECo%P zp&}zCkpP?r>8a;)`q?23nibt=_=Acs(zU8B&$8GzoL8k~kho3(j7n5ic?xNp6%tyH zE1y?SQ4QNTcUARgr^l=&!RAmTs$vbv^r$7n&m}DbPw_fMi zde~Q-Hz9o|%9o?0SnuW`m8X|=h4IGq87WBH;hL$84~lC!os73^$!F*0@|mLAvt^KD z(~-Uc~fs)6=11nap0E^4Y?4L)CL33w02iSwrnW zoNw5GdEOAEr7ibWSH=`(^khDWO{kyy{du*g7yxQ3${-P6klt5Km@h{y?FHuS%YXb| zFs=@`ax5$B#-Mt9t)|C_(^qVwOwrGQoSPWc@Ma1-NCvzyF1~-ULH(TABQ0fe%%Q+| zEcLjc`s(r3ocrkO=c!4&`VM^M<_1=1;dS_34*Aa2M$~_EDs2hMDZA^Qlkb?$qC|x z5wr^ION0S06ZJtW{L(r}X3eN%Ors)IMe(0ty*fzxwImsztY{CxqF^G+(oTJ+vh}(A{pOej-HZh1r6{{p{moEqMJPG>| zvPuM%7;dR{Hsv!bF}|-jov?yY;y+<~gFiWr=dDx2M($M{pNW7SdQXkXhN|Q|Tb6OH zjy@Z0itRZ>UF@SmvJdLGs@`c(!3B{V1oc%-bUJd2T0NO3^cR=!7ejn))@-!|<*exM zK-LLg_hp^JvQZrBTaufpfe&)CNV$oLi&VnFaJUKh&0#uR1z!~@N5DPy9Qc4SUEv(p zIzIf}ci&P*q!LXesU!x|fwto|FqeUiV*^mi!OymGHkb}Nn~YcPxiF+cHy&=|Az>tR zY3j9TIrzPIXNZ5obSjl5z81CnG@({(KNt|>7+oD>ITmPGm{fyj7Zxh|Bk-dHM{70R z)@ph396N^(XO(_69gi#WW6vki_=HBRi`|fnja3UK3N+tqM)+gA;3^>Ql5sRM^Tr4Oh04zmj`5(|tBX;Rpq_=$vtS2ig^DS^^GwU!Lq($!e~F)bfo3ofBW9sL$Qv?iwP^vqB9;&F#zJ!uCO1 zs1Kik?Y52k;B;aaQT<-;t5zeItmim39MN;(oHg$YRc`d0c3xENdVTpb>*4$jy-z;V z?YEPG=*zV^yDZ)C3F>-Uwl(BaF3*F1O@0?W&ih`jrE=;bTLq1DorXu9lK_AT?zf#CxRff;et7>8NdJemSdqX&rn3=7>Zg=F;xdazdB0 z3>N%NH-&&_Y&zP`ogkUDJeJQo(?Mrd;*F9qU$O4X5N+|ms$26S-uAX*88Z>K9xrU> zjc<&|2By#qM)5-rJ(Mw%;rN`Jo0J*!aK5ncRhcy_^80pF;#t4yTCJNd$~p;;p`>7i zCbj&iS={Yrp&UR=y$LRLRFQCmq z-RE8h!Wc978EK82P)ydhL&{E3K3#t=e#higfZr;3EOGKhl;+Ml2J~#K2njnBD z{4qu>(3x?gYGvUVhtRBGIxE7A=4C-1#aQ&`3P@R%H{7`;KN{CrVm@yU_M!`sF9K4X zMY_O*pifphko89&ebf`{DNGt6{VeK*?~#mRA4Kv4P(|2>*iVp9LBU&tW#1e#`4UEb zqUlG?W=8i<2V#~r>QTFMByY(Soh=4QX+|}*bGkv~lNYWMpb* zV`*8hiQjN;J`3ASdU8wyi5iv-Hx#NWO>)||&E=D|ZQ^sB6Ku7Y2Z>AkamSU7{f=&J zy5Tv7=jI;y%$jVI`)w4`y4#)A&#uckct+Xg@%!cBS-U(t=jaS){^57}nRe{Nk%zYI z_}IkW)BU>f(QI>l2l28Q((UNc*>vmHhG;3hR3!Iv3c64#bZ(4 z;X-nV^_NmCX}%3Y7C^j zU@xGE>l|t8IbkfGG0-y8QK(qJM}1$s%eLI-czADsWFD!uv_ua&fXa53%ln^-P$?I+ zi!e2utyzfOvo$=m&qz1RCa_LbWt9@29B8)&K4Zod5{?Xml3F?BELW0hXB)XZ8J$yA zvqVFbh=(geafXtxT*m^V7p61SfMU6w&O<3^6yF2F81TZ984nedP@p1jD+g8vgbDF^ z^-NGKM`^)|%%XGh{iOWJwc2*UE*j~fLw!|K5A}{FUFs`}niI#J41gzO#AD3&P|{x2-bn8!&RW7*QIS<0jrU~VYB~l8lq@KK z1W3WQ-e4~63#i0-by=Ac^t3PcAwWlUR<H;XSHjKBuxH>c0XdMa>;G z6Xdr6T*GXD*^J>VlYl=EZk*r%Zv6J3;HZ=o*K%%`jvsIUe8gx>#&l(;xRGC_MoKY0 zS5rC9I;|Dsm17cl0mJK-qXR4ooEJr8I7@wjE<}ry+qYNs_FYBpRDFnCp?(&T35xX5 zB$Egj>;Qh;g?;<9Na+?}>O+STYz1QZ78aH?`XUylgE^~3rm~z<2L(ocupc{_eTFfo zred`lh0z2Eh_%xwOJme-_ee^~fv6+L3zNm&qhepOO>E3gSAA2*jy;=9$e`zYK1XVQ z%AoG|HG+hmg4jiTKOuu$JS54K{4+PVIH<^g#${5`eUByD?S?_zpG`Xv=RqUOR8_{Pd)gFhuogy+IeX34X&zr5ds z_W{#|38$v_Ua1IkF?q>5$L7T5eqI?3*TiOR3<5HV|4k-MMY%;)RCN-DqdbVZbmR{S zhiz|Trcf_)Ob}dAOv-ypVPB}06rC;V0A;1BV1}>{%^e$_#TZ=`nLih_R;s4bYJR>^ zvX^1Zhz*ED+_Pto|G)=65LpDdwFi6PfkjWcg9TqhR%(KER8cX`mC@zLQ-?f(*!;Mb z4Z(M_!gN%+7N@nIXOq*Ki#TJ4_eNDJ8nBQ{$hv^#qMk15{y4-@pD0sLrEFBIBCk-t zXK7J3rh2iR!0+**$fKZuggE<6=*1{vMiws`%Jw_Wtm@=0~*ZfSaA2%ju_K_L}S;v5thh z=%S0T+fbx2PDqHyDv+ zc^ft&MR7<#5BB1e<_gY=`+`9xBS~#cZHY8D=ORJih7ZekA{y9k-(y!Y$V3fR5P?uK zY#g@1&_v_}#?Cz7b1!_yQf;Av_zq9Cl^QS8TyY&3f%Dlp*lx#;J|{HK3ohXK;XSim z?q^cY6NMTj?y`v;e*e;eY)Tj_(urzQ>5=F3c$kYwBeS)+K3M`$6yAII99#e*>AW!< zzY#t-&Vl{LZ)m~vWD+3VRLT_+4o#@&S~Yw(C16Wlx+7@`UCiV#q{rSbkj6bz0 zWu~XbM@8-d8wOY$brsc?b(S >|Jg77D<$&zDPx9@Nya}3+L4)zccC46W5uz#nx z54Kf`2p_H~gIGWFK5T!sb6)<~)_(r^g!|OL!tT=hmit*GxB;6>p10E)q+6nyyxtj0 zQ5Es4p}Gt7uj;!=Gz(M~X-)kSQS}x6*Y}Oh#_s{Mk(i2^fHbolIqnnt4W`>Cb|l+w zEi%Q4IwAN1wGzg%j;w`dr0Z52r97_ODnLqkT)H)+HJ5l$zDhThvdf`Z8uVNC`wjOg zY(rVzb{_K@nCG+-u&AaF_)$p+G1D>-tP&bF1+%dw!a|~{ zD#dYCX6==34BLlXLce``zAc=e_R=}UTDBD5kpT@Ovozmcx4!)4 zFAvX0D3-j_$o8}L#2k-3W_DTm{TSSD>Y~1Ha={7)ySnt;mfoLCQdOtSd0g-|q z5_>S%g@86Yy~;uU*%dO7?OZxI+_H@bAsx#DiB@FH*4B~?CPX4It3#Gu0RVxtK#DRb zZNKt1_T?C6emmYIge`4bR%Bq{KrSPFCShPqinIgYkcKhwO55op;xdo;R*R~<;v zCc`FFN z05e)|W0N}O85pQE_@%HduoD?L*d`1rvpbllga7cl92=5BhYiVqyXtLkQ-31gi$qmB z%YXullxM#;z3ENPLY&x!eZwF7S~NX&5XZ5N!4D%8Hc^IKh4Zlw+c+1W`Qv^#FUN6C zTQ6#h=zZ~b%{A8yBlp>lZS2E(@!Yzk5YTf)usX}HxuUN?F$s;0y>S@O&O70r&Buy% z0n^DnW-uKnE}%yqZRk8sN^m)LKWA=OYNvD>lTg75n#bu;k0qE6axZR4a+FIS}Q zjE*694l)bQ2CWOm8>NvKl$Za?y1Y?gNUsP3lG#!HH_VbnR7@Uv`OPRW^W#6hQ-4&- z&GW-kiV&u>%E6BmYAGsdP&MmvuBVC!i8qT%dX;-}>R?9u=_hG!A&lBI2_&i%j0||g zxmm&-s9T&%&Yhyt@@K7}_+wvNXFr_yY{OScyykSl;52_BJc=AMwsC&`bP|vOZx070-tC$2P=n>v*m9ZuF8md!^$;*IV6B*hmC;IJhid1Kpq9h* zv^-xoe0ChIHyiInR1WPKz2_6!f%;`hEiHpI=Ay6w*SL>9gJC>e&Vqn2NIWWLz!J>G^#Ed z-C1OOmrRJV9d}&4lM*WF-5SJJo^4u}5^oh6>gdHRR+ezX8kobKLs**m3w_bCvuu

);pai>8T`>ci+P%#st&GLD%)U)$L0H7d-tX=E6{#Xjo3Pg zW@h)4w-@Ksb&f0VGrAwD7}vE34cnI`$J%~!&!#%+{i zx;&nD%^*V7oPseYs@Qht2h(DMGHd0wN^cGYW+uA57x1QM7%yIRRa zcV{W5ah14NU)ttJ9Ll3WgsSP5662Qbr4yEHET6AzW96kd+`N=tGwQf5-_wSwxIycY zR+D_TeN`#Cp_W}3^oo^u7YLOBp&>)%BauDDwhe~cpbgrf4LTY9-$EUC<}w`m4FCWD M07*qoM6N<$g3+ZiZ2$lO literal 0 HcmV?d00001 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_.*" + ] } ] }