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

1
changelog.d/1395.bugfix Normal file
View file

@ -0,0 +1 @@
Fix crash when trying to take a photo or record a video.

View file

@ -48,6 +48,7 @@ dependencies {
implementation(projects.libraries.deeplink) implementation(projects.libraries.deeplink)
implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.usersearch.impl) implementation(projects.libraries.usersearch.impl)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(libs.coil.compose) implementation(libs.coil.compose)
@ -64,6 +65,7 @@ dependencies {
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.mediapickers.test) testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.usersearch.test) testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)

View file

@ -18,6 +18,7 @@ package io.element.android.features.createroom.impl.configureroom
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
@ -40,6 +41,8 @@ import io.element.android.libraries.matrix.api.createroom.RoomVisibility
import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -52,10 +55,15 @@ class ConfigureRoomPresenter @Inject constructor(
private val mediaPickerProvider: PickerProvider, private val mediaPickerProvider: PickerProvider,
private val mediaPreProcessor: MediaPreProcessor, private val mediaPreProcessor: MediaPreProcessor,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<ConfigureRoomState> { ) : Presenter<ConfigureRoomState> {
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
private var pendingPermissionRequest = false
@Composable @Composable
override fun present(): ConfigureRoomState { override fun present(): ConfigureRoomState {
val cameraPermissionState = cameraPermissionPresenter.present()
val createRoomConfig = dataStore.getCreateRoomConfig().collectAsState(CreateRoomConfig()) val createRoomConfig = dataStore.getCreateRoomConfig().collectAsState(CreateRoomConfig())
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker( val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
@ -75,6 +83,13 @@ class ConfigureRoomPresenter @Inject constructor(
} }
} }
LaunchedEffect(cameraPermissionState.permissionGranted) {
if (cameraPermissionState.permissionGranted && pendingPermissionRequest) {
pendingPermissionRequest = false
cameraPhotoPicker.launch()
}
}
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val createRoomAction: MutableState<Async<RoomId>> = remember { mutableStateOf(Async.Uninitialized) } val createRoomAction: MutableState<Async<RoomId>> = remember { mutableStateOf(Async.Uninitialized) }
@ -93,7 +108,12 @@ class ConfigureRoomPresenter @Inject constructor(
is ConfigureRoomEvents.HandleAvatarAction -> { is ConfigureRoomEvents.HandleAvatarAction -> {
when (event.action) { when (event.action) {
AvatarAction.ChoosePhoto -> galleryImagePicker.launch() AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
AvatarAction.TakePhoto -> cameraPhotoPicker.launch() AvatarAction.TakePhoto -> if (cameraPermissionState.permissionGranted) {
cameraPhotoPicker.launch()
} else {
pendingPermissionRequest = true
cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
}
AvatarAction.Remove -> dataStore.setAvatarUri(uri = null) AvatarAction.Remove -> dataStore.setAvatarUri(uri = null)
} }
} }
@ -106,6 +126,7 @@ class ConfigureRoomPresenter @Inject constructor(
config = createRoomConfig.value, config = createRoomConfig.value,
avatarActions = avatarActions, avatarActions = avatarActions,
createRoomAction = createRoomAction.value, createRoomAction = createRoomAction.value,
cameraPermissionState = cameraPermissionState,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }

View file

@ -20,12 +20,14 @@ import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.features.createroom.impl.CreateRoomConfig import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.permissions.api.PermissionsState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class ConfigureRoomState( data class ConfigureRoomState(
val config: CreateRoomConfig, val config: CreateRoomConfig,
val avatarActions: ImmutableList<AvatarAction>, val avatarActions: ImmutableList<AvatarAction>,
val createRoomAction: Async<RoomId>, val createRoomAction: Async<RoomId>,
val cameraPermissionState: PermissionsState,
val eventSink: (ConfigureRoomEvents) -> Unit val eventSink: (ConfigureRoomEvents) -> Unit
) { ) {
val isCreateButtonEnabled: Boolean = config.roomName.isNullOrEmpty().not() val isCreateButtonEnabled: Boolean = config.roomName.isNullOrEmpty().not()

View file

@ -20,6 +20,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.createroom.impl.CreateRoomConfig import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.userlist.aListOfSelectedUsers import io.element.android.features.createroom.impl.userlist.aListOfSelectedUsers
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.permissions.api.aPermissionsState
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> { open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> {
@ -41,5 +42,6 @@ fun aConfigureRoomState() = ConfigureRoomState(
config = CreateRoomConfig(), config = CreateRoomConfig(),
avatarActions = persistentListOf(), avatarActions = persistentListOf(),
createRoomAction = Async.Uninitialized, createRoomAction = Async.Uninitialized,
cameraPermissionState = aPermissionsState(showDialog = false),
eventSink = { }, eventSink = { },
) )

View file

@ -65,6 +65,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.SelectedUsersList import io.element.android.libraries.matrix.ui.components.SelectedUsersList
import io.element.android.libraries.matrix.ui.components.UnsavedAvatar import io.element.android.libraries.matrix.ui.components.UnsavedAvatar
import io.element.android.libraries.permissions.api.PermissionsView
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -73,9 +74,9 @@ import kotlinx.coroutines.launch
@Composable @Composable
fun ConfigureRoomView( fun ConfigureRoomView(
state: ConfigureRoomState, state: ConfigureRoomState,
onBackPressed: () -> Unit,
onRoomCreated: (RoomId) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
onRoomCreated: (RoomId) -> Unit = {},
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
@ -172,6 +173,10 @@ fun ConfigureRoomView(
else -> Unit else -> Unit
} }
PermissionsView(
state = state.cameraPermissionState,
)
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -278,5 +283,7 @@ private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =
internal fun ConfigureRoomViewPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = ElementPreview { internal fun ConfigureRoomViewPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = ElementPreview {
ConfigureRoomView( ConfigureRoomView(
state = state, state = state,
onBackPressed = {},
onRoomCreated = {},
) )
} }

View file

@ -37,6 +37,8 @@ import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.mockk.every import io.mockk.every
@ -55,6 +57,7 @@ import org.robolectric.RobolectricTestRunner
import java.io.File import java.io.File
private const val AN_URI_FROM_CAMERA = "content://uri_from_camera" private const val AN_URI_FROM_CAMERA = "content://uri_from_camera"
private const val AN_URI_FROM_CAMERA_2 = "content://uri_from_camera_2"
private const val AN_URI_FROM_GALLERY = "content://uri_from_gallery" private const val AN_URI_FROM_GALLERY = "content://uri_from_gallery"
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@ -70,6 +73,7 @@ class ConfigureRoomPresenterTests {
private lateinit var fakePickerProvider: FakePickerProvider private lateinit var fakePickerProvider: FakePickerProvider
private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor
private lateinit var fakeAnalyticsService: FakeAnalyticsService private lateinit var fakeAnalyticsService: FakeAnalyticsService
private lateinit var fakePermissionsPresenter: FakePermissionsPresenter
@Before @Before
fun setup() { fun setup() {
@ -79,12 +83,14 @@ class ConfigureRoomPresenterTests {
fakePickerProvider = FakePickerProvider() fakePickerProvider = FakePickerProvider()
fakeMediaPreProcessor = FakeMediaPreProcessor() fakeMediaPreProcessor = FakeMediaPreProcessor()
fakeAnalyticsService = FakeAnalyticsService() fakeAnalyticsService = FakeAnalyticsService()
fakePermissionsPresenter = FakePermissionsPresenter()
presenter = ConfigureRoomPresenter( presenter = ConfigureRoomPresenter(
dataStore = createRoomDataStore, dataStore = createRoomDataStore,
matrixClient = fakeMatrixClient, matrixClient = fakeMatrixClient,
mediaPickerProvider = fakePickerProvider, mediaPickerProvider = fakePickerProvider,
mediaPreProcessor = fakeMediaPreProcessor, mediaPreProcessor = fakeMediaPreProcessor,
analyticsService = fakeAnalyticsService, analyticsService = fakeAnalyticsService,
permissionsPresenterFactory = FakePermissionsPresenterFactory(fakePermissionsPresenter),
) )
mockkStatic(File::readBytes) mockkStatic(File::readBytes)
@ -170,8 +176,6 @@ class ConfigureRoomPresenterTests {
// Room avatar // Room avatar
// Pick avatar // Pick avatar
fakePickerProvider.givenResult(null) fakePickerProvider.givenResult(null)
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
// From gallery // From gallery
val uriFromGallery = Uri.parse(AN_URI_FROM_GALLERY) val uriFromGallery = Uri.parse(AN_URI_FROM_GALLERY)
fakePickerProvider.givenResult(uriFromGallery) fakePickerProvider.givenResult(uriFromGallery)
@ -182,10 +186,23 @@ class ConfigureRoomPresenterTests {
// From camera // From camera
val uriFromCamera = Uri.parse(AN_URI_FROM_CAMERA) val uriFromCamera = Uri.parse(AN_URI_FROM_CAMERA)
fakePickerProvider.givenResult(uriFromCamera) fakePickerProvider.givenResult(uriFromCamera)
assertThat(newState.cameraPermissionState.permissionGranted).isFalse()
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto)) newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
newState = awaitItem() newState = awaitItem()
assertThat(newState.cameraPermissionState.showDialog).isTrue()
fakePermissionsPresenter.setPermissionGranted()
newState = awaitItem()
assertThat(newState.cameraPermissionState.permissionGranted).isTrue()
newState = awaitItem()
expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera) expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera)
assertThat(newState.config).isEqualTo(expectedConfig) assertThat(newState.config).isEqualTo(expectedConfig)
// Do it again, no permission is requested
val uriFromCamera2 = Uri.parse(AN_URI_FROM_CAMERA_2)
fakePickerProvider.givenResult(uriFromCamera2)
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto))
newState = awaitItem()
expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera2)
assertThat(newState.config).isEqualTo(expectedConfig)
// Remove // Remove
newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.Remove)) newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.Remove))
newState = awaitItem() newState = awaitItem()

View file

@ -65,7 +65,7 @@ class NotificationsOptInPresenter @AssistedInject constructor(
if (notificationsPermissionsState.permissionGranted) { if (notificationsPermissionsState.permissionGranted) {
callback.onNotificationsOptInFinished() callback.onNotificationsOptInFinished()
} else { } else {
notificationsPermissionsState.eventSink(PermissionsEvents.OpenSystemDialog) notificationsPermissionsState.eventSink(PermissionsEvents.RequestPermissions)
} }
} }
NotificationsOptInEvents.NotNowClicked -> { NotificationsOptInEvents.NotNowClicked -> {

View file

@ -28,6 +28,6 @@ open class NotificationsOptInStateProvider : PreviewParameterProvider<Notificati
} }
fun aNotificationsOptInState() = NotificationsOptInState( fun aNotificationsOptInState() = NotificationsOptInState(
notificationsPermissionState = aPermissionsState(), notificationsPermissionState = aPermissionsState(showDialog = false),
eventSink = {} eventSink = {}
) )

View file

@ -25,6 +25,7 @@ import io.element.android.libraries.permissions.api.PermissionStateProvider
import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.impl.FakePermissionStateProvider import io.element.android.libraries.permissions.impl.FakePermissionStateProvider
import io.element.android.libraries.permissions.test.FakePermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -130,11 +131,7 @@ class NotificationsOptInPresenterTests {
permissionStateProvider: PermissionStateProvider = FakePermissionStateProvider(), permissionStateProvider: PermissionStateProvider = FakePermissionStateProvider(),
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU, sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU,
) = NotificationsOptInPresenter( ) = NotificationsOptInPresenter(
permissionsPresenterFactory = object : PermissionsPresenter.Factory { permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
override fun create(permission: String): PermissionsPresenter {
return permissionsPresenter
}
},
callback = object : NotificationsOptInNode.Callback { callback = object : NotificationsOptInNode.Callback {
override fun onNotificationsOptInFinished() { override fun onNotificationsOptInFinished() {
isFinished = true isFinished = true

View file

@ -48,6 +48,7 @@ dependencies {
implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.preferences.api) implementation(projects.libraries.preferences.api)
implementation(projects.features.networkmonitor.api) implementation(projects.features.networkmonitor.api)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
@ -77,6 +78,7 @@ dependencies {
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.mediapickers.test) testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.textcomposer.test) testImplementation(projects.libraries.textcomposer.test)
testImplementation(libs.test.mockk) testImplementation(libs.test.mockk)

View file

@ -16,6 +16,7 @@
package io.element.android.features.messages.impl.messagecomposer package io.element.android.features.messages.impl.messagecomposer
import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -44,6 +45,8 @@ import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.textcomposer.Message import io.element.android.libraries.textcomposer.Message
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
@ -70,13 +73,18 @@ class MessageComposerPresenter @Inject constructor(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val messageComposerContext: MessageComposerContextImpl, private val messageComposerContext: MessageComposerContextImpl,
private val richTextEditorStateFactory: RichTextEditorStateFactory, private val richTextEditorStateFactory: RichTextEditorStateFactory,
permissionsPresenterFactory: PermissionsPresenter.Factory
) : Presenter<MessageComposerState> { ) : Presenter<MessageComposerState> {
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
private var pendingEvent: MessageComposerEvents? = null
@SuppressLint("UnsafeOptInUsageError") @SuppressLint("UnsafeOptInUsageError")
@Composable @Composable
override fun present(): MessageComposerState { override fun present(): MessageComposerState {
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val cameraPermissionState = cameraPermissionPresenter.present()
val attachmentsState = remember { val attachmentsState = remember {
mutableStateOf<AttachmentsState>(AttachmentsState.None) mutableStateOf<AttachmentsState>(AttachmentsState.None)
} }
@ -132,6 +140,17 @@ class MessageComposerPresenter @Inject constructor(
} }
} }
LaunchedEffect(cameraPermissionState.permissionGranted) {
if (cameraPermissionState.permissionGranted) {
when (pendingEvent) {
is MessageComposerEvents.PickAttachmentSource.PhotoFromCamera -> cameraPhotoPicker.launch()
is MessageComposerEvents.PickAttachmentSource.VideoFromCamera -> cameraVideoPicker.launch()
else -> Unit
}
pendingEvent = null
}
}
fun handleEvents(event: MessageComposerEvents) { fun handleEvents(event: MessageComposerEvents) {
when (event) { when (event) {
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
@ -163,11 +182,21 @@ class MessageComposerPresenter @Inject constructor(
} }
MessageComposerEvents.PickAttachmentSource.PhotoFromCamera -> localCoroutineScope.launch { MessageComposerEvents.PickAttachmentSource.PhotoFromCamera -> localCoroutineScope.launch {
showAttachmentSourcePicker = false showAttachmentSourcePicker = false
cameraPhotoPicker.launch() if (cameraPermissionState.permissionGranted) {
cameraPhotoPicker.launch()
} else {
pendingEvent = event
cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
}
} }
MessageComposerEvents.PickAttachmentSource.VideoFromCamera -> localCoroutineScope.launch { MessageComposerEvents.PickAttachmentSource.VideoFromCamera -> localCoroutineScope.launch {
showAttachmentSourcePicker = false showAttachmentSourcePicker = false
cameraVideoPicker.launch() if (cameraPermissionState.permissionGranted) {
cameraVideoPicker.launch()
} else {
pendingEvent = event
cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
}
} }
MessageComposerEvents.PickAttachmentSource.Location -> { MessageComposerEvents.PickAttachmentSource.Location -> {
showAttachmentSourcePicker = false showAttachmentSourcePicker = false
@ -301,16 +330,16 @@ class MessageComposerPresenter @Inject constructor(
} }
mediaSender.sendMedia(uri, mimeType, compressIfPossible = false, progressCallback).getOrThrow() mediaSender.sendMedia(uri, mimeType, compressIfPossible = false, progressCallback).getOrThrow()
} }
.onSuccess { .onSuccess {
attachmentState.value = AttachmentsState.None attachmentState.value = AttachmentsState.None
} }
.onFailure { cause -> .onFailure { cause ->
attachmentState.value = AttachmentsState.None attachmentState.value = AttachmentsState.None
if (cause is CancellationException) { if (cause is CancellationException) {
throw cause throw cause
} else { } else {
val snackbarMessage = SnackbarMessage(sendAttachmentError(cause)) val snackbarMessage = SnackbarMessage(sendAttachmentError(cause))
snackbarDispatcher.post(snackbarMessage) snackbarDispatcher.post(snackbarMessage)
}
} }
}
} }

View file

@ -70,6 +70,9 @@ import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
@ -601,6 +604,7 @@ class MessagesPresenterTest {
navigator: FakeMessagesNavigator = FakeMessagesNavigator(), navigator: FakeMessagesNavigator = FakeMessagesNavigator(),
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(), clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(), analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
): MessagesPresenter { ): MessagesPresenter {
val messageComposerPresenter = MessageComposerPresenter( val messageComposerPresenter = MessageComposerPresenter(
appCoroutineScope = this, appCoroutineScope = this,
@ -613,8 +617,8 @@ class MessagesPresenterTest {
analyticsService = analyticsService, analyticsService = analyticsService,
messageComposerContext = MessageComposerContextImpl(), messageComposerContext = MessageComposerContextImpl(),
richTextEditorStateFactory = TestRichTextEditorStateFactory(), richTextEditorStateFactory = TestRichTextEditorStateFactory(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
) )
val timelinePresenter = TimelinePresenter( val timelinePresenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(), timelineItemsFactory = aTimelineItemsFactory(),
room = matrixRoom, room = matrixRoom,

View file

@ -54,6 +54,9 @@ import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.textcomposer.Message import io.element.android.libraries.textcomposer.Message
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
@ -67,6 +70,7 @@ import org.junit.Rule
import org.junit.Test import org.junit.Test
import java.io.File import java.io.File
@Suppress("LargeClass")
class MessageComposerPresenterTest { class MessageComposerPresenterTest {
@get:Rule @get:Rule
@ -487,18 +491,84 @@ class MessageComposerPresenterTest {
} }
@Test @Test
fun `present - Take photo`() = runTest { fun `present - create poll`() = runTest {
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = createPresenter(this, room = room) val presenter = createPresenter(this, room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.AddAttachment)
val attachmentOpenState = awaitItem()
assertThat(attachmentOpenState.showAttachmentSourcePicker).isTrue()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.Poll)
val finalState = awaitItem()
assertThat(finalState.showAttachmentSourcePicker).isFalse()
}
}
@Test
fun `present - share location`() = runTest {
val room = FakeMatrixRoom()
val presenter = createPresenter(this, room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.AddAttachment)
val attachmentOpenState = awaitItem()
assertThat(attachmentOpenState.showAttachmentSourcePicker).isTrue()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.Location)
val finalState = awaitItem()
assertThat(finalState.showAttachmentSourcePicker).isFalse()
}
}
@Test
fun `present - Take photo`() = runTest {
val room = FakeMatrixRoom()
val permissionPresenter = FakePermissionsPresenter().apply { setPermissionGranted() }
val presenter = createPresenter(
this,
room = room,
permissionPresenter = permissionPresenter,
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) initialState.eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera)
val previewingState = awaitItem() val finalState = awaitItem()
assertThat(previewingState.showAttachmentSourcePicker).isFalse() assertThat(finalState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
assertThat(previewingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java) cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - Take photo with permission request`() = runTest {
val room = FakeMatrixRoom()
val permissionPresenter = FakePermissionsPresenter()
val presenter = createPresenter(
this,
room = room,
permissionPresenter = permissionPresenter,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera)
val permissionState = awaitItem()
assertThat(permissionState.showAttachmentSourcePicker).isFalse()
assertThat(permissionState.attachmentsState).isInstanceOf(AttachmentsState.None::class.java)
permissionPresenter.setPermissionGranted()
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@ -506,16 +576,47 @@ class MessageComposerPresenterTest {
@Test @Test
fun `present - Record video`() = runTest { fun `present - Record video`() = runTest {
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = createPresenter(this, room = room) val permissionPresenter = FakePermissionsPresenter().apply { setPermissionGranted() }
val presenter = createPresenter(
this,
room = room,
permissionPresenter = permissionPresenter,
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera) initialState.eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera)
val previewingState = awaitItem() val finalState = awaitItem()
assertThat(previewingState.showAttachmentSourcePicker).isFalse() assertThat(finalState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
assertThat(previewingState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java) cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - Record video with permission request`() = runTest {
val room = FakeMatrixRoom()
val permissionPresenter = FakePermissionsPresenter()
val presenter = createPresenter(
this,
room = room,
permissionPresenter = permissionPresenter,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.VideoFromCamera)
val permissionState = awaitItem()
assertThat(permissionState.showAttachmentSourcePicker).isFalse()
assertThat(permissionState.attachmentsState).isInstanceOf(AttachmentsState.None::class.java)
permissionPresenter.setPermissionGranted()
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.attachmentsState).isInstanceOf(AttachmentsState.Previewing::class.java)
cancelAndIgnoreRemainingEvents()
} }
} }
@ -612,6 +713,7 @@ class MessageComposerPresenterTest {
featureFlagService: FeatureFlagService = this.featureFlagService, featureFlagService: FeatureFlagService = this.featureFlagService,
mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor, mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor,
snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher, snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher,
permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(),
) = MessageComposerPresenter( ) = MessageComposerPresenter(
coroutineScope, coroutineScope,
room, room,
@ -623,6 +725,7 @@ class MessageComposerPresenterTest {
analyticsService, analyticsService,
MessageComposerContextImpl(), MessageComposerContextImpl(),
TestRichTextEditorStateFactory(), TestRichTextEditorStateFactory(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
) )
} }

View file

@ -47,6 +47,7 @@ dependencies {
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.permissions.api)
implementation(projects.features.rageshake.api) implementation(projects.features.rageshake.api)
implementation(projects.features.analytics.api) implementation(projects.features.analytics.api)
implementation(projects.features.ftue.api) implementation(projects.features.ftue.api)
@ -71,6 +72,7 @@ dependencies {
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.mediapickers.test) testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.pushstore.test) testImplementation(projects.libraries.pushstore.test)
testImplementation(projects.features.rageshake.test) testImplementation(projects.features.rageshake.test)

View file

@ -18,6 +18,7 @@ package io.element.android.features.preferences.impl.user.editprofile
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -39,6 +40,8 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -49,8 +52,12 @@ class EditUserProfilePresenter @AssistedInject constructor(
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val mediaPickerProvider: PickerProvider, private val mediaPickerProvider: PickerProvider,
private val mediaPreProcessor: MediaPreProcessor, private val mediaPreProcessor: MediaPreProcessor,
permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<EditUserProfileState> { ) : Presenter<EditUserProfileState> {
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
private var pendingPermissionRequest = false
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(matrixUser: MatrixUser): EditUserProfilePresenter fun create(matrixUser: MatrixUser): EditUserProfilePresenter
@ -58,6 +65,7 @@ class EditUserProfilePresenter @AssistedInject constructor(
@Composable @Composable
override fun present(): EditUserProfileState { override fun present(): EditUserProfileState {
val cameraPermissionState = cameraPermissionPresenter.present()
var userAvatarUri by rememberSaveable { mutableStateOf(matrixUser.avatarUrl?.let { Uri.parse(it) }) } var userAvatarUri by rememberSaveable { mutableStateOf(matrixUser.avatarUrl?.let { Uri.parse(it) }) }
var userDisplayName by rememberSaveable { mutableStateOf(matrixUser.displayName) } var userDisplayName by rememberSaveable { mutableStateOf(matrixUser.displayName) }
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker( val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
@ -77,6 +85,13 @@ class EditUserProfilePresenter @AssistedInject constructor(
} }
} }
LaunchedEffect(cameraPermissionState.permissionGranted) {
if (cameraPermissionState.permissionGranted && pendingPermissionRequest) {
pendingPermissionRequest = false
cameraPhotoPicker.launch()
}
}
val saveAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) } val saveAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) }
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
fun handleEvents(event: EditUserProfileEvents) { fun handleEvents(event: EditUserProfileEvents) {
@ -85,7 +100,12 @@ class EditUserProfilePresenter @AssistedInject constructor(
is EditUserProfileEvents.HandleAvatarAction -> { is EditUserProfileEvents.HandleAvatarAction -> {
when (event.action) { when (event.action) {
AvatarAction.ChoosePhoto -> galleryImagePicker.launch() AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
AvatarAction.TakePhoto -> cameraPhotoPicker.launch() AvatarAction.TakePhoto -> if (cameraPermissionState.permissionGranted) {
cameraPhotoPicker.launch()
} else {
pendingPermissionRequest = true
cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
}
AvatarAction.Remove -> userAvatarUri = null AvatarAction.Remove -> userAvatarUri = null
} }
} }
@ -108,6 +128,7 @@ class EditUserProfilePresenter @AssistedInject constructor(
avatarActions = avatarActions, avatarActions = avatarActions,
saveButtonEnabled = canSave && saveAction.value !is Async.Loading, saveButtonEnabled = canSave && saveAction.value !is Async.Loading,
saveAction = saveAction.value, saveAction = saveAction.value,
cameraPermissionState = cameraPermissionState,
eventSink = { handleEvents(it) }, eventSink = { handleEvents(it) },
) )
} }

View file

@ -20,6 +20,7 @@ import android.net.Uri
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.permissions.api.PermissionsState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class EditUserProfileState( data class EditUserProfileState(
@ -29,5 +30,6 @@ data class EditUserProfileState(
val avatarActions: ImmutableList<AvatarAction>, val avatarActions: ImmutableList<AvatarAction>,
val saveButtonEnabled: Boolean, val saveButtonEnabled: Boolean,
val saveAction: Async<Unit>, val saveAction: Async<Unit>,
val cameraPermissionState: PermissionsState,
val eventSink: (EditUserProfileEvents) -> Unit val eventSink: (EditUserProfileEvents) -> Unit
) )

View file

@ -19,6 +19,7 @@ package io.element.android.features.preferences.impl.user.editprofile
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.permissions.api.aPermissionsState
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
open class EditUserProfileStateProvider : PreviewParameterProvider<EditUserProfileState> { open class EditUserProfileStateProvider : PreviewParameterProvider<EditUserProfileState> {
@ -36,5 +37,6 @@ fun aEditUserProfileState() = EditUserProfileState(
avatarActions = persistentListOf(), avatarActions = persistentListOf(),
saveAction = Async.Uninitialized, saveAction = Async.Uninitialized,
saveButtonEnabled = true, saveButtonEnabled = true,
cameraPermissionState = aPermissionsState(showDialog = false),
eventSink = {} eventSink = {}
) )

View file

@ -58,6 +58,7 @@ import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.EditableAvatarView import io.element.android.libraries.matrix.ui.components.EditableAvatarView
import io.element.android.libraries.permissions.api.PermissionsView
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -168,6 +169,9 @@ fun EditUserProfileView(
else -> Unit else -> Unit
} }
} }
PermissionsView(
state = state.cameraPermissionState,
)
} }
private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier = private fun Modifier.clearFocusOnTap(focusManager: FocusManager): Modifier =

