Replace notification permission dialog with a screen (#1223)

* Replace notification permission dialog with a screen

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa 2023-09-05 18:58:05 +02:00 committed by GitHub
parent d4b527fb80
commit cfdccc904e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 1027 additions and 69 deletions

View file

@ -35,6 +35,7 @@ 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.notifications.NotificationsOptInNode
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
@ -79,6 +80,9 @@ class FtueFlowNode @AssistedInject constructor(
@Parcelize
data object WelcomeScreen : NavTarget
@Parcelize
data object NotificationsOptIn : NavTarget
@Parcelize
data object AnalyticsOptIn : NavTarget
}
@ -124,6 +128,14 @@ class FtueFlowNode @AssistedInject constructor(
}
createNode<WelcomeNode>(buildContext, listOf(callback))
}
NavTarget.NotificationsOptIn -> {
val callback = object : NotificationsOptInNode.Callback {
override fun onNotificationsOptInFinished() {
lifecycleScope.launch { moveToNextStep() }
}
}
createNode<NotificationsOptInNode>(buildContext, listOf(callback))
}
NavTarget.AnalyticsOptIn -> {
analyticsEntryPoint.createNode(this, buildContext)
}
@ -138,6 +150,9 @@ class FtueFlowNode @AssistedInject constructor(
FtueStep.WelcomeScreen -> {
backstack.newRoot(NavTarget.WelcomeScreen)
}
FtueStep.NotificationsOptIn -> {
backstack.newRoot(NavTarget.NotificationsOptIn)
}
FtueStep.AnalyticsOptIn -> {
backstack.replace(NavTarget.AnalyticsOptIn)
}

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.impl.notifications
sealed interface NotificationsOptInEvents {
data object ContinueClicked : NotificationsOptInEvents
data object NotNowClicked : NotificationsOptInEvents
}

View file

@ -0,0 +1,57 @@
/*
* 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.notifications
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.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.AppScope
@ContributesNode(AppScope::class)
class NotificationsOptInNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenterFactory: NotificationsOptInPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
interface Callback: NodeInputs {
fun onNotificationsOptInFinished()
}
private val callback = inputs<Callback>()
private val presenter: NotificationsOptInPresenter by lazy {
presenterFactory.create(callback)
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
NotificationsOptInView(
state = state,
onBack = { callback.onNotificationsOptInFinished() },
modifier = modifier
)
}
}

View file

@ -0,0 +1,89 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.ftue.impl.notifications
import android.Manifest
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.runtime.Composable
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.permissions.api.PermissionStateProvider
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class NotificationsOptInPresenter @AssistedInject constructor(
private val permissionsPresenterFactory: PermissionsPresenter.Factory,
@Assisted private val callback: NotificationsOptInNode.Callback,
private val appCoroutineScope: CoroutineScope,
private val permissionStateProvider: PermissionStateProvider,
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
) : Presenter<NotificationsOptInState> {
@AssistedFactory
interface Factory {
fun create(callback: NotificationsOptInNode.Callback): NotificationsOptInPresenter
}
private val postNotificationPermissionsPresenter by lazy {
// Ask for POST_NOTIFICATION PERMISSION on Android 13+
if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
permissionsPresenterFactory.create(Manifest.permission.POST_NOTIFICATIONS)
} else {
NoopPermissionsPresenter()
}
}
@Composable
override fun present(): NotificationsOptInState {
val notificationPremissionsState = postNotificationPermissionsPresenter.present()
fun handleEvents(event: NotificationsOptInEvents) {
when (event) {
NotificationsOptInEvents.ContinueClicked -> {
if (notificationPremissionsState.permissionGranted) {
callback.onNotificationsOptInFinished()
} else {
notificationPremissionsState.eventSink(PermissionsEvents.OpenSystemDialog)
}
}
NotificationsOptInEvents.NotNowClicked -> {
if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
appCoroutineScope.setPermissionDenied()
}
callback.onNotificationsOptInFinished()
}
}
}
return NotificationsOptInState(
notificationsPermissionState = notificationPremissionsState,
eventSink = ::handleEvents
)
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
private fun CoroutineScope.setPermissionDenied() = launch {
permissionStateProvider.setPermissionDenied(Manifest.permission.POST_NOTIFICATIONS, true)
}
}

View file

@ -0,0 +1,24 @@
/*
* 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.notifications
import io.element.android.libraries.permissions.api.PermissionsState
data class NotificationsOptInState(
val notificationsPermissionState: PermissionsState,
val eventSink: (NotificationsOptInEvents) -> Unit
)

View file

@ -0,0 +1,33 @@
/*
* 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.notifications
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.permissions.api.aPermissionsState
open class NotificationsOptInStateProvider : PreviewParameterProvider<NotificationsOptInState> {
override val values: Sequence<NotificationsOptInState>
get() = sequenceOf(
aNotificationsOptInState(),
// Add other states here
)
}
fun aNotificationsOptInState() = NotificationsOptInState(
notificationsPermissionState = aPermissionsState(),
eventSink = {}
)

View file

@ -0,0 +1,206 @@
/*
* 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.notifications
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.ftue.impl.R
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.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.colors.AvatarColors
import io.element.android.libraries.designsystem.colors.avatarColors
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
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.Surface
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun NotificationsOptInView(
state: NotificationsOptInState,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler(onBack = onBack)
if (state.notificationsPermissionState.permissionAlreadyDenied) {
LaunchedEffect(Unit) {
state.eventSink(NotificationsOptInEvents.NotNowClicked)
}
}
HeaderFooterPage(
modifier = modifier
.systemBarsPadding()
.fillMaxSize(),
header = { NotificationsOptInHeader(modifier = Modifier.padding(top = 60.dp, bottom = 12.dp),) },
footer = { NotificationsOptInFooter(state) },
) {
NotificationsOptInContent(modifier = Modifier.fillMaxWidth())
}
}
@Composable
private fun NotificationsOptInHeader(
modifier: Modifier = Modifier,
) {
IconTitleSubtitleMolecule(
modifier = modifier,
title = stringResource(R.string.screen_notification_optin_title),
subTitle = stringResource(R.string.screen_notification_optin_subtitle),
iconImageVector = Icons.Default.Notifications,
)
}
@Composable
private fun NotificationsOptInFooter(state: NotificationsOptInState) {
ButtonColumnMolecule {
Button(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_ok),
onClick = {
state.eventSink(NotificationsOptInEvents.ContinueClicked)
}
)
TextButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_not_now),
onClick = {
state.eventSink(NotificationsOptInEvents.NotNowClicked)
}
)
}
}
@Composable
private fun NotificationsOptInContent(
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
verticalArrangement = Arrangement.spacedBy(
16.dp,
alignment = Alignment.CenterVertically
)
) {
NotificationRow(
avatarLetter = "M",
avatarColors = avatarColors("5"),
firstRowPercent = 1f,
secondRowPercent = 0.4f
)
NotificationRow(
avatarLetter = "A",
avatarColors = avatarColors("1"),
firstRowPercent = 1f,
secondRowPercent = 1f
)
NotificationRow(
avatarLetter = "T",
avatarColors = avatarColors("4"),
firstRowPercent = 0.65f,
secondRowPercent = 0f
)
}
}
}
@Composable
private fun NotificationRow(
avatarLetter: String,
avatarColors: AvatarColors,
firstRowPercent: Float,
secondRowPercent: Float,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier,
color = ElementTheme.colors.bgCanvasDisabled,
shape = RoundedCornerShape(14.dp),
shadowElevation = 2.dp,
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Avatar(
avatarData = AvatarData(id = "", name = avatarLetter, size = AvatarSize.NotificationsOptIn),
initialAvatarColors = avatarColors,
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Box(
modifier = Modifier
.clip(CircleShape)
.fillMaxWidth(firstRowPercent)
.height(10.dp)
.background(ElementTheme.colors.borderInteractiveSecondary)
)
if (secondRowPercent > 0f) {
Box(
modifier = Modifier.clip(CircleShape)
.fillMaxWidth(secondRowPercent)
.height(10.dp)
.background(ElementTheme.colors.borderInteractiveSecondary)
)
}
}
}
}
}
@DayNightPreviews
@Composable
internal fun NotificationsOptInViewPreview(
@PreviewParameter(NotificationsOptInStateProvider::class) state: NotificationsOptInState
) {
ElementPreview {
NotificationsOptInView(
onBack = {},
state = state,
)
}
}

View file

@ -16,6 +16,8 @@
package io.element.android.features.ftue.impl.state
import android.Manifest
import android.os.Build
import androidx.annotation.VisibleForTesting
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.state.FtueState
@ -23,7 +25,9 @@ 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.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.permissions.api.PermissionStateProvider
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
@ -34,10 +38,12 @@ import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultFtueState @Inject constructor(
private val sdkVersionProvider: BuildVersionSdkIntProvider,
private val coroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService,
private val welcomeScreenState: WelcomeScreenState,
private val migrationScreenStore: MigrationScreenStore,
private val permissionStateProvider: PermissionStateProvider,
private val matrixClient: MatrixClient,
) : FtueState {
@ -47,6 +53,9 @@ class DefaultFtueState @Inject constructor(
welcomeScreenState.reset()
analyticsService.reset()
migrationScreenStore.reset()
if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
permissionStateProvider.resetPermission(Manifest.permission.POST_NOTIFICATIONS)
}
}
init {
@ -63,7 +72,10 @@ class DefaultFtueState @Inject constructor(
FtueStep.MigrationScreen -> if (shouldDisplayWelcomeScreen()) FtueStep.WelcomeScreen else getNextStep(
FtueStep.WelcomeScreen
)
FtueStep.WelcomeScreen -> if (needsAnalyticsOptIn()) FtueStep.AnalyticsOptIn else getNextStep(
FtueStep.WelcomeScreen -> if (shouldAskNotificationPermissions()) FtueStep.NotificationsOptIn else getNextStep(
FtueStep.NotificationsOptIn
)
FtueStep.NotificationsOptIn -> if (needsAnalyticsOptIn()) FtueStep.AnalyticsOptIn else getNextStep(
FtueStep.AnalyticsOptIn
)
FtueStep.AnalyticsOptIn -> null
@ -73,6 +85,7 @@ class DefaultFtueState @Inject constructor(
return listOf(
shouldDisplayMigrationScreen(),
shouldDisplayWelcomeScreen(),
shouldAskNotificationPermissions(),
needsAnalyticsOptIn()
).any { it }
}
@ -90,6 +103,15 @@ class DefaultFtueState @Inject constructor(
return welcomeScreenState.isWelcomeScreenNeeded()
}
private fun shouldAskNotificationPermissions(): Boolean {
return if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
val permission = Manifest.permission.POST_NOTIFICATIONS
val isPermissionDenied = runBlocking { permissionStateProvider.isPermissionDenied(permission).first() }
val isPermissionGranted = permissionStateProvider.isPermissionGranted(permission)
!isPermissionGranted && !isPermissionDenied
} else false
}
fun setWelcomeScreenShown() {
welcomeScreenState.setWelcomeScreenShown()
updateState()
@ -104,5 +126,6 @@ class DefaultFtueState @Inject constructor(
sealed interface FtueStep {
data object MigrationScreen : FtueStep
data object WelcomeScreen : FtueStep
data object NotificationsOptIn : FtueStep
data object AnalyticsOptIn : FtueStep
}

View file

@ -2,6 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_migration_message">"This is a one time process, thanks for waiting."</string>
<string name="screen_migration_title">"Setting up your account."</string>
<string name="screen_notification_optin_subtitle">"You can change your settings later."</string>
<string name="screen_notification_optin_title">"Allow notifications and never miss a message"</string>
<string name="screen_welcome_bullet_1">"Calls, polls, search and more will be added later this year."</string>
<string name="screen_welcome_bullet_2">"Message history for encrypted rooms wont be available in this update."</string>
<string name="screen_welcome_bullet_3">"Wed love to hear from you, let us know what you think via the settings page."</string>

View file

@ -16,6 +16,7 @@
package io.element.android.features.ftue.impl
import android.os.Build
import com.google.common.truth.Truth.assertThat
import io.element.android.features.ftue.impl.migration.InMemoryMigrationScreenStore
import io.element.android.features.ftue.impl.migration.MigrationScreenStore
@ -25,8 +26,10 @@ import io.element.android.features.ftue.impl.welcome.state.FakeWelcomeState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.permissions.impl.FakePermissionStateProvider
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
@ -51,13 +54,21 @@ class DefaultFtueStateTests {
val welcomeState = FakeWelcomeState()
val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true)
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState(coroutineScope, welcomeState, analyticsService, migrationScreenStore)
val state = createState(
coroutineScope = coroutineScope,
welcomeState = welcomeState,
analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider
)
welcomeState.setWelcomeScreenShown()
analyticsService.setDidAskUserConsent()
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
permissionStateProvider.setPermissionGranted()
state.updateState()
assertThat(state.shouldDisplayFlow.value).isFalse()
@ -71,9 +82,16 @@ class DefaultFtueStateTests {
val welcomeState = FakeWelcomeState()
val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState(coroutineScope, welcomeState, analyticsService, migrationScreenStore)
val state = createState(
coroutineScope = coroutineScope,
welcomeState = welcomeState,
analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider
)
val steps = mutableListOf<FtueStep?>()
// First step, migration screen
@ -84,7 +102,11 @@ class DefaultFtueStateTests {
steps.add(state.getNextStep(steps.lastOrNull()))
welcomeState.setWelcomeScreenShown()
// Third step, analytics opt in
// Third step, notifications opt in
steps.add(state.getNextStep(steps.lastOrNull()))
permissionStateProvider.setPermissionGranted()
// Fourth step, analytics opt in
steps.add(state.getNextStep(steps.lastOrNull()))
analyticsService.setDidAskUserConsent()
@ -94,6 +116,7 @@ class DefaultFtueStateTests {
assertThat(steps).containsExactly(
FtueStep.MigrationScreen,
FtueStep.WelcomeScreen,
FtueStep.NotificationsOptIn,
FtueStep.AnalyticsOptIn,
null, // Final state
)
@ -107,11 +130,40 @@ class DefaultFtueStateTests {
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
val state = createState(
coroutineScope = coroutineScope,
analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider,
)
// Skip first 3 steps
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
state.setWelcomeScreenShown()
permissionStateProvider.setPermissionGranted()
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
analyticsService.setDidAskUserConsent()
assertThat(state.getNextStep(FtueStep.WelcomeScreen)).isNull()
// Cleanup
coroutineScope.cancel()
}
@Test
fun `if version is older than 13 we don't display the notification opt in screen`() = runTest {
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore()
val state = createState(
sdkIntVersion = Build.VERSION_CODES.M,
coroutineScope = coroutineScope,
analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore,
)
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
@ -132,12 +184,16 @@ class DefaultFtueStateTests {
welcomeState: FakeWelcomeState = FakeWelcomeState(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
migrationScreenStore: MigrationScreenStore = InMemoryMigrationScreenStore(),
permissionStateProvider: FakePermissionStateProvider = FakePermissionStateProvider(permissionGranted = false),
matrixClient: MatrixClient = FakeMatrixClient(),
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU, // First version where notification permission is required
) = DefaultFtueState(
sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
coroutineScope = coroutineScope,
analyticsService = analyticsService,
welcomeScreenState = welcomeState,
migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider,
matrixClient = matrixClient,
)
}

View file

@ -0,0 +1,140 @@
/*
* 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.notifications
import android.os.Build
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
import io.element.android.libraries.permissions.api.PermissionStateProvider
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.impl.FakePermissionStateProvider
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
class NotificationsOptInPresenterTests {
private var isFinished = false
@Test
fun `initial state`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
Truth.assertThat(initialState.notificationsPermissionState.showDialog).isFalse()
}
}
@Test
fun `show dialog on continue clicked`() = runTest {
val permissionPresenter = FakePermissionsPresenter()
val presenter = createPresenter(permissionPresenter)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(NotificationsOptInEvents.ContinueClicked)
Truth.assertThat(awaitItem().notificationsPermissionState.showDialog).isTrue()
}
}
@Test
fun `finish flow on continue clicked with permission already granted`() = runTest {
val permissionPresenter = FakePermissionsPresenter().apply {
setPermissionGranted()
}
val presenter = createPresenter(permissionPresenter)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(NotificationsOptInEvents.ContinueClicked)
Truth.assertThat(isFinished).isTrue()
}
}
@Test
fun `finish flow on not now clicked`() = runTest {
val permissionPresenter = FakePermissionsPresenter()
val presenter = createPresenter(
permissionsPresenter = permissionPresenter,
sdkIntVersion = Build.VERSION_CODES.M
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(NotificationsOptInEvents.NotNowClicked)
Truth.assertThat(isFinished).isTrue()
}
}
@Test
fun `set permission denied on not now clicked in API 33`() = runTest(StandardTestDispatcher()) {
val permissionPresenter = FakePermissionsPresenter()
val permissionStateProvider = FakePermissionStateProvider()
val presenter = createPresenter(
permissionsPresenter = permissionPresenter,
permissionStateProvider = permissionStateProvider,
sdkIntVersion = Build.VERSION_CODES.TIRAMISU
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(NotificationsOptInEvents.NotNowClicked)
// Allow background coroutines to run
runCurrent()
val isPermissionDenied = runBlocking {
permissionStateProvider.isPermissionDenied("notifications").first()
}
Truth.assertThat(isPermissionDenied).isTrue()
}
}
private fun TestScope.createPresenter(
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
permissionStateProvider: PermissionStateProvider = FakePermissionStateProvider(),
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU,
) = NotificationsOptInPresenter(
permissionsPresenterFactory = object : PermissionsPresenter.Factory {
override fun create(permission: String): PermissionsPresenter {
return permissionsPresenter
}
},
callback = object : NotificationsOptInNode.Callback {
override fun onNotificationsOptInFinished() {
isFinished = true
}
},
appCoroutineScope = this,
permissionStateProvider = permissionStateProvider,
buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
)
}