Merge pull request #1400 from vector-im/feature/bma/fixCrashPhoto

Fix crash when user wants to use the camera
This commit is contained in:
Benoit Marty 2023-09-21 16:31:36 +02:00 committed by GitHub
commit 5119ca33dd
47 changed files with 597 additions and 143 deletions

View file

@ -17,6 +17,7 @@
package io.element.android.libraries.permissions.api
sealed interface PermissionsEvents {
data object OpenSystemDialog : PermissionsEvents
data object RequestPermissions : PermissionsEvents
data object CloseDialog : PermissionsEvents
data object OpenSystemSettingAndCloseDialog : PermissionsEvents
}

View file

@ -16,67 +16,42 @@
package io.element.android.libraries.permissions.api
import android.Manifest
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun PermissionsView(
state: PermissionsState,
modifier: Modifier = Modifier,
openSystemSettings: () -> Unit = {},
) {
if (state.showDialog.not()) return
when {
state.permissionGranted -> {
// Notification Granted, nothing to do
}
state.permissionAlreadyDenied -> {
// In this case, tell the user to go to the settings
ConfirmationDialog(
modifier = modifier,
title = "System",
content = "In order to let the application display notification, please grant the permission to the system settings",
submitText = "Open settings",
onSubmitClicked = {
state.eventSink.invoke(PermissionsEvents.CloseDialog)
openSystemSettings()
},
onDismiss = { state.eventSink.invoke(PermissionsEvents.CloseDialog) },
)
}
else -> {
val textToShow = if (state.shouldShowRationale) {
// TODO Move to state
// If the user has denied the permission but the rationale can be shown,
// then gently explain why the app requires this permission
// permissions_rationale_msg_notification
"To be able to receive notifications, please grant the permission. Else you will not be able to be alerted if you've got new messages."
} else {
// TODO Move to state
// If it's the first time the user lands on this feature, or the user
// doesn't want to be asked again for this permission, explain that the
// permission is required
"To be able to receive notifications, please grant the permission."
}
ConfirmationDialog(
modifier = modifier,
title = "Notifications",
content = textToShow,
submitText = "Request permission",
onSubmitClicked = {
state.eventSink.invoke(PermissionsEvents.OpenSystemDialog)
},
onCancelClicked = {
state.eventSink.invoke(PermissionsEvents.CloseDialog)
},
onDismiss = {}
)
}
ConfirmationDialog(
modifier = modifier,
title = stringResource(id = CommonStrings.common_permission),
content = state.permission.toDialogContent(),
submitText = stringResource(id = CommonStrings.action_open_settings),
onSubmitClicked = {
state.eventSink.invoke(PermissionsEvents.OpenSystemSettingAndCloseDialog)
},
onDismiss = { state.eventSink.invoke(PermissionsEvents.CloseDialog) },
)
}
@Composable
private fun String.toDialogContent(): String {
return when (this) {
Manifest.permission.POST_NOTIFICATIONS -> stringResource(id = R.string.dialog_permission_notification)
Manifest.permission.CAMERA -> stringResource(id = R.string.dialog_permission_camera)
Manifest.permission.RECORD_AUDIO -> stringResource(id = R.string.dialog_permission_microphone)
else -> stringResource(id = R.string.dialog_permission_generic)
}
}

View file

@ -22,17 +22,21 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class PermissionsViewStateProvider : PreviewParameterProvider<PermissionsState> {
override val values: Sequence<PermissionsState>
get() = sequenceOf(
aPermissionsState(),
aPermissionsState().copy(shouldShowRationale = true),
aPermissionsState().copy(permissionAlreadyDenied = true),
aPermissionsState(showDialog = true, permission = Manifest.permission.POST_NOTIFICATIONS),
aPermissionsState(showDialog = true, permission = Manifest.permission.CAMERA),
aPermissionsState(showDialog = true, permission = Manifest.permission.RECORD_AUDIO),
aPermissionsState(showDialog = true, permission = Manifest.permission.INTERNET),
)
}
fun aPermissionsState() = PermissionsState(
permission = Manifest.permission.INTERNET,
fun aPermissionsState(
showDialog: Boolean,
permission: String = Manifest.permission.POST_NOTIFICATIONS
) = PermissionsState(
permission = permission,
permissionGranted = false,
shouldShowRationale = false,
showDialog = true,
showDialog = showDialog,
permissionAlreadyAsked = false,
permissionAlreadyDenied = false,
eventSink = {}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="dialog_permission_camera">"In order to let the application use the camera, please grant the permission to the system settings."</string>
<string name="dialog_permission_generic">"Please grant the permission to the system settings."</string>
<string name="dialog_permission_microphone">"In order to let the application use the microphone, please grant the permission to the system settings."</string>
<string name="dialog_permission_notification">"In order to let the application display notification, please grant the permission to the system settings."</string>
</resources>

View file

@ -39,6 +39,7 @@ 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 io.element.android.libraries.permissions.impl.action.PermissionActions
import kotlinx.coroutines.launch
import timber.log.Timber
@ -48,6 +49,7 @@ class DefaultPermissionsPresenter @AssistedInject constructor(
@Assisted val permission: String,
private val permissionsStore: PermissionsStore,
private val composablePermissionStateProvider: ComposablePermissionStateProvider,
private val permissionActions: PermissionActions,
) : PermissionsPresenter {
@AssistedFactory
@ -98,20 +100,27 @@ class DefaultPermissionsPresenter @AssistedInject constructor(
LaunchedEffect(this) {
if (permissionState.status.isGranted) {
// User may have granted permission from the settings, to reset the store regarding this permission
// User may have granted permission from the settings, so reset the store regarding this permission
permissionsStore.resetPermission(permission)
}
}
val showDialog = rememberSaveable { mutableStateOf(permissionState.status !is PermissionStatus.Granted) }
val showDialog = rememberSaveable { mutableStateOf(false) }
fun handleEvents(event: PermissionsEvents) {
when (event) {
PermissionsEvents.CloseDialog -> {
showDialog.value = false
}
PermissionsEvents.OpenSystemDialog -> {
permissionState.launchPermissionRequest()
PermissionsEvents.RequestPermissions -> {
if (permissionState.status !is PermissionStatus.Granted && isAlreadyDenied) {
showDialog.value = true
} else {
permissionState.launchPermissionRequest()
}
}
PermissionsEvents.OpenSystemSettingAndCloseDialog -> {
permissionActions.openSettings()
showDialog.value = false
}
}

View file

@ -0,0 +1,34 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.permissions.impl.action
import android.content.Context
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.androidutils.system.openAppSettingsPage
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class AndroidPermissionActions @Inject constructor(
@ApplicationContext private val context: Context
) : PermissionActions {
override fun openSettings() {
context.openAppSettingsPage()
}
}

View file

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

View file

@ -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.impl.action.FakePermissionActions
import io.element.android.libraries.permissions.test.InMemoryPermissionsStore
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
@ -52,7 +53,8 @@ class DefaultPermissionsPresenterTest {
val presenter = DefaultPermissionsPresenter(
A_PERMISSION,
permissionsStore,
permissionStateProvider
permissionStateProvider,
FakePermissionActions(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@ -69,7 +71,10 @@ class DefaultPermissionsPresenterTest {
@Test
fun `present - user closes dialog`() = runTest {
val permissionsStore = InMemoryPermissionsStore()
val permissionsStore = InMemoryPermissionsStore(
permissionDenied = true,
permissionAsked = true
)
val permissionState = FakePermissionState(
A_PERMISSION,
PermissionStatus.Denied(shouldShowRationale = false)
@ -81,18 +86,58 @@ class DefaultPermissionsPresenterTest {
val presenter = DefaultPermissionsPresenter(
A_PERMISSION,
permissionsStore,
permissionStateProvider
permissionStateProvider,
FakePermissionActions(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.showDialog).isTrue()
initialState.eventSink.invoke(PermissionsEvents.CloseDialog)
initialState.eventSink.invoke(PermissionsEvents.RequestPermissions)
val withDialogState = awaitItem()
assertThat(withDialogState.showDialog).isTrue()
withDialogState.eventSink.invoke(PermissionsEvents.CloseDialog)
assertThat(awaitItem().showDialog).isFalse()
}
}
@Test
fun `present - user open settings`() = runTest {
val permissionsStore = InMemoryPermissionsStore(
permissionDenied = true,
permissionAsked = true
)
val permissionState = FakePermissionState(
A_PERMISSION,
PermissionStatus.Denied(shouldShowRationale = false)
)
val permissionStateProvider =
FakeComposablePermissionStateProvider(
permissionState
)
val permissionActions = FakePermissionActions()
val presenter = DefaultPermissionsPresenter(
A_PERMISSION,
permissionsStore,
permissionStateProvider,
permissionActions,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink.invoke(PermissionsEvents.RequestPermissions)
val withDialogState = awaitItem()
assertThat(withDialogState.showDialog).isTrue()
assertThat(permissionActions.openSettingsCalled).isFalse()
withDialogState.eventSink.invoke(PermissionsEvents.OpenSystemSettingAndCloseDialog)
assertThat(awaitItem().showDialog).isFalse()
assertThat(permissionActions.openSettingsCalled).isTrue()
}
}
@Test
fun `present - user does not grant permission`() = runTest {
val permissionsStore = InMemoryPermissionsStore()
@ -107,16 +152,16 @@ class DefaultPermissionsPresenterTest {
val presenter = DefaultPermissionsPresenter(
A_PERMISSION,
permissionsStore,
permissionStateProvider
permissionStateProvider,
FakePermissionActions(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.showDialog).isTrue()
initialState.eventSink.invoke(PermissionsEvents.OpenSystemDialog)
assertThat(initialState.showDialog).isFalse()
initialState.eventSink.invoke(PermissionsEvents.RequestPermissions)
assertThat(permissionState.launchPermissionRequestCalled).isTrue()
assertThat(awaitItem().showDialog).isFalse()
// User does not grant permission
permissionStateProvider.userGiveAnswer(answer = false, firstTime = true)
skipItems(1)
@ -142,16 +187,16 @@ class DefaultPermissionsPresenterTest {
val presenter = DefaultPermissionsPresenter(
A_PERMISSION,
permissionsStore,
permissionStateProvider
permissionStateProvider,
FakePermissionActions(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.showDialog).isTrue()
initialState.eventSink.invoke(PermissionsEvents.OpenSystemDialog)
assertThat(initialState.showDialog).isFalse()
initialState.eventSink.invoke(PermissionsEvents.RequestPermissions)
assertThat(permissionState.launchPermissionRequestCalled).isTrue()
assertThat(awaitItem().showDialog).isFalse()
// User does not grant permission
permissionStateProvider.userGiveAnswer(answer = false, firstTime = false)
skipItems(2)
@ -181,17 +226,20 @@ class DefaultPermissionsPresenterTest {
val presenter = DefaultPermissionsPresenter(
A_PERMISSION,
permissionsStore,
permissionStateProvider
permissionStateProvider,
FakePermissionActions(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.showDialog).isTrue()
assertThat(initialState.permissionGranted).isFalse()
assertThat(initialState.permissionAlreadyDenied).isTrue()
assertThat(initialState.permissionAlreadyAsked).isTrue()
initialState.eventSink.invoke(PermissionsEvents.RequestPermissions)
val withDialogState = awaitItem()
assertThat(withDialogState.showDialog).isTrue()
assertThat(withDialogState.permissionGranted).isFalse()
assertThat(withDialogState.permissionAlreadyDenied).isTrue()
assertThat(withDialogState.permissionAlreadyAsked).isTrue()
}
}
@ -209,16 +257,16 @@ class DefaultPermissionsPresenterTest {
val presenter = DefaultPermissionsPresenter(
A_PERMISSION,
permissionsStore,
permissionStateProvider
permissionStateProvider,
FakePermissionActions(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.showDialog).isTrue()
initialState.eventSink.invoke(PermissionsEvents.OpenSystemDialog)
assertThat(initialState.showDialog).isFalse()
initialState.eventSink.invoke(PermissionsEvents.RequestPermissions)
assertThat(permissionState.launchPermissionRequestCalled).isTrue()
assertThat(awaitItem().showDialog).isFalse()
// User grants permission
permissionStateProvider.userGiveAnswer(answer = true, firstTime = true)
skipItems(1)

View 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.
*/
package io.element.android.libraries.permissions.impl.action
class FakePermissionActions : PermissionActions {
var openSettingsCalled = false
private set
override fun openSettings() {
openSettingsCalled = true
}
}

View file

@ -24,13 +24,14 @@ 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),
private val initialState: PermissionsState = aPermissionsState(showDialog = false),
) : PermissionsPresenter {
private fun eventSink(events: PermissionsEvents) {
when (events) {
PermissionsEvents.OpenSystemDialog -> state.value = state.value.copy(showDialog = true, permissionAlreadyAsked = true)
PermissionsEvents.RequestPermissions -> state.value = state.value.copy(showDialog = true, permissionAlreadyAsked = true)
PermissionsEvents.CloseDialog -> state.value = state.value.copy(showDialog = false)
PermissionsEvents.OpenSystemSettingAndCloseDialog -> state.value = state.value.copy(showDialog = false)
}
}

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.permissions.test
import io.element.android.libraries.permissions.api.PermissionsPresenter
class FakePermissionsPresenterFactory(
private val permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(),
) : PermissionsPresenter.Factory {
override fun create(permission: String): PermissionsPresenter {
return permissionPresenter
}
}

View file

@ -44,6 +44,7 @@
<string name="action_no">"No"</string>
<string name="action_not_now">"Not now"</string>
<string name="action_ok">"OK"</string>
<string name="action_open_settings">"Open settings"</string>
<string name="action_open_with">"Open with"</string>
<string name="action_quick_reply">"Quick reply"</string>
<string name="action_quote">"Quote"</string>
@ -105,6 +106,7 @@
<string name="common_password">"Password"</string>
<string name="common_people">"People"</string>
<string name="common_permalink">"Permalink"</string>
<string name="common_permission">"Permission"</string>
<string name="common_poll_total_votes">"Total votes: %1$s"</string>
<string name="common_poll_undisclosed_text">"Results will show after the poll has ended"</string>
<string name="common_privacy_policy">"Privacy policy"</string>