View file

@ -32,6 +32,9 @@ import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.mockk.every import io.mockk.every
@ -78,12 +81,14 @@ class EditUserProfilePresenterTest {
private fun createEditUserProfilePresenter( private fun createEditUserProfilePresenter(
matrixClient: MatrixClient = FakeMatrixClient(), matrixClient: MatrixClient = FakeMatrixClient(),
matrixUser: MatrixUser = aMatrixUser(), matrixUser: MatrixUser = aMatrixUser(),
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
): EditUserProfilePresenter { ): EditUserProfilePresenter {
return EditUserProfilePresenter( return EditUserProfilePresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
matrixUser = matrixUser, matrixUser = matrixUser,
mediaPickerProvider = fakePickerProvider, mediaPickerProvider = fakePickerProvider,
mediaPreProcessor = fakeMediaPreProcessor, mediaPreProcessor = fakeMediaPreProcessor,
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
) )
} }
@ -157,16 +162,30 @@ class EditUserProfilePresenterTest {
fun `present - obtains avatar uris from camera`() = runTest { fun `present - obtains avatar uris from camera`() = runTest {
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
fakePickerProvider.givenResult(anotherAvatarUri) fakePickerProvider.givenResult(anotherAvatarUri)
val presenter = createEditUserProfilePresenter(matrixUser = user) val fakePermissionsPresenter = FakePermissionsPresenter()
val presenter = createEditUserProfilePresenter(
matrixUser = user,
permissionsPresenter = fakePermissionsPresenter,
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.userAvatarUrl).isEqualTo(userAvatarUri) assertThat(initialState.userAvatarUrl).isEqualTo(userAvatarUri)
assertThat(initialState.cameraPermissionState.permissionGranted).isFalse()
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.TakePhoto)) initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.TakePhoto))
awaitItem().apply { val stateWithAskingPermission = awaitItem()
assertThat(userAvatarUrl).isEqualTo(anotherAvatarUri) assertThat(stateWithAskingPermission.cameraPermissionState.showDialog).isTrue()
} fakePermissionsPresenter.setPermissionGranted()
val stateWithPermission = awaitItem()
assertThat(stateWithPermission.cameraPermissionState.permissionGranted).isTrue()
val stateWithNewAvatar = awaitItem()
assertThat(stateWithNewAvatar.userAvatarUrl).isEqualTo(anotherAvatarUri)
// Do it again, no permission is requested
fakePickerProvider.givenResult(userAvatarUri)
stateWithNewAvatar.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.TakePhoto))
val stateWithNewAvatar2 = awaitItem()
assertThat(stateWithNewAvatar2.userAvatarUrl).isEqualTo(userAvatarUri)
} }
} }

View file

@ -43,6 +43,7 @@ dependencies {
implementation(projects.libraries.mediapickers.api) implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.permissions.api)
api(projects.features.roomdetails.api) api(projects.features.roomdetails.api)
api(projects.libraries.usersearch.api) api(projects.libraries.usersearch.api)
api(projects.services.apperror.api) api(projects.services.apperror.api)
@ -59,6 +60,7 @@ dependencies {
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.mediaupload.test) testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.mediapickers.test) testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.usersearch.test) testImplementation(projects.libraries.usersearch.test)
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)

View file

@ -39,6 +39,8 @@ import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -49,10 +51,15 @@ class RoomDetailsEditPresenter @Inject constructor(
private val room: MatrixRoom, private val room: MatrixRoom,
private val mediaPickerProvider: PickerProvider, private val mediaPickerProvider: PickerProvider,
private val mediaPreProcessor: MediaPreProcessor, private val mediaPreProcessor: MediaPreProcessor,
permissionsPresenterFactory: PermissionsPresenter.Factory,
) : Presenter<RoomDetailsEditState> { ) : Presenter<RoomDetailsEditState> {
private val cameraPermissionPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
private var pendingPermissionRequest = false
@Composable @Composable
override fun present(): RoomDetailsEditState { override fun present(): RoomDetailsEditState {
val cameraPermissionState = cameraPermissionPresenter.present()
val roomSyncUpdateFlow = room.syncUpdateFlow.collectAsState() val roomSyncUpdateFlow = room.syncUpdateFlow.collectAsState()
// Since there is no way to obtain the new avatar uri after uploading a new avatar, // Since there is no way to obtain the new avatar uri after uploading a new avatar,
@ -92,6 +99,13 @@ class RoomDetailsEditPresenter @Inject constructor(
onResult = { uri -> if (uri != null) roomAvatarUri = uri } onResult = { uri -> if (uri != null) roomAvatarUri = uri }
) )
LaunchedEffect(cameraPermissionState.permissionGranted) {
if (cameraPermissionState.permissionGranted && pendingPermissionRequest) {
pendingPermissionRequest = false
cameraPhotoPicker.launch()
}
}
val avatarActions by remember(roomAvatarUri) { val avatarActions by remember(roomAvatarUri) {
derivedStateOf { derivedStateOf {
listOfNotNull( listOfNotNull(
@ -110,7 +124,12 @@ class RoomDetailsEditPresenter @Inject constructor(
is RoomDetailsEditEvents.HandleAvatarAction -> { is RoomDetailsEditEvents.HandleAvatarAction -> {
when (event.action) { when (event.action) {
AvatarAction.ChoosePhoto -> galleryImagePicker.launch() AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
AvatarAction.TakePhoto -> cameraPhotoPicker.launch() AvatarAction.TakePhoto -> if (cameraPermissionState.permissionGranted) {
cameraPhotoPicker.launch()
} else {
pendingPermissionRequest = true
cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
}
AvatarAction.Remove -> roomAvatarUri = null AvatarAction.Remove -> roomAvatarUri = null
} }
} }
@ -132,6 +151,7 @@ class RoomDetailsEditPresenter @Inject constructor(
avatarActions = avatarActions, avatarActions = avatarActions,
saveButtonEnabled = saveButtonEnabled, saveButtonEnabled = saveButtonEnabled,
saveAction = saveAction.value, saveAction = saveAction.value,
cameraPermissionState = cameraPermissionState,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }

View file

@ -17,8 +17,9 @@
package io.element.android.features.roomdetails.impl.edit package io.element.android.features.roomdetails.impl.edit
import android.net.Uri import android.net.Uri
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.permissions.api.PermissionsState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class RoomDetailsEditState( data class RoomDetailsEditState(
@ -32,5 +33,6 @@ data class RoomDetailsEditState(
val avatarActions: ImmutableList<AvatarAction>, val avatarActions: ImmutableList<AvatarAction>,
val saveButtonEnabled: Boolean, val saveButtonEnabled: Boolean,
val saveAction: Async<Unit>, val saveAction: Async<Unit>,
val cameraPermissionState: PermissionsState,
val eventSink: (RoomDetailsEditEvents) -> Unit val eventSink: (RoomDetailsEditEvents) -> Unit
) )

View file

@ -19,6 +19,7 @@ package io.element.android.features.roomdetails.impl.edit
import android.net.Uri import android.net.Uri
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.permissions.api.aPermissionsState
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
open class RoomDetailsEditStateProvider : PreviewParameterProvider<RoomDetailsEditState> { open class RoomDetailsEditStateProvider : PreviewParameterProvider<RoomDetailsEditState> {
@ -45,5 +46,6 @@ fun aRoomDetailsEditState() = RoomDetailsEditState(
avatarActions = persistentListOf(), avatarActions = persistentListOf(),
saveButtonEnabled = true, saveButtonEnabled = true,
saveAction = Async.Uninitialized, saveAction = Async.Uninitialized,
cameraPermissionState = aPermissionsState(showDialog = false),
eventSink = {} eventSink = {}
) )

View file

@ -62,6 +62,7 @@ import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.EditableAvatarView import io.element.android.libraries.matrix.ui.components.EditableAvatarView
import io.element.android.libraries.permissions.api.PermissionsView
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -193,6 +194,10 @@ fun RoomDetailsEditView(
else -> Unit else -> Unit
} }
PermissionsView(
state = state.cameraPermissionState,
)
} }
@Composable @Composable

View file

@ -32,6 +32,9 @@ import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
@ -74,11 +77,15 @@ class RoomDetailsEditPresenterTest {
unmockkAll() unmockkAll()
} }
private fun aRoomDetailsEditPresenter(room: MatrixRoom): RoomDetailsEditPresenter { private fun aRoomDetailsEditPresenter(
room: MatrixRoom,
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
): RoomDetailsEditPresenter {
return RoomDetailsEditPresenter( return RoomDetailsEditPresenter(
room = room, room = room,
mediaPickerProvider = fakePickerProvider, mediaPickerProvider = fakePickerProvider,
mediaPreProcessor = fakeMediaPreProcessor, mediaPreProcessor = fakeMediaPreProcessor,
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
) )
} }
@ -252,19 +259,31 @@ class RoomDetailsEditPresenterTest {
val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL) val room = aMatrixRoom(topic = "My topic", name = "Name", avatarUrl = AN_AVATAR_URL)
fakePickerProvider.givenResult(anotherAvatarUri) fakePickerProvider.givenResult(anotherAvatarUri)
val fakePermissionsPresenter = FakePermissionsPresenter()
val presenter = aRoomDetailsEditPresenter(room) val presenter = aRoomDetailsEditPresenter(
room = room,
permissionsPresenter = fakePermissionsPresenter,
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri) assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri)
assertThat(initialState.cameraPermissionState.permissionGranted).isFalse()
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto)) initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto))
awaitItem().apply { val stateWithAskingPermission = awaitItem()
assertThat(roomAvatarUrl).isEqualTo(anotherAvatarUri) assertThat(stateWithAskingPermission.cameraPermissionState.showDialog).isTrue()
} fakePermissionsPresenter.setPermissionGranted()
val stateWithPermission = awaitItem()
assertThat(stateWithPermission.cameraPermissionState.permissionGranted).isTrue()
val stateWithNewAvatar = awaitItem()
assertThat(stateWithNewAvatar.roomAvatarUrl).isEqualTo(anotherAvatarUri)
// Do it again, no permission is requested
fakePickerProvider.givenResult(roomAvatarUri)
stateWithNewAvatar.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto))
val stateWithNewAvatar2 = awaitItem()
assertThat(stateWithNewAvatar2.roomAvatarUrl).isEqualTo(roomAvatarUri)
} }
} }

View file

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

View file

@ -16,67 +16,42 @@
package io.element.android.libraries.permissions.api package io.element.android.libraries.permissions.api
import android.Manifest
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun PermissionsView( fun PermissionsView(
state: PermissionsState, state: PermissionsState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
openSystemSettings: () -> Unit = {},
) { ) {
if (state.showDialog.not()) return if (state.showDialog.not()) return
when { ConfirmationDialog(
state.permissionGranted -> { modifier = modifier,
// Notification Granted, nothing to do title = stringResource(id = CommonStrings.common_permission),
} content = state.permission.toDialogContent(),
state.permissionAlreadyDenied -> { submitText = stringResource(id = CommonStrings.action_open_settings),
// In this case, tell the user to go to the settings onSubmitClicked = {
ConfirmationDialog( state.eventSink.invoke(PermissionsEvents.OpenSystemSettingAndCloseDialog)
modifier = modifier, },
title = "System", onDismiss = { state.eventSink.invoke(PermissionsEvents.CloseDialog) },
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) @Composable
openSystemSettings() private fun String.toDialogContent(): String {
}, return when (this) {
onDismiss = { state.eventSink.invoke(PermissionsEvents.CloseDialog) }, 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 -> { else -> stringResource(id = R.string.dialog_permission_generic)
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 = {}
)
}
} }
} }

