Add PickerLauncher wrapper for media/file pickers. (#361)

* Add `PickerLauncher` wrapper for media/file pickers.

* Add FileProvider path, handle Camera picker and add NoOp implementation to fix tests.

* Move media pickers to their own module.

* Add missing media pickers

* Add feature flag and some extra tests
This commit is contained in:
Jorge Martin Espinosa 2023-04-28 10:52:34 +02:00 committed by GitHub
parent 78a715ce8d
commit eeca1c9ee3
16 changed files with 483 additions and 51 deletions

View file

@ -0,0 +1,34 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.libraries.mediapickers"
dependencies {
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.core)
implementation(libs.inject)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.test.truth)
testImplementation(libs.test.robolectric)
}
}

View file

@ -0,0 +1,51 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.mediapickers
import androidx.activity.compose.ManagedActivityResultLauncher
/**
* Wrapper around [ManagedActivityResultLauncher] to be used with media/file pickers.
*/
interface PickerLauncher<Input, Output> {
/** Starts the activity result launcher with its default input. */
fun launch()
/** Starts the activity result launcher with a [customInput]. */
fun launch(customInput: Input)
}
class ComposePickerLauncher<Input, Output>(
private val managedLauncher: ManagedActivityResultLauncher<Input, Output>,
private val defaultRequest: Input,
) : PickerLauncher<Input, Output> {
override fun launch() {
managedLauncher.launch(defaultRequest)
}
override fun launch(customInput: Input) {
managedLauncher.launch(customInput)
}
}
/** Needed for screenshot tests. */
class NoOpPickerLauncher<Input, Output>(
private val onResult: () -> Unit,
) : PickerLauncher<Input, Output> {
override fun launch() = onResult()
override fun launch(customInput: Input) = onResult()
}

View file

@ -0,0 +1,150 @@
/*
* 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.mediapickers
import android.content.Context
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.core.content.FileProvider
import io.element.android.libraries.core.mimetype.MimeTypes
import java.io.File
import java.util.UUID
import javax.inject.Inject
class PickerProvider constructor(private val isInTest: Boolean) {
@Inject
constructor(): this(false)
/**
* Remembers and returns a [PickerLauncher] for a certain media/file [type].
*/
@Composable
internal fun <Input, Output> rememberPickerLauncher(
type: PickerType<Input, Output>,
onResult: (Output) -> Unit,
): PickerLauncher<Input, Output> {
return if (LocalInspectionMode.current || isInTest) {
NoOpPickerLauncher { }
} else {
val contract = type.getContract()
val managedLauncher = rememberLauncherForActivityResult(contract = contract, onResult = onResult)
remember(type) { ComposePickerLauncher(managedLauncher, type.getDefaultRequest()) }
}
}
/**
* Remembers and returns a [PickerLauncher] for a gallery item, either a picture or a video.
* [onResult] will be called with either the selected file's [Uri] or `null` if nothing was selected.
*/
@Composable
fun registerGalleryPicker(onResult: (Uri?) -> Unit): PickerLauncher<PickVisualMediaRequest, Uri?> {
// Tests and UI preview can't handle Contexts, so we might as well disable the whole picker
return if (LocalInspectionMode.current || isInTest) {
NoOpPickerLauncher { onResult(null) }
} else {
rememberPickerLauncher(type = PickerType.ImageAndVideo) { uri -> onResult(uri) }
}
}
/**
* Remembers and returns a [PickerLauncher] for a file of a certain [mimeType] (any type of file, by default).
* [onResult] will be called with either the selected file's [Uri] or `null` if nothing was selected.
*/
@Composable
fun registerFilePicker(mimeType: String = MimeTypes.Any, onResult: (Uri?) -> Unit): PickerLauncher<String, Uri?> {
// Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker
return if (LocalInspectionMode.current || isInTest) {
NoOpPickerLauncher { onResult(null) }
} else {
rememberPickerLauncher(type = PickerType.File(mimeType)) { uri -> onResult(uri) }
}
}
/**
* Remembers and returns a [PickerLauncher] for taking a photo with a camera app.
* @param [onResult] will be called with either the photo's [Uri] or `null` if nothing was selected.
* @param [deleteAfter] When it's `true`, the taken photo will be automatically removed after calling [onResult].
* It's `true` by default.
*/
@Composable
fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit, deleteAfter: Boolean = true): PickerLauncher<Uri, Boolean> {
// Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker
return if (LocalInspectionMode.current || isInTest) {
NoOpPickerLauncher { onResult(null) }
} else {
val context = LocalContext.current
val tmpFile = remember { getTemporaryFile(context) }
val tmpFileUri = remember(tmpFile) { getTemporaryUri(context, tmpFile) }
rememberPickerLauncher(type = PickerType.Camera.Photo(tmpFileUri)) { success ->
// Execute callback
onResult(if (success) tmpFileUri else null)
// Then remove the file and clear the picker
if (deleteAfter) {
tmpFile.delete()
}
}
}
}
/**
* Remembers and returns a [PickerLauncher] for recording a video with a camera app.
* @param [onResult] will be called with either the video's [Uri] or `null` if nothing was selected.
* @param [deleteAfter] When it's `true`, the recorded video will be automatically removed after calling [onResult].
* It's `true` by default.
*/
@Composable
fun registerCameraVideoPicker(onResult: (Uri?) -> Unit, deleteAfter: Boolean = true): PickerLauncher<Uri, Boolean> {
// Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker
return if (LocalInspectionMode.current || isInTest) {
NoOpPickerLauncher { onResult(null) }
} else {
val context = LocalContext.current
val tmpFile = remember { getTemporaryFile(context) }
val tmpFileUri = remember(tmpFile) { getTemporaryUri(context, tmpFile) }
rememberPickerLauncher(type = PickerType.Camera.Video(tmpFileUri)) { success ->
// Execute callback
onResult(if (success) tmpFileUri else null)
// Then remove the file and clear the picker
if (deleteAfter) {
tmpFile.delete()
}
}
}
}
private fun getTemporaryFile(
context: Context,
baseFolder: File = context.cacheDir,
filename: String = UUID.randomUUID().toString(),
): File {
return File(baseFolder, filename)
}
private fun getTemporaryUri(
context: Context,
file: File,
): Uri {
val authority = "${context.packageName}.fileprovider"
return FileProvider.getUriForFile(context, authority, file)
}
}

