diff --git a/features/ftue/impl/build.gradle.kts b/features/ftue/impl/build.gradle.kts index 0dee792464..5154d68be3 100644 --- a/features/ftue/impl/build.gradle.kts +++ b/features/ftue/impl/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(projects.anvilannotations) anvil(projects.anvilcodegen) api(projects.features.ftue.api) + implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) 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 index 0ff9c80d46..b23fa9016f 100644 --- 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 @@ -34,6 +34,7 @@ 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.migration.MigrationScreenNode 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 @@ -41,6 +42,7 @@ 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.libraries.di.SessionScope import io.element.android.services.analytics.api.AnalyticsService import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -50,7 +52,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize -@ContributesNode(AppScope::class) +@ContributesNode(SessionScope::class) class FtueFlowNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, @@ -71,6 +73,9 @@ class FtueFlowNode @AssistedInject constructor( @Parcelize object Placeholder : NavTarget + @Parcelize + data object MigrationScreen : NavTarget + @Parcelize object WelcomeScreen : NavTarget @@ -102,6 +107,14 @@ class FtueFlowNode @AssistedInject constructor( NavTarget.Placeholder -> { createNode(buildContext) } + NavTarget.MigrationScreen -> { + val callback = object : MigrationScreenNode.Callback { + override fun onMigrationFinished() { + lifecycleScope.launch { moveToNextStep() } + } + } + createNode(buildContext, listOf(callback)) + } NavTarget.WelcomeScreen -> { val callback = object : WelcomeNode.Callback { override fun onContinueClicked() { @@ -117,12 +130,15 @@ class FtueFlowNode @AssistedInject constructor( } } - private suspend fun moveToNextStep() { + private fun moveToNextStep() { when (ftueState.getNextStep()) { - is FtueStep.WelcomeScreen -> { + FtueStep.MigrationScreen -> { + backstack.newRoot(NavTarget.MigrationScreen) + } + FtueStep.WelcomeScreen -> { backstack.newRoot(NavTarget.WelcomeScreen) } - is FtueStep.AnalyticsOptIn -> { + FtueStep.AnalyticsOptIn -> { backstack.replace(NavTarget.AnalyticsOptIn) } null -> callback?.onFtueFlowFinished() diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenNode.kt new file mode 100644 index 0000000000..a4c5d16bc4 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenNode.kt @@ -0,0 +1,53 @@ +/* + * 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.migration + +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.di.SessionScope + +@ContributesNode(SessionScope::class) +class MigrationScreenNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: MigrationScreenPresenter, +) : Node(buildContext, plugins = plugins) { + + interface Callback : Plugin { + fun onMigrationFinished() + } + + private fun onMigrationFinished() { + plugins.filterIsInstance().forEach { it.onMigrationFinished() } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + MigrationScreenView( + state, + onMigrationFinished = ::onMigrationFinished, + modifier = modifier + ) + } +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenPresenter.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenPresenter.kt new file mode 100644 index 0000000000..6507130383 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenPresenter.kt @@ -0,0 +1,44 @@ +/* + * 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.migration + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.roomlist.RoomListService +import javax.inject.Inject + +class MigrationScreenPresenter @Inject constructor( + private val matrixClient: MatrixClient, + private val migrationScreenStore: MigrationScreenStore, +) : Presenter { + @Composable + override fun present(): MigrationScreenState { + val roomListState by matrixClient.roomListService.state.collectAsState() + if (roomListState == RoomListService.State.Running) { + LaunchedEffect(Unit) { + migrationScreenStore.setMigrationScreenShown(matrixClient.sessionId) + } + } + return MigrationScreenState( + isMigrating = roomListState != RoomListService.State.Running + ) + } +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenState.kt new file mode 100644 index 0000000000..fa718990e4 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenState.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.ftue.impl.migration + +data class MigrationScreenState( + val isMigrating: Boolean +) diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenStore.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenStore.kt new file mode 100644 index 0000000000..42eeab673a --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenStore.kt @@ -0,0 +1,25 @@ +/* + * 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.migration + +import io.element.android.libraries.matrix.api.core.SessionId + +interface MigrationScreenStore { + fun isMigrationScreenNeeded(sessionId: SessionId): Boolean + fun setMigrationScreenShown(sessionId: SessionId) + fun reset() +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenView.kt new file mode 100644 index 0000000000..a8f20713e4 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/MigrationScreenView.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.migration + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import io.element.android.features.ftue.impl.R +import io.element.android.libraries.designsystem.atomic.pages.SunsetPage +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview + +@Composable +fun MigrationScreenView( + migrationState: MigrationScreenState, + onMigrationFinished: () -> Unit, + modifier: Modifier = Modifier, +) { + if (migrationState.isMigrating.not()) { + LaunchedEffect(Unit) { + onMigrationFinished() + } + } + SunsetPage( + modifier = modifier, + isLoading = true, + title = stringResource(id = R.string.screen_migration_title), + subtitle = stringResource(id = R.string.screen_migration_message), + overallContent = {} + ) +} + +@DayNightPreviews +@Composable +internal fun MigrationViewPreview() = ElementPreview { + MigrationScreenView( + migrationState = MigrationScreenState(isMigrating = true), + onMigrationFinished = {}) +} diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/SharedPrefsMigrationScreenStore.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/SharedPrefsMigrationScreenStore.kt new file mode 100644 index 0000000000..64bb27cf92 --- /dev/null +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/migration/SharedPrefsMigrationScreenStore.kt @@ -0,0 +1,59 @@ +/* + * 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.migration + +import android.content.SharedPreferences +import androidx.core.content.edit +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.androidutils.hash.md5 +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.DefaultPreferences +import io.element.android.libraries.matrix.api.core.SessionId +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class SharedPrefsMigrationScreenStore @Inject constructor( + @DefaultPreferences private val sharedPreferences: SharedPreferences, +) : MigrationScreenStore { + + override fun isMigrationScreenNeeded(sessionId: SessionId): Boolean { + return sharedPreferences.getBoolean(sessionId.toKey(), false).not() + } + + override fun setMigrationScreenShown(sessionId: SessionId) { + sharedPreferences.edit().putBoolean(sessionId.toKey(), true).apply() + } + + override fun reset() { + sharedPreferences.edit { + sharedPreferences.all.keys + .filter { it.startsWith(IS_MIGRATION_SCREEN_SHOWN_PREFIX) } + .forEach { + remove(it) + } + } + } + + private fun SessionId.toKey(): String { + return IS_MIGRATION_SCREEN_SHOWN_PREFIX + value.md5() + } + + companion object { + private const val IS_MIGRATION_SCREEN_SHOWN_PREFIX = "is_migration_screen_shown_" + } +} + 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 index 52c8d90254..f643c93335 100644 --- 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 @@ -19,8 +19,10 @@ 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.migration.MigrationScreenStore import io.element.android.features.ftue.impl.welcome.state.WelcomeScreenState -import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.services.analytics.api.AnalyticsService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -30,11 +32,13 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.runBlocking import javax.inject.Inject -@ContributesBinding(AppScope::class) +@ContributesBinding(SessionScope::class) class DefaultFtueState @Inject constructor( private val coroutineScope: CoroutineScope, private val analyticsService: AnalyticsService, private val welcomeScreenState: WelcomeScreenState, + private val migrationScreenStore: MigrationScreenStore, + private val matrixClient: MatrixClient, ) : FtueState { override val shouldDisplayFlow = MutableStateFlow(isAnyStepIncomplete()) @@ -42,6 +46,7 @@ class DefaultFtueState @Inject constructor( override suspend fun reset() { welcomeScreenState.reset() analyticsService.reset() + migrationScreenStore.reset() } init { @@ -52,7 +57,10 @@ class DefaultFtueState @Inject constructor( fun getNextStep(currentStep: FtueStep? = null): FtueStep? = when (currentStep) { - null -> if (shouldDisplayWelcomeScreen()) FtueStep.WelcomeScreen else getNextStep( + null -> if (shouldDisplayMigrationScreen()) FtueStep.MigrationScreen else getNextStep( + FtueStep.MigrationScreen + ) + FtueStep.MigrationScreen -> if (shouldDisplayWelcomeScreen()) FtueStep.WelcomeScreen else getNextStep( FtueStep.WelcomeScreen ) FtueStep.WelcomeScreen -> if (needsAnalyticsOptIn()) FtueStep.AnalyticsOptIn else getNextStep( @@ -63,11 +71,16 @@ class DefaultFtueState @Inject constructor( private fun isAnyStepIncomplete(): Boolean { return listOf( + shouldDisplayMigrationScreen(), shouldDisplayWelcomeScreen(), needsAnalyticsOptIn() ).any { it } } + private fun shouldDisplayMigrationScreen(): Boolean { + return migrationScreenStore.isMigrationScreenNeeded(matrixClient.sessionId) + } + 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() } @@ -89,6 +102,7 @@ class DefaultFtueState @Inject constructor( } sealed interface FtueStep { + data object MigrationScreen : FtueStep object WelcomeScreen : FtueStep object AnalyticsOptIn : FtueStep } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/hash/Hash.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/hash/Hash.kt new file mode 100644 index 0000000000..8c11c426af --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/hash/Hash.kt @@ -0,0 +1,34 @@ +/* + * 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.androidutils.hash + +import java.security.MessageDigest +import java.util.Locale + +/** + * Compute a Hash of a String, using md5 algorithm. + */ +fun String.md5() = try { + val digest = MessageDigest.getInstance("md5") + digest.update(toByteArray()) + digest.digest() + .joinToString("") { String.format("%02X", it) } + .lowercase(Locale.ROOT) +} catch (exc: Exception) { + // Should not happen, but just in case + hashCode().toString() +}