View file

@ -22,17 +22,21 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class PermissionsViewStateProvider : PreviewParameterProvider<PermissionsState> { open class PermissionsViewStateProvider : PreviewParameterProvider<PermissionsState> {
override val values: Sequence<PermissionsState> override val values: Sequence<PermissionsState>
get() = sequenceOf( get() = sequenceOf(
aPermissionsState(), aPermissionsState(showDialog = true, permission = Manifest.permission.POST_NOTIFICATIONS),
aPermissionsState().copy(shouldShowRationale = true), aPermissionsState(showDialog = true, permission = Manifest.permission.CAMERA),
aPermissionsState().copy(permissionAlreadyDenied = true), aPermissionsState(showDialog = true, permission = Manifest.permission.RECORD_AUDIO),
aPermissionsState(showDialog = true, permission = Manifest.permission.INTERNET),
) )
} }
fun aPermissionsState() = PermissionsState( fun aPermissionsState(
permission = Manifest.permission.INTERNET, showDialog: Boolean,
permission: String = Manifest.permission.POST_NOTIFICATIONS
) = PermissionsState(
permission = permission,
permissionGranted = false, permissionGranted = false,
shouldShowRationale = false, shouldShowRationale = false,
showDialog = true, showDialog = showDialog,
permissionAlreadyAsked = false, permissionAlreadyAsked = false,
permissionAlreadyDenied = false, permissionAlreadyDenied = false,
eventSink = {} 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.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 io.element.android.libraries.permissions.api.PermissionsStore
import io.element.android.libraries.permissions.impl.action.PermissionActions
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@ -48,6 +49,7 @@ class DefaultPermissionsPresenter @AssistedInject constructor(
@Assisted val permission: String, @Assisted val permission: String,
private val permissionsStore: PermissionsStore, private val permissionsStore: PermissionsStore,
private val composablePermissionStateProvider: ComposablePermissionStateProvider, private val composablePermissionStateProvider: ComposablePermissionStateProvider,
private val permissionActions: PermissionActions,
) : PermissionsPresenter { ) : PermissionsPresenter {
@AssistedFactory @AssistedFactory
@ -98,20 +100,27 @@ class DefaultPermissionsPresenter @AssistedInject constructor(
LaunchedEffect(this) { LaunchedEffect(this) {
if (permissionState.status.isGranted) { 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) permissionsStore.resetPermission(permission)
} }
} }
val showDialog = rememberSaveable { mutableStateOf(permissionState.status !is PermissionStatus.Granted) } val showDialog = rememberSaveable { mutableStateOf(false) }
fun handleEvents(event: PermissionsEvents) { fun handleEvents(event: PermissionsEvents) {
when (event) { when (event) {
PermissionsEvents.CloseDialog -> { PermissionsEvents.CloseDialog -> {
showDialog.value = false showDialog.value = false
} }
PermissionsEvents.OpenSystemDialog -> { PermissionsEvents.RequestPermissions -> {
permissionState.launchPermissionRequest() if (permissionState.status !is PermissionStatus.Granted && isAlreadyDenied) {
showDialog.value = true
} else {
permissionState.launchPermissionRequest()
}
}
PermissionsEvents.OpenSystemSettingAndCloseDialog -> {
permissionActions.openSettings()
showDialog.value = false 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.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.impl.action.FakePermissionActions
import io.element.android.libraries.permissions.test.InMemoryPermissionsStore import io.element.android.libraries.permissions.test.InMemoryPermissionsStore
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -52,7 +53,8 @@ class DefaultPermissionsPresenterTest {
val presenter = DefaultPermissionsPresenter( val presenter = DefaultPermissionsPresenter(
A_PERMISSION, A_PERMISSION,
permissionsStore, permissionsStore,
permissionStateProvider permissionStateProvider,
FakePermissionActions(),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -69,7 +71,10 @@ class DefaultPermissionsPresenterTest {
@Test @Test
fun `present - user closes dialog`() = runTest { fun `present - user closes dialog`() = runTest {
val permissionsStore = InMemoryPermissionsStore() val permissionsStore = InMemoryPermissionsStore(
permissionDenied = true,
permissionAsked = true
)
val permissionState = FakePermissionState( val permissionState = FakePermissionState(
A_PERMISSION, A_PERMISSION,
PermissionStatus.Denied(shouldShowRationale = false) PermissionStatus.Denied(shouldShowRationale = false)
@ -81,18 +86,58 @@ class DefaultPermissionsPresenterTest {
val presenter = DefaultPermissionsPresenter( val presenter = DefaultPermissionsPresenter(
A_PERMISSION, A_PERMISSION,
permissionsStore, permissionsStore,
permissionStateProvider permissionStateProvider,
FakePermissionActions(),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.showDialog).isTrue() initialState.eventSink.invoke(PermissionsEvents.RequestPermissions)
initialState.eventSink.invoke(PermissionsEvents.CloseDialog) val withDialogState = awaitItem()
assertThat(withDialogState.showDialog).isTrue()
withDialogState.eventSink.invoke(PermissionsEvents.CloseDialog)
assertThat(awaitItem().showDialog).isFalse() 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 @Test
fun `present - user does not grant permission`() = runTest { fun `present - user does not grant permission`() = runTest {
val permissionsStore = InMemoryPermissionsStore() val permissionsStore = InMemoryPermissionsStore()
@ -107,16 +152,16 @@ class DefaultPermissionsPresenterTest {
val presenter = DefaultPermissionsPresenter( val presenter = DefaultPermissionsPresenter(
A_PERMISSION, A_PERMISSION,
permissionsStore, permissionsStore,
permissionStateProvider permissionStateProvider,
FakePermissionActions(),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.showDialog).isTrue() assertThat(initialState.showDialog).isFalse()
initialState.eventSink.invoke(PermissionsEvents.OpenSystemDialog) initialState.eventSink.invoke(PermissionsEvents.RequestPermissions)
assertThat(permissionState.launchPermissionRequestCalled).isTrue() assertThat(permissionState.launchPermissionRequestCalled).isTrue()
assertThat(awaitItem().showDialog).isFalse()
// User does not grant permission // User does not grant permission
permissionStateProvider.userGiveAnswer(answer = false, firstTime = true) permissionStateProvider.userGiveAnswer(answer = false, firstTime = true)
skipItems(1) skipItems(1)
@ -142,16 +187,16 @@ class DefaultPermissionsPresenterTest {
val presenter = DefaultPermissionsPresenter( val presenter = DefaultPermissionsPresenter(
A_PERMISSION, A_PERMISSION,
permissionsStore, permissionsStore,
permissionStateProvider permissionStateProvider,
FakePermissionActions(),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.showDialog).isTrue() assertThat(initialState.showDialog).isFalse()
initialState.eventSink.invoke(PermissionsEvents.OpenSystemDialog) initialState.eventSink.invoke(PermissionsEvents.RequestPermissions)
assertThat(permissionState.launchPermissionRequestCalled).isTrue() assertThat(permissionState.launchPermissionRequestCalled).isTrue()
assertThat(awaitItem().showDialog).isFalse()
// User does not grant permission // User does not grant permission
permissionStateProvider.userGiveAnswer(answer = false, firstTime = false) permissionStateProvider.userGiveAnswer(answer = false, firstTime = false)
skipItems(2) skipItems(2)
@ -181,17 +226,20 @@ class DefaultPermissionsPresenterTest {
val presenter = DefaultPermissionsPresenter( val presenter = DefaultPermissionsPresenter(
A_PERMISSION, A_PERMISSION,
permissionsStore, permissionsStore,
permissionStateProvider permissionStateProvider,
FakePermissionActions(),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.showDialog).isTrue() initialState.eventSink.invoke(PermissionsEvents.RequestPermissions)
assertThat(initialState.permissionGranted).isFalse() val withDialogState = awaitItem()
assertThat(initialState.permissionAlreadyDenied).isTrue() assertThat(withDialogState.showDialog).isTrue()
assertThat(initialState.permissionAlreadyAsked).isTrue() assertThat(withDialogState.permissionGranted).isFalse()
assertThat(withDialogState.permissionAlreadyDenied).isTrue()
assertThat(withDialogState.permissionAlreadyAsked).isTrue()
} }
} }
@ -209,16 +257,16 @@ class DefaultPermissionsPresenterTest {
val presenter = DefaultPermissionsPresenter( val presenter = DefaultPermissionsPresenter(
A_PERMISSION, A_PERMISSION,
permissionsStore, permissionsStore,
permissionStateProvider permissionStateProvider,
FakePermissionActions(),
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.showDialog).isTrue() assertThat(initialState.showDialog).isFalse()
initialState.eventSink.invoke(PermissionsEvents.OpenSystemDialog) initialState.eventSink.invoke(PermissionsEvents.RequestPermissions)
assertThat(permissionState.launchPermissionRequestCalled).isTrue() assertThat(permissionState.launchPermissionRequestCalled).isTrue()
assertThat(awaitItem().showDialog).isFalse()
// User grants permission // User grants permission
permissionStateProvider.userGiveAnswer(answer = true, firstTime = true) permissionStateProvider.userGiveAnswer(answer = true, firstTime = true)
skipItems(1) 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 import io.element.android.libraries.permissions.api.aPermissionsState
class FakePermissionsPresenter( class FakePermissionsPresenter(
private val initialState: PermissionsState = aPermissionsState().copy(showDialog = false), private val initialState: PermissionsState = aPermissionsState(showDialog = false),
) : PermissionsPresenter { ) : PermissionsPresenter {
private fun eventSink(events: PermissionsEvents) { private fun eventSink(events: PermissionsEvents) {
when (events) { 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.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_no">"No"</string>
<string name="action_not_now">"Not now"</string> <string name="action_not_now">"Not now"</string>
<string name="action_ok">"OK"</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_open_with">"Open with"</string>
<string name="action_quick_reply">"Quick reply"</string> <string name="action_quick_reply">"Quick reply"</string>
<string name="action_quote">"Quote"</string> <string name="action_quote">"Quote"</string>
@ -105,6 +106,7 @@
<string name="common_password">"Password"</string> <string name="common_password">"Password"</string>
<string name="common_people">"People"</string> <string name="common_people">"People"</string>
<string name="common_permalink">"Permalink"</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_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_poll_undisclosed_text">"Results will show after the poll has ended"</string>
<string name="common_privacy_policy">"Privacy policy"</string> <string name="common_privacy_policy">"Privacy policy"</string>

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9787976d603dddf43993583b316cf05fde9473d09e17641632ee93f1869a4314 oid sha256:c3f0f1afc824e1fb49b50149ceb00ea8b687195c6e9f70010012f1c0aaed6af1
size 28307 size 32689

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a10a6b3ec837277fe6665847c85c9dac481d121b053b924b0c3334824f4497e3 oid sha256:92ef5cef1f64f76c60cc3961a9510710948644529d1e8354f9218aa38f4ab647
size 38688 size 32146

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0c492e1d390829e70e099ccd7c94f970c51f0d2289376cae273dded8e1e2adb5 oid sha256:e8bcbfe6bf79cb2b5d73a75f12a7588f78ca21e1a74ff9a6d0c87e194733ac73
size 32183 size 32693

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:81239e6188eb3761d880e4b724a9fb5e10a852d0ad6a697f8eae753eb7ae95ef
size 25386

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6bd4c43636ee860ee8f22548596327d8be88b3c14246a763be1fd5580606f6d3 oid sha256:9b32df35c4848bcaf45a1bef4cd61a3863ea47500e3cef0cdbeea887f6e98703
size 24193 size 28315

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:109f7dafffc356b4c79e6325d462e3e313183419956cbf7f492c5767d0d6cfa8 oid sha256:ac99b55aabf6777ce28a30c25a7747c37c46045c8b8cbbbe74b2a25890e8d47b
size 33810 size 27706

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3a4e69aca1d1697796ceb859efe993c30ad778e6633be7cf471edbee3ea7fbbe oid sha256:e2f038e9c9f19b099997b94ac95cb81a36b105e0b193bba97b7e6c17e85efdcd
size 27821 size 28358

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9e3188c584ccfeeeee801cf95f52e16e1c2420c34e682292f4ead8253e49200d
size 21863

View file

@ -50,6 +50,12 @@
"rich_text_editor.*" "rich_text_editor.*"
] ]
}, },
{
"name": ":libraries:permissions:api",
"includeRegex": [
"dialog\\.permission_.*"
]
},
{ {
"name": ":libraries:androidutils", "name": ":libraries:androidutils",
"includeRegex": [ "includeRegex": [