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:
parent
e7eb798894
commit
ee3d3ae213
43 changed files with 1027 additions and 69 deletions
|
|
@ -48,8 +48,6 @@ dependencies {
|
||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
implementation(projects.libraries.matrixui)
|
implementation(projects.libraries.matrixui)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.libraries.permissions.api)
|
|
||||||
implementation(projects.libraries.permissions.noop)
|
|
||||||
|
|
||||||
implementation(libs.coil)
|
implementation(libs.coil)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,6 @@
|
||||||
|
|
||||||
package io.element.android.appnav.loggedin
|
package io.element.android.appnav.loggedin
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
|
@ -30,8 +28,6 @@ import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
|
||||||
import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter
|
|
||||||
import io.element.android.libraries.push.api.PushService
|
import io.element.android.libraries.push.api.PushService
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -40,20 +36,10 @@ private const val DELAY_BEFORE_SHOWING_SYNC_SPINNER_IN_MILLIS = 1500L
|
||||||
|
|
||||||
class LoggedInPresenter @Inject constructor(
|
class LoggedInPresenter @Inject constructor(
|
||||||
private val matrixClient: MatrixClient,
|
private val matrixClient: MatrixClient,
|
||||||
private val permissionsPresenterFactory: PermissionsPresenter.Factory,
|
|
||||||
private val networkMonitor: NetworkMonitor,
|
private val networkMonitor: NetworkMonitor,
|
||||||
private val pushService: PushService,
|
private val pushService: PushService,
|
||||||
) : Presenter<LoggedInState> {
|
) : Presenter<LoggedInState> {
|
||||||
|
|
||||||
private val postNotificationPermissionsPresenter by lazy {
|
|
||||||
// Ask for POST_NOTIFICATION PERMISSION on Android 13+
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
permissionsPresenterFactory.create(Manifest.permission.POST_NOTIFICATIONS)
|
|
||||||
} else {
|
|
||||||
NoopPermissionsPresenter()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): LoggedInState {
|
override fun present(): LoggedInState {
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
|
@ -66,7 +52,6 @@ class LoggedInPresenter @Inject constructor(
|
||||||
|
|
||||||
val roomListState by matrixClient.roomListService.state.collectAsState()
|
val roomListState by matrixClient.roomListService.state.collectAsState()
|
||||||
val networkStatus by networkMonitor.connectivity.collectAsState()
|
val networkStatus by networkMonitor.connectivity.collectAsState()
|
||||||
val permissionsState = postNotificationPermissionsPresenter.present()
|
|
||||||
var showSyncSpinner by remember {
|
var showSyncSpinner by remember {
|
||||||
mutableStateOf(false)
|
mutableStateOf(false)
|
||||||
}
|
}
|
||||||
|
|
@ -82,7 +67,6 @@ class LoggedInPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
return LoggedInState(
|
return LoggedInState(
|
||||||
showSyncSpinner = showSyncSpinner,
|
showSyncSpinner = showSyncSpinner,
|
||||||
permissionsState = permissionsState,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,6 @@
|
||||||
|
|
||||||
package io.element.android.appnav.loggedin
|
package io.element.android.appnav.loggedin
|
||||||
|
|
||||||
import io.element.android.libraries.permissions.api.PermissionsState
|
|
||||||
|
|
||||||
data class LoggedInState(
|
data class LoggedInState(
|
||||||
val showSyncSpinner: Boolean,
|
val showSyncSpinner: Boolean,
|
||||||
val permissionsState: PermissionsState,
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
package io.element.android.appnav.loggedin
|
package io.element.android.appnav.loggedin
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.libraries.permissions.api.createDummyPostNotificationPermissionsState
|
|
||||||
|
|
||||||
open class LoggedInStateProvider : PreviewParameterProvider<LoggedInState> {
|
open class LoggedInStateProvider : PreviewParameterProvider<LoggedInState> {
|
||||||
override val values: Sequence<LoggedInState>
|
override val values: Sequence<LoggedInState>
|
||||||
|
|
@ -32,5 +31,4 @@ fun aLoggedInState(
|
||||||
showSyncSpinner: Boolean = true,
|
showSyncSpinner: Boolean = true,
|
||||||
) = LoggedInState(
|
) = LoggedInState(
|
||||||
showSyncSpinner = showSyncSpinner,
|
showSyncSpinner = showSyncSpinner,
|
||||||
permissionsState = createDummyPostNotificationPermissionsState(),
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -23,21 +23,16 @@ import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.libraries.androidutils.system.openAppSettingsPage
|
|
||||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.permissions.api.PermissionsView
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LoggedInView(
|
fun LoggedInView(
|
||||||
state: LoggedInState,
|
state: LoggedInState,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -49,10 +44,6 @@ fun LoggedInView(
|
||||||
.align(Alignment.TopCenter),
|
.align(Alignment.TopCenter),
|
||||||
isVisible = state.showSyncSpinner,
|
isVisible = state.showSyncSpinner,
|
||||||
)
|
)
|
||||||
PermissionsView(
|
|
||||||
state = state.permissionsState,
|
|
||||||
openSystemSettings = context::openAppSettingsPage
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,6 @@ import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
|
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
|
||||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
|
||||||
import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter
|
|
||||||
import io.element.android.libraries.push.api.PushService
|
import io.element.android.libraries.push.api.PushService
|
||||||
import io.element.android.libraries.pushproviders.api.Distributor
|
import io.element.android.libraries.pushproviders.api.Distributor
|
||||||
import io.element.android.libraries.pushproviders.api.PushProvider
|
import io.element.android.libraries.pushproviders.api.PushProvider
|
||||||
|
|
@ -43,7 +41,7 @@ class LoggedInPresenterTest {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.permissionsState.permission).isEmpty()
|
assertThat(initialState.showSyncSpinner).isFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,11 +66,6 @@ class LoggedInPresenterTest {
|
||||||
): LoggedInPresenter {
|
): LoggedInPresenter {
|
||||||
return LoggedInPresenter(
|
return LoggedInPresenter(
|
||||||
matrixClient = FakeMatrixClient(roomListService = roomListService),
|
matrixClient = FakeMatrixClient(roomListService = roomListService),
|
||||||
permissionsPresenterFactory = object : PermissionsPresenter.Factory {
|
|
||||||
override fun create(permission: String): PermissionsPresenter {
|
|
||||||
return NoopPermissionsPresenter()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
networkMonitor = FakeNetworkMonitor(networkStatus),
|
networkMonitor = FakeNetworkMonitor(networkStatus),
|
||||||
pushService = object : PushService {
|
pushService = object : PushService {
|
||||||
override fun notificationStyleChanged() {
|
override fun notificationStyleChanged() {
|
||||||
|
|
|
||||||
1
changelog.d/897.feature
Normal file
1
changelog.d/897.feature
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Add a notification permission screen to the initial flow.
|
||||||
|
|
@ -43,6 +43,10 @@ dependencies {
|
||||||
implementation(projects.libraries.testtags)
|
implementation(projects.libraries.testtags)
|
||||||
implementation(projects.features.analytics.api)
|
implementation(projects.features.analytics.api)
|
||||||
implementation(projects.services.analytics.api)
|
implementation(projects.services.analytics.api)
|
||||||
|
implementation(projects.libraries.permissions.api)
|
||||||
|
implementation(projects.libraries.permissions.noop)
|
||||||
|
implementation(projects.services.toolbox.api)
|
||||||
|
implementation(projects.services.toolbox.test)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testImplementation(libs.test.junit)
|
||||||
testImplementation(libs.coroutines.test)
|
testImplementation(libs.coroutines.test)
|
||||||
|
|
@ -51,6 +55,8 @@ dependencies {
|
||||||
testImplementation(libs.test.turbine)
|
testImplementation(libs.test.turbine)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.services.analytics.test)
|
testImplementation(projects.services.analytics.test)
|
||||||
|
testImplementation(projects.libraries.permissions.impl)
|
||||||
|
testImplementation(projects.libraries.permissions.test)
|
||||||
|
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.features.analytics.api.AnalyticsEntryPoint
|
import io.element.android.features.analytics.api.AnalyticsEntryPoint
|
||||||
import io.element.android.features.ftue.api.FtueEntryPoint
|
import io.element.android.features.ftue.api.FtueEntryPoint
|
||||||
import io.element.android.features.ftue.impl.migration.MigrationScreenNode
|
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.DefaultFtueState
|
||||||
import io.element.android.features.ftue.impl.state.FtueStep
|
import io.element.android.features.ftue.impl.state.FtueStep
|
||||||
import io.element.android.features.ftue.impl.welcome.WelcomeNode
|
import io.element.android.features.ftue.impl.welcome.WelcomeNode
|
||||||
|
|
@ -79,6 +80,9 @@ class FtueFlowNode @AssistedInject constructor(
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data object WelcomeScreen : NavTarget
|
data object WelcomeScreen : NavTarget
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data object NotificationsOptIn : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data object AnalyticsOptIn : NavTarget
|
data object AnalyticsOptIn : NavTarget
|
||||||
}
|
}
|
||||||
|
|
@ -124,6 +128,14 @@ class FtueFlowNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
createNode<WelcomeNode>(buildContext, listOf(callback))
|
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 -> {
|
NavTarget.AnalyticsOptIn -> {
|
||||||
analyticsEntryPoint.createNode(this, buildContext)
|
analyticsEntryPoint.createNode(this, buildContext)
|
||||||
}
|
}
|
||||||
|
|
@ -138,6 +150,9 @@ class FtueFlowNode @AssistedInject constructor(
|
||||||
FtueStep.WelcomeScreen -> {
|
FtueStep.WelcomeScreen -> {
|
||||||
backstack.newRoot(NavTarget.WelcomeScreen)
|
backstack.newRoot(NavTarget.WelcomeScreen)
|
||||||
}
|
}
|
||||||
|
FtueStep.NotificationsOptIn -> {
|
||||||
|
backstack.newRoot(NavTarget.NotificationsOptIn)
|
||||||
|
}
|
||||||
FtueStep.AnalyticsOptIn -> {
|
FtueStep.AnalyticsOptIn -> {
|
||||||
backstack.replace(NavTarget.AnalyticsOptIn)
|
backstack.replace(NavTarget.AnalyticsOptIn)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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 = {}
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,8 @@
|
||||||
|
|
||||||
package io.element.android.features.ftue.impl.state
|
package io.element.android.features.ftue.impl.state
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.os.Build
|
||||||
import androidx.annotation.VisibleForTesting
|
import androidx.annotation.VisibleForTesting
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.features.ftue.api.state.FtueState
|
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.features.ftue.impl.welcome.state.WelcomeScreenState
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
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.analytics.api.AnalyticsService
|
||||||
|
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
@ -34,10 +38,12 @@ import javax.inject.Inject
|
||||||
|
|
||||||
@ContributesBinding(SessionScope::class)
|
@ContributesBinding(SessionScope::class)
|
||||||
class DefaultFtueState @Inject constructor(
|
class DefaultFtueState @Inject constructor(
|
||||||
|
private val sdkVersionProvider: BuildVersionSdkIntProvider,
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
private val welcomeScreenState: WelcomeScreenState,
|
private val welcomeScreenState: WelcomeScreenState,
|
||||||
private val migrationScreenStore: MigrationScreenStore,
|
private val migrationScreenStore: MigrationScreenStore,
|
||||||
|
private val permissionStateProvider: PermissionStateProvider,
|
||||||
private val matrixClient: MatrixClient,
|
private val matrixClient: MatrixClient,
|
||||||
) : FtueState {
|
) : FtueState {
|
||||||
|
|
||||||
|
|
@ -47,6 +53,9 @@ class DefaultFtueState @Inject constructor(
|
||||||
welcomeScreenState.reset()
|
welcomeScreenState.reset()
|
||||||
analyticsService.reset()
|
analyticsService.reset()
|
||||||
migrationScreenStore.reset()
|
migrationScreenStore.reset()
|
||||||
|
if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
|
||||||
|
permissionStateProvider.resetPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
|
@ -63,7 +72,10 @@ class DefaultFtueState @Inject constructor(
|
||||||
FtueStep.MigrationScreen -> if (shouldDisplayWelcomeScreen()) FtueStep.WelcomeScreen else getNextStep(
|
FtueStep.MigrationScreen -> if (shouldDisplayWelcomeScreen()) FtueStep.WelcomeScreen else getNextStep(
|
||||||
FtueStep.WelcomeScreen
|
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
|
||||||
)
|
)
|
||||||
FtueStep.AnalyticsOptIn -> null
|
FtueStep.AnalyticsOptIn -> null
|
||||||
|
|
@ -73,6 +85,7 @@ class DefaultFtueState @Inject constructor(
|
||||||
return listOf(
|
return listOf(
|
||||||
shouldDisplayMigrationScreen(),
|
shouldDisplayMigrationScreen(),
|
||||||
shouldDisplayWelcomeScreen(),
|
shouldDisplayWelcomeScreen(),
|
||||||
|
shouldAskNotificationPermissions(),
|
||||||
needsAnalyticsOptIn()
|
needsAnalyticsOptIn()
|
||||||
).any { it }
|
).any { it }
|
||||||
}
|
}
|
||||||
|
|
@ -90,6 +103,15 @@ class DefaultFtueState @Inject constructor(
|
||||||
return welcomeScreenState.isWelcomeScreenNeeded()
|
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() {
|
fun setWelcomeScreenShown() {
|
||||||
welcomeScreenState.setWelcomeScreenShown()
|
welcomeScreenState.setWelcomeScreenShown()
|
||||||
updateState()
|
updateState()
|
||||||
|
|
@ -104,5 +126,6 @@ class DefaultFtueState @Inject constructor(
|
||||||
sealed interface FtueStep {
|
sealed interface FtueStep {
|
||||||
data object MigrationScreen : FtueStep
|
data object MigrationScreen : FtueStep
|
||||||
data object WelcomeScreen : FtueStep
|
data object WelcomeScreen : FtueStep
|
||||||
|
data object NotificationsOptIn : FtueStep
|
||||||
data object AnalyticsOptIn : FtueStep
|
data object AnalyticsOptIn : FtueStep
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<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_message">"This is a one time process, thanks for waiting."</string>
|
||||||
<string name="screen_migration_title">"Setting up your account."</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_1">"Calls, polls, search and more will be added later this year."</string>
|
||||||
<string name="screen_welcome_bullet_2">"Message history for encrypted rooms won’t be available in this update."</string>
|
<string name="screen_welcome_bullet_2">"Message history for encrypted rooms won’t be available in this update."</string>
|
||||||
<string name="screen_welcome_bullet_3">"We’d love to hear from you, let us know what you think via the settings page."</string>
|
<string name="screen_welcome_bullet_3">"We’d love to hear from you, let us know what you think via the settings page."</string>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.ftue.impl
|
package io.element.android.features.ftue.impl
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.ftue.impl.migration.InMemoryMigrationScreenStore
|
import io.element.android.features.ftue.impl.migration.InMemoryMigrationScreenStore
|
||||||
import io.element.android.features.ftue.impl.migration.MigrationScreenStore
|
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.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
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.api.AnalyticsService
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
|
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
|
@ -51,13 +54,21 @@ class DefaultFtueStateTests {
|
||||||
val welcomeState = FakeWelcomeState()
|
val welcomeState = FakeWelcomeState()
|
||||||
val analyticsService = FakeAnalyticsService()
|
val analyticsService = FakeAnalyticsService()
|
||||||
val migrationScreenStore = InMemoryMigrationScreenStore()
|
val migrationScreenStore = InMemoryMigrationScreenStore()
|
||||||
|
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true)
|
||||||
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
|
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()
|
welcomeState.setWelcomeScreenShown()
|
||||||
analyticsService.setDidAskUserConsent()
|
analyticsService.setDidAskUserConsent()
|
||||||
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
|
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
|
||||||
|
permissionStateProvider.setPermissionGranted()
|
||||||
state.updateState()
|
state.updateState()
|
||||||
|
|
||||||
assertThat(state.shouldDisplayFlow.value).isFalse()
|
assertThat(state.shouldDisplayFlow.value).isFalse()
|
||||||
|
|
@ -71,9 +82,16 @@ class DefaultFtueStateTests {
|
||||||
val welcomeState = FakeWelcomeState()
|
val welcomeState = FakeWelcomeState()
|
||||||
val analyticsService = FakeAnalyticsService()
|
val analyticsService = FakeAnalyticsService()
|
||||||
val migrationScreenStore = InMemoryMigrationScreenStore()
|
val migrationScreenStore = InMemoryMigrationScreenStore()
|
||||||
|
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
|
||||||
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
|
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?>()
|
val steps = mutableListOf<FtueStep?>()
|
||||||
|
|
||||||
// First step, migration screen
|
// First step, migration screen
|
||||||
|
|
@ -84,7 +102,11 @@ class DefaultFtueStateTests {
|
||||||
steps.add(state.getNextStep(steps.lastOrNull()))
|
steps.add(state.getNextStep(steps.lastOrNull()))
|
||||||
welcomeState.setWelcomeScreenShown()
|
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()))
|
steps.add(state.getNextStep(steps.lastOrNull()))
|
||||||
analyticsService.setDidAskUserConsent()
|
analyticsService.setDidAskUserConsent()
|
||||||
|
|
||||||
|
|
@ -94,6 +116,7 @@ class DefaultFtueStateTests {
|
||||||
assertThat(steps).containsExactly(
|
assertThat(steps).containsExactly(
|
||||||
FtueStep.MigrationScreen,
|
FtueStep.MigrationScreen,
|
||||||
FtueStep.WelcomeScreen,
|
FtueStep.WelcomeScreen,
|
||||||
|
FtueStep.NotificationsOptIn,
|
||||||
FtueStep.AnalyticsOptIn,
|
FtueStep.AnalyticsOptIn,
|
||||||
null, // Final state
|
null, // Final state
|
||||||
)
|
)
|
||||||
|
|
@ -107,11 +130,40 @@ class DefaultFtueStateTests {
|
||||||
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
|
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||||
val analyticsService = FakeAnalyticsService()
|
val analyticsService = FakeAnalyticsService()
|
||||||
val migrationScreenStore = InMemoryMigrationScreenStore()
|
val migrationScreenStore = InMemoryMigrationScreenStore()
|
||||||
|
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
|
||||||
|
|
||||||
val state = createState(
|
val state = createState(
|
||||||
coroutineScope = coroutineScope,
|
coroutineScope = coroutineScope,
|
||||||
analyticsService = analyticsService,
|
analyticsService = analyticsService,
|
||||||
migrationScreenStore = migrationScreenStore,
|
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)
|
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
|
||||||
|
|
@ -132,12 +184,16 @@ class DefaultFtueStateTests {
|
||||||
welcomeState: FakeWelcomeState = FakeWelcomeState(),
|
welcomeState: FakeWelcomeState = FakeWelcomeState(),
|
||||||
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
||||||
migrationScreenStore: MigrationScreenStore = InMemoryMigrationScreenStore(),
|
migrationScreenStore: MigrationScreenStore = InMemoryMigrationScreenStore(),
|
||||||
|
permissionStateProvider: FakePermissionStateProvider = FakePermissionStateProvider(permissionGranted = false),
|
||||||
matrixClient: MatrixClient = FakeMatrixClient(),
|
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||||
|
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU, // First version where notification permission is required
|
||||||
) = DefaultFtueState(
|
) = DefaultFtueState(
|
||||||
|
sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
|
||||||
coroutineScope = coroutineScope,
|
coroutineScope = coroutineScope,
|
||||||
analyticsService = analyticsService,
|
analyticsService = analyticsService,
|
||||||
welcomeScreenState = welcomeState,
|
welcomeScreenState = welcomeState,
|
||||||
migrationScreenStore = migrationScreenStore,
|
migrationScreenStore = migrationScreenStore,
|
||||||
|
permissionStateProvider = permissionStateProvider,
|
||||||
matrixClient = matrixClient,
|
matrixClient = matrixClient,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -42,4 +42,6 @@ enum class AvatarSize(val dp: Dp) {
|
||||||
|
|
||||||
RoomInviteItem(52.dp),
|
RoomInviteItem(52.dp),
|
||||||
InviteSender(16.dp),
|
InviteSender(16.dp),
|
||||||
|
|
||||||
|
NotificationsOptIn(32.dp),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.libraries.permissions.api
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface PermissionStateProvider {
|
||||||
|
fun isPermissionGranted(permission: String): Boolean
|
||||||
|
suspend fun setPermissionDenied(permission: String, value: Boolean)
|
||||||
|
fun isPermissionDenied(permission: String): Flow<Boolean>
|
||||||
|
|
||||||
|
suspend fun setPermissionAsked(permission: String, value: Boolean)
|
||||||
|
fun isPermissionAsked(permission: String): Flow<Boolean>
|
||||||
|
|
||||||
|
suspend fun resetPermission(permission: String)
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.libraries.permissions.impl
|
package io.element.android.libraries.permissions.api
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
|
@ -56,6 +56,7 @@ dependencies {
|
||||||
testImplementation(libs.test.truth)
|
testImplementation(libs.test.truth)
|
||||||
testImplementation(libs.test.turbine)
|
testImplementation(libs.test.turbine)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
|
testImplementation(projects.libraries.permissions.test)
|
||||||
|
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,13 @@ import com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
interface PermissionStateProvider {
|
interface ComposablePermissionStateProvider {
|
||||||
@Composable
|
@Composable
|
||||||
fun provide(permission: String, onPermissionResult: (Boolean) -> Unit): PermissionState
|
fun provide(permission: String, onPermissionResult: (Boolean) -> Unit): PermissionState
|
||||||
}
|
}
|
||||||
|
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
class AccompanistPermissionStateProvider @Inject constructor() : PermissionStateProvider {
|
class AccompanistPermissionStateProvider @Inject constructor() : ComposablePermissionStateProvider {
|
||||||
@Composable
|
@Composable
|
||||||
override fun provide(permission: String, onPermissionResult: (Boolean) -> Unit): PermissionState {
|
override fun provide(permission: String, onPermissionResult: (Boolean) -> Unit): PermissionState {
|
||||||
return rememberPermissionState(
|
return rememberPermissionState(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
/*
|
||||||
|
* 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.permissions.impl
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import io.element.android.libraries.di.SingleIn
|
||||||
|
import io.element.android.libraries.permissions.api.PermissionStateProvider
|
||||||
|
import io.element.android.libraries.permissions.api.PermissionsStore
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@SingleIn(AppScope::class)
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class DefaultPermissionStateProvider @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val permissionsStore: PermissionsStore,
|
||||||
|
): PermissionStateProvider {
|
||||||
|
override fun isPermissionGranted(permission: String): Boolean {
|
||||||
|
return context.checkSelfPermission(permission) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setPermissionDenied(permission: String, value: Boolean) = permissionsStore.setPermissionDenied(permission, value)
|
||||||
|
|
||||||
|
override fun isPermissionDenied(permission: String): Flow<Boolean> = permissionsStore.isPermissionDenied(permission)
|
||||||
|
|
||||||
|
override suspend fun setPermissionAsked(permission: String, value: Boolean) = permissionsStore.setPermissionAsked(permission, value)
|
||||||
|
|
||||||
|
override fun isPermissionAsked(permission: String): Flow<Boolean> = permissionsStore.isPermissionAsked(permission)
|
||||||
|
|
||||||
|
override suspend fun resetPermission(permission: String) = permissionsStore.resetPermission(permission)
|
||||||
|
}
|
||||||
|
|
@ -38,6 +38,7 @@ import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.permissions.api.PermissionsEvents
|
import io.element.android.libraries.permissions.api.PermissionsEvents
|
||||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
import io.element.android.libraries.permissions.api.PermissionsState
|
import io.element.android.libraries.permissions.api.PermissionsState
|
||||||
|
import io.element.android.libraries.permissions.api.PermissionsStore
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
|
|
@ -46,7 +47,7 @@ private val loggerTag = LoggerTag("DefaultPermissionsPresenter")
|
||||||
class DefaultPermissionsPresenter @AssistedInject constructor(
|
class DefaultPermissionsPresenter @AssistedInject constructor(
|
||||||
@Assisted val permission: String,
|
@Assisted val permission: String,
|
||||||
private val permissionsStore: PermissionsStore,
|
private val permissionsStore: PermissionsStore,
|
||||||
private val permissionStateProvider: PermissionStateProvider,
|
private val composablePermissionStateProvider: ComposablePermissionStateProvider,
|
||||||
) : PermissionsPresenter {
|
) : PermissionsPresenter {
|
||||||
|
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
|
|
@ -90,7 +91,7 @@ class DefaultPermissionsPresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
permissionState = permissionStateProvider.provide(
|
permissionState = composablePermissionStateProvider.provide(
|
||||||
permission = permission,
|
permission = permission,
|
||||||
onPermissionResult = ::onPermissionResult
|
onPermissionResult = ::onPermissionResult
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.libraries.core.bool.orFalse
|
import io.element.android.libraries.core.bool.orFalse
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.di.ApplicationContext
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import io.element.android.libraries.permissions.api.PermissionsStore
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -34,7 +35,7 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
|
||||||
|
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
class DefaultPermissionsStore @Inject constructor(
|
class DefaultPermissionsStore @Inject constructor(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
) : PermissionsStore {
|
) : PermissionsStore {
|
||||||
private val store = context.dataStore
|
private val store = context.dataStore
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.libraries.permissions.impl
|
||||||
|
|
||||||
|
import io.element.android.libraries.permissions.api.PermissionStateProvider
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
|
class FakePermissionStateProvider(
|
||||||
|
private var permissionGranted: Boolean = true,
|
||||||
|
permissionDenied: Boolean = false,
|
||||||
|
permissionAsked: Boolean = false,
|
||||||
|
): PermissionStateProvider {
|
||||||
|
private val permissionDeniedFlow = MutableStateFlow(permissionDenied)
|
||||||
|
private val permissionAskedFlow = MutableStateFlow(permissionAsked)
|
||||||
|
|
||||||
|
fun setPermissionGranted() {
|
||||||
|
permissionGranted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isPermissionGranted(permission: String): Boolean = permissionGranted
|
||||||
|
|
||||||
|
override suspend fun setPermissionDenied(permission: String, value: Boolean) {
|
||||||
|
permissionDeniedFlow.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isPermissionDenied(permission: String): Flow<Boolean> = permissionDeniedFlow
|
||||||
|
|
||||||
|
override suspend fun setPermissionAsked(permission: String, value: Boolean) {
|
||||||
|
permissionAskedFlow.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isPermissionAsked(permission: String): Flow<Boolean> = permissionAskedFlow
|
||||||
|
|
||||||
|
override suspend fun resetPermission(permission: String) {
|
||||||
|
setPermissionAsked(permission, false)
|
||||||
|
setPermissionDenied(permission, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
import com.google.accompanist.permissions.PermissionStatus
|
import com.google.accompanist.permissions.PermissionStatus
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.permissions.api.PermissionsEvents
|
import io.element.android.libraries.permissions.api.PermissionsEvents
|
||||||
|
import io.element.android.libraries.permissions.test.InMemoryPermissionsStore
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
@ -34,8 +35,14 @@ class DefaultPermissionsPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - initial state`() = runTest {
|
fun `present - initial state`() = runTest {
|
||||||
val permissionsStore = InMemoryPermissionsStore()
|
val permissionsStore = InMemoryPermissionsStore()
|
||||||
val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Granted)
|
val permissionState = FakePermissionState(
|
||||||
val permissionStateProvider = FakePermissionStateProvider(permissionState)
|
A_PERMISSION,
|
||||||
|
PermissionStatus.Granted
|
||||||
|
)
|
||||||
|
val permissionStateProvider =
|
||||||
|
FakeComposablePermissionStateProvider(
|
||||||
|
permissionState
|
||||||
|
)
|
||||||
val presenter = DefaultPermissionsPresenter(
|
val presenter = DefaultPermissionsPresenter(
|
||||||
A_PERMISSION,
|
A_PERMISSION,
|
||||||
permissionsStore,
|
permissionsStore,
|
||||||
|
|
@ -57,8 +64,14 @@ class DefaultPermissionsPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - user closes dialog`() = runTest {
|
fun `present - user closes dialog`() = runTest {
|
||||||
val permissionsStore = InMemoryPermissionsStore()
|
val permissionsStore = InMemoryPermissionsStore()
|
||||||
val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = false))
|
val permissionState = FakePermissionState(
|
||||||
val permissionStateProvider = FakePermissionStateProvider(permissionState)
|
A_PERMISSION,
|
||||||
|
PermissionStatus.Denied(shouldShowRationale = false)
|
||||||
|
)
|
||||||
|
val permissionStateProvider =
|
||||||
|
FakeComposablePermissionStateProvider(
|
||||||
|
permissionState
|
||||||
|
)
|
||||||
val presenter = DefaultPermissionsPresenter(
|
val presenter = DefaultPermissionsPresenter(
|
||||||
A_PERMISSION,
|
A_PERMISSION,
|
||||||
permissionsStore,
|
permissionsStore,
|
||||||
|
|
@ -77,8 +90,14 @@ class DefaultPermissionsPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - user does not grant permission`() = runTest {
|
fun `present - user does not grant permission`() = runTest {
|
||||||
val permissionsStore = InMemoryPermissionsStore()
|
val permissionsStore = InMemoryPermissionsStore()
|
||||||
val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = false))
|
val permissionState = FakePermissionState(
|
||||||
val permissionStateProvider = FakePermissionStateProvider(permissionState)
|
A_PERMISSION,
|
||||||
|
PermissionStatus.Denied(shouldShowRationale = false)
|
||||||
|
)
|
||||||
|
val permissionStateProvider =
|
||||||
|
FakeComposablePermissionStateProvider(
|
||||||
|
permissionState
|
||||||
|
)
|
||||||
val presenter = DefaultPermissionsPresenter(
|
val presenter = DefaultPermissionsPresenter(
|
||||||
A_PERMISSION,
|
A_PERMISSION,
|
||||||
permissionsStore,
|
permissionsStore,
|
||||||
|
|
@ -106,8 +125,14 @@ class DefaultPermissionsPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - user does not grant permission second time`() = runTest {
|
fun `present - user does not grant permission second time`() = runTest {
|
||||||
val permissionsStore = InMemoryPermissionsStore()
|
val permissionsStore = InMemoryPermissionsStore()
|
||||||
val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = true))
|
val permissionState = FakePermissionState(
|
||||||
val permissionStateProvider = FakePermissionStateProvider(permissionState)
|
A_PERMISSION,
|
||||||
|
PermissionStatus.Denied(shouldShowRationale = true)
|
||||||
|
)
|
||||||
|
val permissionStateProvider =
|
||||||
|
FakeComposablePermissionStateProvider(
|
||||||
|
permissionState
|
||||||
|
)
|
||||||
val presenter = DefaultPermissionsPresenter(
|
val presenter = DefaultPermissionsPresenter(
|
||||||
A_PERMISSION,
|
A_PERMISSION,
|
||||||
permissionsStore,
|
permissionsStore,
|
||||||
|
|
@ -134,9 +159,19 @@ class DefaultPermissionsPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - user does not grant permission third time`() = runTest {
|
fun `present - user does not grant permission third time`() = runTest {
|
||||||
val permissionsStore = InMemoryPermissionsStore(permissionDenied = true, permissionAsked = true)
|
val permissionsStore =
|
||||||
val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = false))
|
InMemoryPermissionsStore(
|
||||||
val permissionStateProvider = FakePermissionStateProvider(permissionState)
|
permissionDenied = true,
|
||||||
|
permissionAsked = true
|
||||||
|
)
|
||||||
|
val permissionState = FakePermissionState(
|
||||||
|
A_PERMISSION,
|
||||||
|
PermissionStatus.Denied(shouldShowRationale = false)
|
||||||
|
)
|
||||||
|
val permissionStateProvider =
|
||||||
|
FakeComposablePermissionStateProvider(
|
||||||
|
permissionState
|
||||||
|
)
|
||||||
val presenter = DefaultPermissionsPresenter(
|
val presenter = DefaultPermissionsPresenter(
|
||||||
A_PERMISSION,
|
A_PERMISSION,
|
||||||
permissionsStore,
|
permissionsStore,
|
||||||
|
|
@ -157,8 +192,14 @@ class DefaultPermissionsPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - user grants permission`() = runTest {
|
fun `present - user grants permission`() = runTest {
|
||||||
val permissionsStore = InMemoryPermissionsStore()
|
val permissionsStore = InMemoryPermissionsStore()
|
||||||
val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = false))
|
val permissionState = FakePermissionState(
|
||||||
val permissionStateProvider = FakePermissionStateProvider(permissionState)
|
A_PERMISSION,
|
||||||
|
PermissionStatus.Denied(shouldShowRationale = false)
|
||||||
|
)
|
||||||
|
val permissionStateProvider =
|
||||||
|
FakeComposablePermissionStateProvider(
|
||||||
|
permissionState
|
||||||
|
)
|
||||||
val presenter = DefaultPermissionsPresenter(
|
val presenter = DefaultPermissionsPresenter(
|
||||||
A_PERMISSION,
|
A_PERMISSION,
|
||||||
permissionsStore,
|
permissionsStore,
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,9 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
import com.google.accompanist.permissions.PermissionState
|
import com.google.accompanist.permissions.PermissionState
|
||||||
import com.google.accompanist.permissions.PermissionStatus
|
import com.google.accompanist.permissions.PermissionStatus
|
||||||
|
|
||||||
class FakePermissionStateProvider constructor(
|
class FakeComposablePermissionStateProvider constructor(
|
||||||
private val permissionState: FakePermissionState
|
private val permissionState: FakePermissionState
|
||||||
) : PermissionStateProvider {
|
) : ComposablePermissionStateProvider {
|
||||||
private lateinit var onPermissionResult: (Boolean) -> Unit
|
private lateinit var onPermissionResult: (Boolean) -> Unit
|
||||||
|
|
||||||
@OptIn(ExperimentalPermissionsApi::class)
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
28
libraries/permissions/test/build.gradle.kts
Normal file
28
libraries/permissions/test/build.gradle.kts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
/*
|
||||||
|
* 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.element.android.libraries.permissions.test"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(projects.libraries.architecture)
|
||||||
|
api(projects.libraries.permissions.api)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
/*
|
||||||
|
* 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.permissions.test
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import io.element.android.libraries.permissions.api.PermissionsEvents
|
||||||
|
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
|
import io.element.android.libraries.permissions.api.PermissionsState
|
||||||
|
import io.element.android.libraries.permissions.api.aPermissionsState
|
||||||
|
|
||||||
|
class FakePermissionsPresenter(
|
||||||
|
private val initialState: PermissionsState = aPermissionsState().copy(showDialog = false),
|
||||||
|
) : PermissionsPresenter {
|
||||||
|
|
||||||
|
private fun eventSink(events: PermissionsEvents) {
|
||||||
|
when (events) {
|
||||||
|
PermissionsEvents.OpenSystemDialog -> state.value = state.value.copy(showDialog = true, permissionAlreadyAsked = true)
|
||||||
|
PermissionsEvents.CloseDialog -> state.value = state.value.copy(showDialog = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val state = mutableStateOf(initialState.copy(eventSink = ::eventSink))
|
||||||
|
|
||||||
|
fun setPermissionGranted() {
|
||||||
|
state.value = state.value.copy(permissionGranted = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPermissionDenied() {
|
||||||
|
state.value = state.value.copy(permissionAlreadyDenied = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): PermissionsState {
|
||||||
|
return state.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,8 +14,9 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.libraries.permissions.impl
|
package io.element.android.libraries.permissions.test
|
||||||
|
|
||||||
|
import io.element.android.libraries.permissions.api.PermissionsStore
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
|
|
@ -43,6 +44,5 @@ class InMemoryPermissionsStore(
|
||||||
setPermissionDenied(permission, false)
|
setPermissionDenied(permission, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun resetStore() {
|
override suspend fun resetStore() = Unit
|
||||||
}
|
|
||||||
}
|
}
|
||||||
26
services/toolbox/test/build.gradle.kts
Normal file
26
services/toolbox/test/build.gradle.kts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* 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.services.toolbox.test"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
api(projects.services.toolbox.api)
|
||||||
|
}
|
||||||
|
|
@ -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.services.toolbox.test.sdk
|
||||||
|
|
||||||
|
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
|
||||||
|
|
||||||
|
class FakeBuildVersionSdkIntProvider(
|
||||||
|
private val sdkInt: Int
|
||||||
|
) : BuildVersionSdkIntProvider {
|
||||||
|
override fun get(): Int = sdkInt
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:2a47f94d02280f2122c0743fadc2d53824555f2e57e2c595b07dc32836f80586
|
||||||
|
size 37142
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:f9028f6a5a6f67954385bcbcc098535071c096f37d12428c32af94a3e898bf6a
|
||||||
|
size 33685
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:5c50d9aee05b3e8b8be0b403a0621bc949c08dbca46695f6c70666002d5101d2
|
||||||
|
size 18355
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:d56abddf692484f8e2f3795c6cd3c62e8497a2d03042ef770fb75e8863921d40
|
||||||
|
size 17689
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:f0cb196c59e3bbd845f0e110150ac358ca78c9c08c212e184423ead5d841f67c
|
||||||
|
size 20219
|
||||||
|
|
@ -118,7 +118,8 @@
|
||||||
"name": ":features:ftue:impl",
|
"name": ":features:ftue:impl",
|
||||||
"includeRegex": [
|
"includeRegex": [
|
||||||
"screen_welcome_.*",
|
"screen_welcome_.*",
|
||||||
"screen_migration_.*"
|
"screen_migration_.*",
|
||||||
|
"screen_notification_optin_.*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue