Browse Source

Replace notification permission dialog with a screen (#1223)

* Replace notification permission dialog with a screen

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
pull/1229/head
Jorge Martin Espinosa 1 year ago committed by GitHub
parent
commit
cfdccc904e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      appnav/build.gradle.kts
  2. 16
      appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt
  3. 3
      appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt
  4. 2
      appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt
  5. 9
      appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt
  6. 9
      appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt
  7. 1
      changelog.d/897.feature
  8. 6
      features/ftue/impl/build.gradle.kts
  9. 15
      features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt
  10. 22
      features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInEvents.kt
  11. 57
      features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInNode.kt
  12. 89
      features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt
  13. 24
      features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInState.kt
  14. 33
      features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInStateProvider.kt
  15. 206
      features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInView.kt
  16. 25
      features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt
  17. 2
      features/ftue/impl/src/main/res/values/localazy.xml
  18. 62
      features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt
  19. 140
      features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenterTests.kt
  20. 2
      libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt
  21. 30
      libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionStateProvider.kt
  22. 2
      libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsStore.kt
  23. 1
      libraries/permissions/impl/build.gradle.kts
  24. 4
      libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/AccompanistPermissionStateProvider.kt
  25. 48
      libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionStateProvider.kt
  26. 5
      libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt
  27. 3
      libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsStore.kt
  28. 53
      libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/FakePermissionStateProvider.kt
  29. 67
      libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt
  30. 4
      libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/FakeComposablePermissionStateProvider.kt
  31. 28
      libraries/permissions/test/build.gradle.kts
  32. 51
      libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakePermissionsPresenter.kt
  33. 6
      libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/InMemoryPermissionsStore.kt
  34. 26
      services/toolbox/test/build.gradle.kts
  35. 25
      services/toolbox/test/src/main/kotlin/io/element/android/services/toolbox/test/sdk/FakeBuildVersionSdkIntProvider.kt
  36. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.ftue.impl.notifications_null_NotificationsOptInView-D-1_2_null_0,NEXUS_5,1.0,en].png
  37. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.ftue.impl.notifications_null_NotificationsOptInView-N-1_3_null_0,NEXUS_5,1.0,en].png
  38. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.ftue.impl.welcome_null_WelcomeView-D-2_3_null,NEXUS_5,1.0,en].png
  39. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.ftue.impl.welcome_null_WelcomeView-N-2_4_null,NEXUS_5,1.0,en].png
  40. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_42,NEXUS_5,1.0,en].png
  41. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_43,NEXUS_5,1.0,en].png
  42. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_44,NEXUS_5,1.0,en].png
  43. 3
      tools/localazy/config.json

2
appnav/build.gradle.kts

@ -48,8 +48,6 @@ dependencies { @@ -48,8 +48,6 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.permissions.noop)
implementation(libs.coil)

16
appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt

@ -16,8 +16,6 @@ @@ -16,8 +16,6 @@
package io.element.android.appnav.loggedin
import android.Manifest
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@ -30,8 +28,6 @@ import io.element.android.features.networkmonitor.api.NetworkStatus @@ -30,8 +28,6 @@ import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter
import io.element.android.libraries.push.api.PushService
import kotlinx.coroutines.delay
import javax.inject.Inject
@ -40,20 +36,10 @@ private const val DELAY_BEFORE_SHOWING_SYNC_SPINNER_IN_MILLIS = 1500L @@ -40,20 +36,10 @@ private const val DELAY_BEFORE_SHOWING_SYNC_SPINNER_IN_MILLIS = 1500L
class LoggedInPresenter @Inject constructor(
private val matrixClient: MatrixClient,
private val permissionsPresenterFactory: PermissionsPresenter.Factory,
private val networkMonitor: NetworkMonitor,
private val pushService: PushService,
) : 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
override fun present(): LoggedInState {
LaunchedEffect(Unit) {
@ -66,7 +52,6 @@ class LoggedInPresenter @Inject constructor( @@ -66,7 +52,6 @@ class LoggedInPresenter @Inject constructor(
val roomListState by matrixClient.roomListService.state.collectAsState()
val networkStatus by networkMonitor.connectivity.collectAsState()
val permissionsState = postNotificationPermissionsPresenter.present()
var showSyncSpinner by remember {
mutableStateOf(false)
}
@ -82,7 +67,6 @@ class LoggedInPresenter @Inject constructor( @@ -82,7 +67,6 @@ class LoggedInPresenter @Inject constructor(
}
return LoggedInState(
showSyncSpinner = showSyncSpinner,
permissionsState = permissionsState,
)
}
}

3
appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInState.kt

@ -16,9 +16,6 @@ @@ -16,9 +16,6 @@
package io.element.android.appnav.loggedin
import io.element.android.libraries.permissions.api.PermissionsState
data class LoggedInState(
val showSyncSpinner: Boolean,
val permissionsState: PermissionsState,
)

2
appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInStateProvider.kt

@ -17,7 +17,6 @@ @@ -17,7 +17,6 @@
package io.element.android.appnav.loggedin
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.permissions.api.createDummyPostNotificationPermissionsState
open class LoggedInStateProvider : PreviewParameterProvider<LoggedInState> {
override val values: Sequence<LoggedInState>
@ -32,5 +31,4 @@ fun aLoggedInState( @@ -32,5 +31,4 @@ fun aLoggedInState(
showSyncSpinner: Boolean = true,
) = LoggedInState(
showSyncSpinner = showSyncSpinner,
permissionsState = createDummyPostNotificationPermissionsState(),
)

9
appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInView.kt

@ -23,21 +23,16 @@ import androidx.compose.foundation.layout.systemBarsPadding @@ -23,21 +23,16 @@ import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.PreviewParameter
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.ElementPreview
import io.element.android.libraries.permissions.api.PermissionsView
@Composable
fun LoggedInView(
state: LoggedInState,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
Box(
modifier = modifier
.fillMaxSize()
@ -49,10 +44,6 @@ fun LoggedInView( @@ -49,10 +44,6 @@ fun LoggedInView(
.align(Alignment.TopCenter),
isVisible = state.showSyncSpinner,
)
PermissionsView(
state = state.permissionsState,
openSystemSettings = context::openAppSettingsPage
)
}
}

9
appnav/src/test/kotlin/io/element/android/appnav/loggedin/LoggedInPresenterTest.kt

@ -26,8 +26,6 @@ import io.element.android.libraries.matrix.api.MatrixClient @@ -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.test.FakeMatrixClient
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.pushproviders.api.Distributor
import io.element.android.libraries.pushproviders.api.PushProvider
@ -43,7 +41,7 @@ class LoggedInPresenterTest { @@ -43,7 +41,7 @@ class LoggedInPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.permissionsState.permission).isEmpty()
assertThat(initialState.showSyncSpinner).isFalse()
}
}
@ -68,11 +66,6 @@ class LoggedInPresenterTest { @@ -68,11 +66,6 @@ class LoggedInPresenterTest {
): LoggedInPresenter {
return LoggedInPresenter(
matrixClient = FakeMatrixClient(roomListService = roomListService),
permissionsPresenterFactory = object : PermissionsPresenter.Factory {
override fun create(permission: String): PermissionsPresenter {
return NoopPermissionsPresenter()
}
},
networkMonitor = FakeNetworkMonitor(networkStatus),
pushService = object : PushService {
override fun notificationStyleChanged() {

1
changelog.d/897.feature

@ -0,0 +1 @@ @@ -0,0 +1 @@
Add a notification permission screen to the initial flow.

6
features/ftue/impl/build.gradle.kts

@ -43,6 +43,10 @@ dependencies { @@ -43,6 +43,10 @@ dependencies {
implementation(projects.libraries.testtags)
implementation(projects.features.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.coroutines.test)
@ -51,6 +55,8 @@ dependencies { @@ -51,6 +55,8 @@ dependencies {
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.libraries.permissions.impl)
testImplementation(projects.libraries.permissions.test)
ksp(libs.showkase.processor)
}

15
features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt

@ -35,6 +35,7 @@ import io.element.android.anvilannotations.ContributesNode @@ -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( @@ -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( @@ -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( @@ -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)
}

22
features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInEvents.kt

@ -0,0 +1,22 @@ @@ -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
}

57
features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInNode.kt

@ -0,0 +1,57 @@ @@ -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
)
}
}

89
features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt

@ -0,0 +1,89 @@ @@ -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)
}
}

24
features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInState.kt

@ -0,0 +1,24 @@ @@ -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
)

33
features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInStateProvider.kt

@ -0,0 +1,33 @@ @@ -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 = {}
)

206
features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInView.kt

@ -0,0 +1,206 @@ @@ -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,
)
}
}

25
features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt

@ -16,6 +16,8 @@ @@ -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 @@ -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 @@ -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( @@ -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( @@ -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( @@ -73,6 +85,7 @@ class DefaultFtueState @Inject constructor(
return listOf(
shouldDisplayMigrationScreen(),
shouldDisplayWelcomeScreen(),
shouldAskNotificationPermissions(),
needsAnalyticsOptIn()
).any { it }
}
@ -90,6 +103,15 @@ class DefaultFtueState @Inject constructor( @@ -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( @@ -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
}

2
features/ftue/impl/src/main/res/values/localazy.xml

@ -2,6 +2,8 @@ @@ -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 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>

62
features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt

@ -16,6 +16,7 @@ @@ -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 @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -94,6 +116,7 @@ class DefaultFtueStateTests {
assertThat(steps).containsExactly(
FtueStep.MigrationScreen,
FtueStep.WelcomeScreen,
FtueStep.NotificationsOptIn,
FtueStep.AnalyticsOptIn,
null, // Final state
)
@ -107,8 +130,37 @@ class DefaultFtueStateTests { @@ -107,8 +130,37 @@ 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,
@ -132,12 +184,16 @@ class DefaultFtueStateTests { @@ -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,
)
}

140
features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenterTests.kt

@ -0,0 +1,140 @@ @@ -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),
)
}

2
libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt

@ -42,4 +42,6 @@ enum class AvatarSize(val dp: Dp) { @@ -42,4 +42,6 @@ enum class AvatarSize(val dp: Dp) {
RoomInviteItem(52.dp),
InviteSender(16.dp),
NotificationsOptIn(32.dp),
}

30
libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionStateProvider.kt

@ -0,0 +1,30 @@ @@ -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)
}

2
libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/PermissionsStore.kt → libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsStore.kt

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.libraries.permissions.impl
package io.element.android.libraries.permissions.api
import kotlinx.coroutines.flow.Flow

1
libraries/permissions/impl/build.gradle.kts

@ -56,6 +56,7 @@ dependencies { @@ -56,6 +56,7 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.permissions.test)
ksp(libs.showkase.processor)
}

4
libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/AccompanistPermissionStateProvider.kt

@ -26,13 +26,13 @@ import com.squareup.anvil.annotations.ContributesBinding @@ -26,13 +26,13 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
interface PermissionStateProvider {
interface ComposablePermissionStateProvider {
@Composable
fun provide(permission: String, onPermissionResult: (Boolean) -> Unit): PermissionState
}
@ContributesBinding(AppScope::class)
class AccompanistPermissionStateProvider @Inject constructor() : PermissionStateProvider {
class AccompanistPermissionStateProvider @Inject constructor() : ComposablePermissionStateProvider {
@Composable
override fun provide(permission: String, onPermissionResult: (Boolean) -> Unit): PermissionState {
return rememberPermissionState(

48
libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionStateProvider.kt

@ -0,0 +1,48 @@ @@ -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)
}

5
libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt

@ -38,6 +38,7 @@ import io.element.android.libraries.di.AppScope @@ -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.PermissionsPresenter
import io.element.android.libraries.permissions.api.PermissionsState
import io.element.android.libraries.permissions.api.PermissionsStore
import kotlinx.coroutines.launch
import timber.log.Timber
@ -46,7 +47,7 @@ private val loggerTag = LoggerTag("DefaultPermissionsPresenter") @@ -46,7 +47,7 @@ private val loggerTag = LoggerTag("DefaultPermissionsPresenter")
class DefaultPermissionsPresenter @AssistedInject constructor(
@Assisted val permission: String,
private val permissionsStore: PermissionsStore,
private val permissionStateProvider: PermissionStateProvider,
private val composablePermissionStateProvider: ComposablePermissionStateProvider,
) : PermissionsPresenter {
@AssistedFactory
@ -90,7 +91,7 @@ class DefaultPermissionsPresenter @AssistedInject constructor( @@ -90,7 +91,7 @@ class DefaultPermissionsPresenter @AssistedInject constructor(
}
}
permissionState = permissionStateProvider.provide(
permissionState = composablePermissionStateProvider.provide(
permission = permission,
onPermissionResult = ::onPermissionResult
)

3
libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsStore.kt

@ -26,6 +26,7 @@ import com.squareup.anvil.annotations.ContributesBinding @@ -26,6 +26,7 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.permissions.api.PermissionsStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
@ -34,7 +35,7 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na @@ -34,7 +35,7 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
@ContributesBinding(AppScope::class)
class DefaultPermissionsStore @Inject constructor(
@ApplicationContext context: Context,
@ApplicationContext private val context: Context,
) : PermissionsStore {
private val store = context.dataStore

53
libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/FakePermissionStateProvider.kt

@ -0,0 +1,53 @@ @@ -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)
}
}

67
libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt

@ -25,6 +25,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -25,6 +25,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionStatus
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.test.InMemoryPermissionsStore
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -34,8 +35,14 @@ class DefaultPermissionsPresenterTest { @@ -34,8 +35,14 @@ class DefaultPermissionsPresenterTest {
@Test
fun `present - initial state`() = runTest {
val permissionsStore = InMemoryPermissionsStore()
val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Granted)
val permissionStateProvider = FakePermissionStateProvider(permissionState)
val permissionState = FakePermissionState(
A_PERMISSION,
PermissionStatus.Granted
)
val permissionStateProvider =
FakeComposablePermissionStateProvider(
permissionState
)
val presenter = DefaultPermissionsPresenter(
A_PERMISSION,
permissionsStore,
@ -57,8 +64,14 @@ class DefaultPermissionsPresenterTest { @@ -57,8 +64,14 @@ class DefaultPermissionsPresenterTest {
@Test
fun `present - user closes dialog`() = runTest {
val permissionsStore = InMemoryPermissionsStore()
val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = false))
val permissionStateProvider = FakePermissionStateProvider(permissionState)
val permissionState = FakePermissionState(
A_PERMISSION,
PermissionStatus.Denied(shouldShowRationale = false)
)
val permissionStateProvider =
FakeComposablePermissionStateProvider(
permissionState
)
val presenter = DefaultPermissionsPresenter(
A_PERMISSION,
permissionsStore,
@ -77,8 +90,14 @@ class DefaultPermissionsPresenterTest { @@ -77,8 +90,14 @@ class DefaultPermissionsPresenterTest {
@Test
fun `present - user does not grant permission`() = runTest {
val permissionsStore = InMemoryPermissionsStore()
val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = false))
val permissionStateProvider = FakePermissionStateProvider(permissionState)
val permissionState = FakePermissionState(
A_PERMISSION,
PermissionStatus.Denied(shouldShowRationale = false)
)
val permissionStateProvider =
FakeComposablePermissionStateProvider(
permissionState
)
val presenter = DefaultPermissionsPresenter(
A_PERMISSION,
permissionsStore,
@ -106,8 +125,14 @@ class DefaultPermissionsPresenterTest { @@ -106,8 +125,14 @@ class DefaultPermissionsPresenterTest {
@Test
fun `present - user does not grant permission second time`() = runTest {
val permissionsStore = InMemoryPermissionsStore()
val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = true))
val permissionStateProvider = FakePermissionStateProvider(permissionState)
val permissionState = FakePermissionState(
A_PERMISSION,
PermissionStatus.Denied(shouldShowRationale = true)
)
val permissionStateProvider =
FakeComposablePermissionStateProvider(
permissionState
)
val presenter = DefaultPermissionsPresenter(
A_PERMISSION,
permissionsStore,
@ -134,9 +159,19 @@ class DefaultPermissionsPresenterTest { @@ -134,9 +159,19 @@ class DefaultPermissionsPresenterTest {
@Test
fun `present - user does not grant permission third time`() = runTest {
val permissionsStore = InMemoryPermissionsStore(permissionDenied = true, permissionAsked = true)
val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = false))
val permissionStateProvider = FakePermissionStateProvider(permissionState)
val permissionsStore =
InMemoryPermissionsStore(
permissionDenied = true,
permissionAsked = true
)
val permissionState = FakePermissionState(
A_PERMISSION,
PermissionStatus.Denied(shouldShowRationale = false)
)
val permissionStateProvider =
FakeComposablePermissionStateProvider(
permissionState
)
val presenter = DefaultPermissionsPresenter(
A_PERMISSION,
permissionsStore,
@ -157,8 +192,14 @@ class DefaultPermissionsPresenterTest { @@ -157,8 +192,14 @@ class DefaultPermissionsPresenterTest {
@Test
fun `present - user grants permission`() = runTest {
val permissionsStore = InMemoryPermissionsStore()
val permissionState = FakePermissionState(A_PERMISSION, PermissionStatus.Denied(shouldShowRationale = false))
val permissionStateProvider = FakePermissionStateProvider(permissionState)
val permissionState = FakePermissionState(
A_PERMISSION,
PermissionStatus.Denied(shouldShowRationale = false)
)
val permissionStateProvider =
FakeComposablePermissionStateProvider(
permissionState
)
val presenter = DefaultPermissionsPresenter(
A_PERMISSION,
permissionsStore,

4
libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/FakePermissionStateProvider.kt → libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/FakeComposablePermissionStateProvider.kt

@ -27,9 +27,9 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -27,9 +27,9 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.PermissionStatus
class FakePermissionStateProvider constructor(
class FakeComposablePermissionStateProvider constructor(
private val permissionState: FakePermissionState
) : PermissionStateProvider {
) : ComposablePermissionStateProvider {
private lateinit var onPermissionResult: (Boolean) -> Unit
@OptIn(ExperimentalPermissionsApi::class)

28
libraries/permissions/test/build.gradle.kts

@ -0,0 +1,28 @@ @@ -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)
}

51
libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakePermissionsPresenter.kt

@ -0,0 +1,51 @@ @@ -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
}
}

6
libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/InMemoryPermissionsStore.kt → libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/InMemoryPermissionsStore.kt

@ -14,8 +14,9 @@ @@ -14,8 +14,9 @@
* 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.MutableStateFlow
@ -43,6 +44,5 @@ class InMemoryPermissionsStore( @@ -43,6 +44,5 @@ class InMemoryPermissionsStore(
setPermissionDenied(permission, false)
}
override suspend fun resetStore() {
}
override suspend fun resetStore() = Unit
}

26
services/toolbox/test/build.gradle.kts

@ -0,0 +1,26 @@ @@ -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)
}

25
services/toolbox/test/src/main/kotlin/io/element/android/services/toolbox/test/sdk/FakeBuildVersionSdkIntProvider.kt

@ -0,0 +1,25 @@ @@ -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
}

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.ftue.impl.notifications_null_NotificationsOptInView-D-1_2_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.ftue.impl.notifications_null_NotificationsOptInView-N-1_3_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.ftue.impl.welcome_null_WelcomeView-D-1_2_null,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.ftue.impl.welcome_null_WelcomeView-D-2_3_null,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.ftue.impl.welcome_null_WelcomeView-N-1_3_null,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.ftue.impl.welcome_null_WelcomeView-N-2_4_null,NEXUS_5,1.0,en].png

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_42,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_43,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[l.designsystem.components.avatar_null_Avatars_Avatar_0_null_44,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

3
tools/localazy/config.json

@ -118,7 +118,8 @@ @@ -118,7 +118,8 @@
"name": ":features:ftue:impl",
"includeRegex": [
"screen_welcome_.*",
"screen_migration_.*"
"screen_migration_.*",
"screen_notification_optin_.*"
]
},
{

Loading…
Cancel
Save