First Commit
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import extension.testCommonDependencies
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.mediapickers.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.di)
|
||||
|
||||
testCommonDependencies(libs)
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediapickers.api
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
try {
|
||||
managedLauncher.launch(defaultRequest)
|
||||
} catch (activityNotFoundException: ActivityNotFoundException) {
|
||||
Timber.w(activityNotFoundException, "No activity found")
|
||||
}
|
||||
}
|
||||
|
||||
override fun launch(customInput: Input) {
|
||||
try {
|
||||
managedLauncher.launch(customInput)
|
||||
} catch (activityNotFoundException: ActivityNotFoundException) {
|
||||
Timber.w(activityNotFoundException, "No activity found")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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()
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediapickers.api
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
interface PickerProvider {
|
||||
@Composable
|
||||
fun registerGalleryPicker(
|
||||
onResult: (uri: Uri?, mimeType: String?) -> Unit
|
||||
): PickerLauncher<PickVisualMediaRequest, Uri?>
|
||||
|
||||
@Composable
|
||||
fun registerGalleryImagePicker(
|
||||
onResult: (Uri?) -> Unit
|
||||
): PickerLauncher<PickVisualMediaRequest, Uri?>
|
||||
|
||||
@Composable
|
||||
fun registerFilePicker(
|
||||
mimeType: String,
|
||||
onResult: (uri: Uri?, mimeType: String?) -> Unit,
|
||||
): PickerLauncher<String, Uri?>
|
||||
|
||||
@Composable
|
||||
fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean>
|
||||
|
||||
@Composable
|
||||
fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean>
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediapickers.api
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
|
||||
@Immutable
|
||||
sealed interface PickerType<Input, Output> {
|
||||
fun getContract(): ActivityResultContract<Input, Output>
|
||||
fun getDefaultRequest(): Input
|
||||
|
||||
data object Image : PickerType<PickVisualMediaRequest, Uri?> {
|
||||
override fun getContract() = ActivityResultContracts.PickVisualMedia()
|
||||
override fun getDefaultRequest(): PickVisualMediaRequest {
|
||||
return PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
||||
}
|
||||
}
|
||||
|
||||
data 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
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
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 io.element.android.libraries.mediapickers.api.PickerType
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class PickerTypeTest {
|
||||
@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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import extension.setupDependencyInjection
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
}
|
||||
|
||||
setupDependencyInjection()
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.mediapickers.impl"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.di)
|
||||
api(projects.libraries.mediapickers.api)
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediapickers.impl
|
||||
|
||||
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.LocalInspectionMode
|
||||
import androidx.core.content.FileProvider
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.mediapickers.api.ComposePickerLauncher
|
||||
import io.element.android.libraries.mediapickers.api.NoOpPickerLauncher
|
||||
import io.element.android.libraries.mediapickers.api.PickerLauncher
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
import io.element.android.libraries.mediapickers.api.PickerType
|
||||
import java.io.File
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultPickerProvider(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : PickerProvider {
|
||||
/**
|
||||
* 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) {
|
||||
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 picture.
|
||||
* [onResult] will be called with either the selected file's [Uri] or `null` if nothing was selected.
|
||||
*/
|
||||
@Composable
|
||||
override fun registerGalleryImagePicker(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) {
|
||||
NoOpPickerLauncher { onResult(null) }
|
||||
} else {
|
||||
rememberPickerLauncher(type = PickerType.Image) { uri -> onResult(uri) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
override fun registerGalleryPicker(
|
||||
onResult: (uri: Uri?, mimeType: String?) -> 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) {
|
||||
NoOpPickerLauncher { onResult(null, null) }
|
||||
} else {
|
||||
rememberPickerLauncher(type = PickerType.ImageAndVideo) { uri ->
|
||||
val mimeType = uri?.let { context.contentResolver.getType(it) }
|
||||
onResult(uri, mimeType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
override fun registerFilePicker(
|
||||
mimeType: String,
|
||||
onResult: (uri: Uri?, mimeType: String?) -> 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) {
|
||||
NoOpPickerLauncher { onResult(null, null) }
|
||||
} else {
|
||||
rememberPickerLauncher(type = PickerType.File(mimeType)) { uri ->
|
||||
val pickedMimeType = uri?.let { context.contentResolver.getType(it) }
|
||||
onResult(uri, pickedMimeType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@Composable
|
||||
override fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): 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) {
|
||||
NoOpPickerLauncher { onResult(null) }
|
||||
} else {
|
||||
val tmpFile = remember { getTemporaryFile("photo.jpg") }
|
||||
val tmpFileUri = remember(tmpFile) { getTemporaryUri(tmpFile) }
|
||||
rememberPickerLauncher(type = PickerType.Camera.Photo(tmpFileUri)) { success ->
|
||||
// Execute callback
|
||||
onResult(if (success) tmpFileUri else null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@Composable
|
||||
override fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): 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) {
|
||||
NoOpPickerLauncher { onResult(null) }
|
||||
} else {
|
||||
val tmpFile = remember { getTemporaryFile("video.mp4") }
|
||||
val tmpFileUri = remember(tmpFile) { getTemporaryUri(tmpFile) }
|
||||
rememberPickerLauncher(type = PickerType.Camera.Video(tmpFileUri)) { success ->
|
||||
// Execute callback
|
||||
onResult(if (success) tmpFileUri else null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getTemporaryFile(
|
||||
filename: String,
|
||||
): File {
|
||||
return File(context.cacheDir, filename)
|
||||
}
|
||||
|
||||
private fun getTemporaryUri(
|
||||
file: File,
|
||||
): Uri {
|
||||
val authority = "${context.packageName}.fileprovider"
|
||||
return FileProvider.getUriForFile(context, authority, file)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.mediapickers.test"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.di)
|
||||
api(projects.libraries.mediapickers.api)
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.mediapickers.test
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.compose.runtime.Composable
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.mediapickers.api.NoOpPickerLauncher
|
||||
import io.element.android.libraries.mediapickers.api.PickerLauncher
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
|
||||
class FakePickerProvider : PickerProvider {
|
||||
private var mimeType = MimeTypes.Any
|
||||
private var result: Uri? = null
|
||||
|
||||
@Composable
|
||||
override fun registerGalleryPicker(onResult: (uri: Uri?, mimeType: String?) -> Unit): PickerLauncher<PickVisualMediaRequest, Uri?> {
|
||||
return NoOpPickerLauncher { onResult(result, mimeType) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun registerGalleryImagePicker(onResult: (uri: Uri?) -> Unit): PickerLauncher<PickVisualMediaRequest, Uri?> {
|
||||
return NoOpPickerLauncher { onResult(result) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun registerFilePicker(mimeType: String, onResult: (Uri?, String?) -> Unit): PickerLauncher<String, Uri?> {
|
||||
return NoOpPickerLauncher { onResult(result, this.mimeType) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean> {
|
||||
return NoOpPickerLauncher { onResult(result) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean> {
|
||||
return NoOpPickerLauncher { onResult(result) }
|
||||
}
|
||||
|
||||
fun givenResult(value: Uri?) {
|
||||
this.result = value
|
||||
}
|
||||
|
||||
fun givenMimeType(mimeType: String) {
|
||||
this.mimeType = mimeType
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user