View file

@ -0,0 +1,58 @@
/*
* 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.mediapickers
import android.net.Uri
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts
import io.element.android.libraries.core.mimetype.MimeTypes
sealed interface PickerType<Input, Output> {
fun getContract(): ActivityResultContract<Input, Output>
fun getDefaultRequest(): Input
object ImageAndVideo : PickerType<PickVisualMediaRequest, Uri?> {
override fun getContract() = ActivityResultContracts.PickVisualMedia()
override fun getDefaultRequest(): PickVisualMediaRequest {
return PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)
}
}
object Camera {
data class Photo(val destUri: Uri) : PickerType<Uri, Boolean> {
override fun getContract() = ActivityResultContracts.TakePicture()
override fun getDefaultRequest(): Uri {
return destUri
}
}
data class Video(val destUri: Uri) : PickerType<Uri, Boolean> {
override fun getContract() = ActivityResultContracts.CaptureVideo()
override fun getDefaultRequest(): Uri {
return destUri
}
}
}
data class File(val mimeType: String = MimeTypes.Any) : PickerType<String, Uri?> {
override fun getContract() = ActivityResultContracts.GetContent()
override fun getDefaultRequest(): String {
return mimeType
}
}
}

View file

@ -0,0 +1,65 @@
/*
* 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.mediapickers
import android.net.Uri
import androidx.activity.result.contract.ActivityResultContracts
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.mimetype.MimeTypes
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class PickerTypeTests {
@Test
fun `ImageAndVideo - assert types`() {
val pickerType = PickerType.ImageAndVideo
assertThat(pickerType.getContract()).isInstanceOf(ActivityResultContracts.PickVisualMedia::class.java)
assertThat(pickerType.getDefaultRequest().mediaType).isEqualTo(ActivityResultContracts.PickVisualMedia.ImageAndVideo)
}
@Test
fun `File - assert types`() {
val pickerType = PickerType.File()
assertThat(pickerType.getContract()).isInstanceOf(ActivityResultContracts.GetContent::class.java)
assertThat(pickerType.getDefaultRequest()).isEqualTo(MimeTypes.Any)
val mimeType = MimeTypes.Images
val customPickerType = PickerType.File(mimeType)
assertThat(customPickerType.getContract()).isInstanceOf(ActivityResultContracts.GetContent::class.java)
assertThat(customPickerType.getDefaultRequest()).isEqualTo(mimeType)
}
@Test
fun `CameraPhoto - assert types`() {
val uri = Uri.parse("file:///tmp/test")
val pickerType = PickerType.Camera.Photo(uri)
assertThat(pickerType.getContract()).isInstanceOf(ActivityResultContracts.TakePicture::class.java)
assertThat(pickerType.getDefaultRequest()).isEqualTo(uri)
}
@Test
fun `CameraVideo - assert types`() {
val uri = Uri.parse("file:///tmp/test")
val pickerType = PickerType.Camera.Video(uri)
assertThat(pickerType.getContract()).isInstanceOf(ActivityResultContracts.CaptureVideo::class.java)
assertThat(pickerType.getDefaultRequest()).isEqualTo(uri)
}
}

View file

@ -50,6 +50,7 @@ fun TextComposer(
onFullscreenToggle: () -> Unit = {},
onCloseSpecialMode: () -> Unit = {},
onComposerTextChange: (CharSequence) -> Unit = {},
onAddAttachment:() -> Unit = {},
) {
if (LocalInspectionMode.current) {
FakeComposer(modifier)
@ -78,6 +79,7 @@ fun TextComposer(
}
override fun onAddAttachment() {
onAddAttachment()
}
override fun onExpandOrCompactChange() {