From 47b6d32a080aee62b28f9e3b886b1654b2bab4d4 Mon Sep 17 00:00:00 2001 From: Alexey Nesterov Date: Fri, 19 Apr 2024 17:16:47 +0700 Subject: [PATCH 01/18] #34 up moko permissions version 0.15.0 -> 0.18.0 --- gradle/libs.versions.toml | 2 +- .../moko/media/compose/BindMediaPickerEffect.android.kt | 7 ++++--- .../icerock/moko/media/picker/MediaPickerController.kt | 5 ++--- .../moko/media/picker/MediaPickerControllerImpl.kt | 9 +++++---- .../src/main/java/com/icerockdev/MainActivity.kt | 2 +- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 752c5ac..bd2411f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,7 @@ androidExifInterface = "1.3.6" androidMediaFilePicker = "1.9.1" coroutinesVersion = "1.6.4" mokoMvvmVersion = "0.16.0" -mokoPermissionsVersion = "0.15.0" +mokoPermissionsVersion = "0.18.0" mokoTestVersion = "0.6.1" mokoMediaVersion = "0.11.0" composeJetBrainsVersion = "1.3.1" diff --git a/media-compose/src/androidMain/kotlin/dev/icerock/moko/media/compose/BindMediaPickerEffect.android.kt b/media-compose/src/androidMain/kotlin/dev/icerock/moko/media/compose/BindMediaPickerEffect.android.kt index 5632a42..99b3c94 100644 --- a/media-compose/src/androidMain/kotlin/dev/icerock/moko/media/compose/BindMediaPickerEffect.android.kt +++ b/media-compose/src/androidMain/kotlin/dev/icerock/moko/media/compose/BindMediaPickerEffect.android.kt @@ -10,7 +10,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.fragment.app.FragmentActivity -import androidx.fragment.app.FragmentManager import androidx.lifecycle.LifecycleOwner import dev.icerock.moko.media.picker.MediaPickerController @@ -21,8 +20,10 @@ actual fun BindMediaPickerEffect(mediaPickerController: MediaPickerController) { val context: Context = LocalContext.current LaunchedEffect(mediaPickerController, lifecycleOwner, context) { - val fragmentManager: FragmentManager = (context as FragmentActivity).supportFragmentManager + val activity: FragmentActivity = checkNotNull(context as? FragmentActivity) { + "$context context is not instance of FragmentActivity" + } - mediaPickerController.bind(lifecycleOwner.lifecycle, fragmentManager) + mediaPickerController.bind(activity) } } diff --git a/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerController.kt b/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerController.kt index 6916b57..c40fc3e 100755 --- a/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerController.kt +++ b/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerController.kt @@ -4,8 +4,7 @@ package dev.icerock.moko.media.picker -import androidx.fragment.app.FragmentManager -import androidx.lifecycle.Lifecycle +import androidx.fragment.app.FragmentActivity import dev.icerock.moko.media.Bitmap import dev.icerock.moko.media.FileMedia import dev.icerock.moko.media.Media @@ -19,7 +18,7 @@ actual interface MediaPickerController { actual suspend fun pickMedia(): Media actual suspend fun pickFiles(): FileMedia - fun bind(lifecycle: Lifecycle, fragmentManager: FragmentManager) + fun bind(activity: FragmentActivity) companion object { operator fun invoke( diff --git a/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerControllerImpl.kt b/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerControllerImpl.kt index 5f24936..5b8985d 100755 --- a/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerControllerImpl.kt +++ b/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerControllerImpl.kt @@ -5,6 +5,7 @@ package dev.icerock.moko.media.picker import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleObserver @@ -25,10 +26,10 @@ internal class MediaPickerControllerImpl( ) : MediaPickerController { var fragmentManager: FragmentManager? = null - override fun bind(lifecycle: Lifecycle, fragmentManager: FragmentManager) { - permissionsController.bind(lifecycle, fragmentManager) + override fun bind(activity: FragmentActivity) { + permissionsController.bind(activity) - this.fragmentManager = fragmentManager + this.fragmentManager = activity.supportFragmentManager val observer = object : LifecycleObserver { @@ -38,7 +39,7 @@ internal class MediaPickerControllerImpl( source.lifecycle.removeObserver(this) } } - lifecycle.addObserver(observer) + activity.lifecycle.addObserver(observer) } override suspend fun pickImage(source: MediaSource): Bitmap { diff --git a/sample/android-app/src/main/java/com/icerockdev/MainActivity.kt b/sample/android-app/src/main/java/com/icerockdev/MainActivity.kt index 0100af6..f2a35d3 100755 --- a/sample/android-app/src/main/java/com/icerockdev/MainActivity.kt +++ b/sample/android-app/src/main/java/com/icerockdev/MainActivity.kt @@ -24,7 +24,7 @@ class MainActivity : AppCompatActivity() { ImageSelectionViewModel(mediaPickerController) } - viewModel.mediaPickerController.bind(lifecycle, supportFragmentManager) + viewModel.mediaPickerController.bind(this) binding.cameraButton.setOnClickListener { viewModel.onCameraPressed() } binding.galleryButton.setOnClickListener { viewModel.onGalleryPressed() } From 99c9e9e2b890486bc9d9bd36389d49706f2dc620 Mon Sep 17 00:00:00 2001 From: Alexey Nesterov Date: Wed, 24 Apr 2024 18:39:12 +0700 Subject: [PATCH 02/18] #34 replace FragmentActivity to ComponentActivity at bind MediaPickerController method and remove fragmentManager usage at pickImage method --- .../media/picker/MediaPickerController.kt | 4 +- .../media/picker/MediaPickerControllerImpl.kt | 196 ++++++++++++++++-- 2 files changed, 175 insertions(+), 25 deletions(-) diff --git a/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerController.kt b/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerController.kt index c40fc3e..498b691 100755 --- a/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerController.kt +++ b/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerController.kt @@ -4,7 +4,7 @@ package dev.icerock.moko.media.picker -import androidx.fragment.app.FragmentActivity +import androidx.activity.ComponentActivity import dev.icerock.moko.media.Bitmap import dev.icerock.moko.media.FileMedia import dev.icerock.moko.media.Media @@ -18,7 +18,7 @@ actual interface MediaPickerController { actual suspend fun pickMedia(): Media actual suspend fun pickFiles(): FileMedia - fun bind(activity: FragmentActivity) + fun bind(activity: ComponentActivity) companion object { operator fun invoke( diff --git a/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerControllerImpl.kt b/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerControllerImpl.kt index 5b8985d..7c80aa7 100755 --- a/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerControllerImpl.kt +++ b/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerControllerImpl.kt @@ -4,18 +4,34 @@ package dev.icerock.moko.media.picker +import android.app.Activity +import android.content.Context +import android.net.Uri +import android.os.Environment +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.ActivityResultRegistryOwner +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.FileProvider import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.OnLifecycleEvent import dev.icerock.moko.media.Bitmap +import dev.icerock.moko.media.BitmapUtils import dev.icerock.moko.media.FileMedia import dev.icerock.moko.media.Media import dev.icerock.moko.permissions.Permission import dev.icerock.moko.permissions.PermissionsController +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull +import java.io.File +import java.util.UUID import kotlin.coroutines.suspendCoroutine internal class MediaPickerControllerImpl( @@ -24,18 +40,73 @@ internal class MediaPickerControllerImpl( private val imagePickerFragmentTag: String, private val filePickerFragmentTag: String ) : MediaPickerController { + + private val activityHolder = MutableStateFlow(null) + + private var photoFilePath: String? = null + var fragmentManager: FragmentManager? = null - override fun bind(activity: FragmentActivity) { + private val key = UUID.randomUUID().toString() + + // TODO replace ImagePickerFragment.CallbackData to own CallbackData + private val codeCallbackMap = mutableMapOf() + + private val takePictureLauncherHolder = MutableStateFlow?>(null) + private val pickMultipleVisualMediaLauncherHolder = MutableStateFlow?>(null) + + private val maxImageWidth + get() = DEFAULT_MAX_IMAGE_WIDTH + private val maxImageHeight + get() = DEFAULT_MAX_IMAGE_HEIGHT + + override fun bind(activity: ComponentActivity) { + this.activityHolder.value = activity permissionsController.bind(activity) - this.fragmentManager = activity.supportFragmentManager + val activityResultRegistryOwner = activity as ActivityResultRegistryOwner + + val takePictureLauncher = activityResultRegistryOwner.activityResultRegistry.register( + "TakePicture-$key", + ActivityResultContracts.TakePicture() + ) { success -> + val callbackData = codeCallbackMap.values.last() + val callback = callbackData.callback + if (success) { + when (callbackData) { + is ImagePickerFragment.CallbackData.Camera -> { + processResult(activity, callback, callbackData.outputUri) + } + else -> Unit + } + } else { + callback.invoke(Result.failure(CanceledException())) + } + } + + val pickMultipleVisualMediaLauncher = activityResultRegistryOwner.activityResultRegistry.register( + "PickVisualMedia-$key", + ActivityResultContracts.PickVisualMedia() + ) { uri -> + val callbackData = codeCallbackMap.values.last() + val callback = callbackData.callback + if (uri != null) { + processResult(activity, callback, uri) + } else { + callback.invoke(Result.failure(CanceledException())) + } + } + + takePictureLauncherHolder.value = takePictureLauncher + pickMultipleVisualMediaLauncherHolder.value = pickMultipleVisualMediaLauncher val observer = object : LifecycleObserver { @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) fun onDestroyed(source: LifecycleOwner) { - this@MediaPickerControllerImpl.fragmentManager = null + this@MediaPickerControllerImpl.activityHolder.value = null + this@MediaPickerControllerImpl.takePictureLauncherHolder.value = null + this@MediaPickerControllerImpl.pickMultipleVisualMediaLauncherHolder.value = null source.lifecycle.removeObserver(this) } } @@ -52,50 +123,73 @@ internal class MediaPickerControllerImpl( * (Look here: https://youtrack.jetbrains.com/issue/KT-37331) */ override suspend fun pickImage(source: MediaSource, maxWidth: Int, maxHeight: Int): Bitmap { - val fragmentManager = - fragmentManager ?: error("can't pick image without active window") - source.requiredPermissions().forEach { permission -> permissionsController.providePermission(permission) } - val currentFragment: Fragment? = fragmentManager.findFragmentByTag(imagePickerFragmentTag) - val imagePickerFragment: ImagePickerFragment = if (currentFragment != null) { - currentFragment as ImagePickerFragment - } else { - ImagePickerFragment.newInstance(maxWidth, maxHeight).also { - fragmentManager - .beginTransaction() - .add(it, imagePickerFragmentTag) - .commitNow() - } - } + val outputUri = createPhotoUri() val bitmap = suspendCoroutine { continuation -> val action: (Result) -> Unit = { continuation.resumeWith(it) } when (source) { - MediaSource.GALLERY -> imagePickerFragment.pickGalleryImage(action) - MediaSource.CAMERA -> imagePickerFragment.pickCameraImage(action) + MediaSource.GALLERY -> pickGalleryImage(action) + MediaSource.CAMERA -> pickCameraImage(outputUri, action) } } return Bitmap(bitmap) } + private fun pickGalleryImage(callback: (Result) -> Unit) { + val requestCode = codeCallbackMap.keys.sorted().lastOrNull() ?: 0 + codeCallbackMap[requestCode] = + ImagePickerFragment.CallbackData.Gallery( + callback + ) + val launcher = pickMultipleVisualMediaLauncherHolder.value + launcher?.launch(PickVisualMediaRequest()) + } + + private fun pickCameraImage(outputUri: Uri, callback: (Result) -> Unit) { + val requestCode = codeCallbackMap.keys.sorted().lastOrNull() ?: 0 + codeCallbackMap[requestCode] = + ImagePickerFragment.CallbackData.Camera( + callback, + outputUri + ) + + val launcher = takePictureLauncherHolder.value + launcher?.launch(outputUri) + } + + private suspend fun createPhotoUri(): Uri { + val context = awaitActivity() + val filesDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) + val tmpFile = File(filesDir, DEFAULT_FILE_NAME) + photoFilePath = tmpFile.absolutePath + + return FileProvider.getUriForFile( + context, + context.applicationContext.packageName + FILE_PROVIDER_SUFFIX, + tmpFile + ) + } + + // TODO refactor fragmentManager usage override suspend fun pickMedia(): Media { val fragmentManager = fragmentManager ?: error("can't pick image without active window") permissionsController.providePermission(Permission.GALLERY) - val currentFragment: Fragment? = fragmentManager.findFragmentByTag(mediaPickerFragmentTag) + val currentFragment: Fragment? = fragmentManager.findFragmentByTag(pickerFragmentTag) val pickerFragment: MediaPickerFragment = if (currentFragment != null) { currentFragment as MediaPickerFragment } else { MediaPickerFragment().apply { fragmentManager .beginTransaction() - .add(this, mediaPickerFragmentTag) + .add(this, pickerFragmentTag) .commitNow() } } @@ -106,6 +200,41 @@ internal class MediaPickerControllerImpl( } } + @Suppress("ReturnCount") + private fun processResult( + context: Context, + callback: (Result) -> Unit, + uri: Uri + ) { + val contentResolver = context.contentResolver + + val bitmapOptions = contentResolver.openInputStream(uri)?.use { + BitmapUtils.getBitmapOptionsFromStream(it) + } ?: run { + callback.invoke(Result.failure(NoAccessToFileException(uri.toString()))) + return + } + + val sampleSize = + BitmapUtils.calculateInSampleSize(bitmapOptions, maxImageWidth, maxImageHeight) + + val orientation = contentResolver.openInputStream(uri)?.use { + BitmapUtils.getBitmapOrientation(it) + } ?: run { + callback.invoke(Result.failure(NoAccessToFileException(uri.toString()))) + return + } + + val bitmap = contentResolver.openInputStream(uri)?.use { + BitmapUtils.getNormalizedBitmap(it, orientation, sampleSize) + } ?: run { + callback.invoke(Result.failure(NoAccessToFileException(uri.toString()))) + return + } + + callback.invoke(Result.success(bitmap)) + } + override suspend fun pickFiles(): FileMedia { val fragmentManager = fragmentManager ?: error("can't pick image without active window") @@ -119,7 +248,7 @@ internal class MediaPickerControllerImpl( FilePickerFragment().apply { fragmentManager .beginTransaction() - .add(this, filePickerFragmentTag) + .add(this, pickerFragmentTag) .commitNow() } } @@ -132,10 +261,31 @@ internal class MediaPickerControllerImpl( return path } + private suspend fun awaitActivity(): Activity { + val activity = activityHolder.value + if (activity != null) return activity + + return withTimeoutOrNull(AWAIT_ACTIVITY_TIMEOUT_DURATION_MS) { + activityHolder.filterNotNull().first() + } ?: error( + "activity is null, `bind` function was never called," + + " consider calling mediaPickerController.bind(activity)" + + " or BindMediaPickerEffect(mediaPickerController) in the composable function," + + " check the documentation for more info: " + + "https://github.com/icerockdev/moko-media/blob/master/README.md" + ) + } + private fun MediaSource.requiredPermissions(): List { return when (this) { MediaSource.GALLERY -> listOf(Permission.GALLERY) MediaSource.CAMERA -> listOf(Permission.CAMERA) } } + + companion object { + private const val AWAIT_ACTIVITY_TIMEOUT_DURATION_MS = 2000L + private const val DEFAULT_FILE_NAME = "image.png" + private const val FILE_PROVIDER_SUFFIX = ".moko.media.provider" + } } From c98aa71977a57faa2442e9876b98e1163fe84e58 Mon Sep 17 00:00:00 2001 From: Alexey Nesterov Date: Thu, 25 Apr 2024 18:22:09 +0700 Subject: [PATCH 03/18] #34 remove fragmentManager and mediaFilePicker usage usage --- gradle/libs.versions.toml | 2 - media/build.gradle.kts | 3 - .../moko/media/picker/FilePickerFragment.kt | 68 ------- .../moko/media/picker/ImagePickerFragment.kt | 181 ------------------ .../media/picker/MediaPickerController.kt | 6 - .../media/picker/MediaPickerControllerImpl.kt | 146 +++++++++----- .../moko/media/picker/MediaPickerFragment.kt | 91 --------- .../main/java/com/icerockdev/MainActivity.kt | 1 + .../src/main/res/layout/activity_main.xml | 6 + .../library/ImageSelectionViewModel.kt | 4 + 10 files changed, 105 insertions(+), 403 deletions(-) delete mode 100644 media/src/androidMain/kotlin/dev/icerock/moko/media/picker/FilePickerFragment.kt delete mode 100644 media/src/androidMain/kotlin/dev/icerock/moko/media/picker/ImagePickerFragment.kt delete mode 100644 media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerFragment.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bd2411f..9b66123 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,6 @@ androidAppCompatVersion = "1.6.1" materialDesignVersion = "1.8.0" androidLifecycleVersion = "2.3.1" androidExifInterface = "1.3.6" -androidMediaFilePicker = "1.9.1" coroutinesVersion = "1.6.4" mokoMvvmVersion = "0.16.0" mokoPermissionsVersion = "0.18.0" @@ -17,7 +16,6 @@ appCompat = { module = "androidx.appcompat:appcompat", version.ref = "androidApp material = { module = "com.google.android.material:material", version.ref = "materialDesignVersion" } lifecycle = { module = "androidx.lifecycle:lifecycle-extensions", version.ref = "androidLifecycleVersion" } exifInterface = { module = "androidx.exifinterface:exifinterface", version.ref = "androidExifInterface" } -mediaFilePicker = { module = "com.github.icerockdev:MaterialFilePicker", version.ref = "androidMediaFilePicker" } coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutinesVersion" } mokoPermissions = { module = "dev.icerock.moko:permissions", version.ref = "mokoPermissionsVersion" } diff --git a/media/build.gradle.kts b/media/build.gradle.kts index c8c5b97..57d99a6 100644 --- a/media/build.gradle.kts +++ b/media/build.gradle.kts @@ -15,7 +15,4 @@ dependencies { androidMainImplementation(libs.appCompat) androidMainImplementation(libs.exifInterface) - - // TODO #34 remove external dependency - androidMainImplementation(libs.mediaFilePicker) } diff --git a/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/FilePickerFragment.kt b/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/FilePickerFragment.kt deleted file mode 100644 index 0403b0b..0000000 --- a/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/FilePickerFragment.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2021 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. - */ - -package dev.icerock.moko.media.picker - -import android.app.Activity -import android.content.Intent -import android.os.Environment -import androidx.fragment.app.Fragment -import com.nbsp.materialfilepicker.MaterialFilePicker -import com.nbsp.materialfilepicker.ui.FilePickerActivity -import dev.icerock.moko.media.FileMedia -import java.io.File - -class FilePickerFragment : Fragment() { - init { - @Suppress("DEPRECATION") - retainInstance = true - } - - private val codeCallbackMap = mutableMapOf() - - fun pickFile(callback: (Result) -> Unit) { - val requestCode = codeCallbackMap.keys.maxOrNull() ?: 0 - - codeCallbackMap[requestCode] = CallbackData(callback) - - // TODO нужно убрать использование внешней зависимости, сделать конфигурацию способа - // выбора файла из вне (аргументом в контроллер передавать) - val externalStorage = Environment.getExternalStorageDirectory() - MaterialFilePicker().withSupportFragment(this) - .withCloseMenu(true) - .withRootPath(externalStorage.absolutePath) - .withRequestCode(requestCode) - .start() - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - - val callbackData = codeCallbackMap[requestCode] ?: return - codeCallbackMap.remove(requestCode) - - val callback = callbackData.callback - - if (resultCode == Activity.RESULT_CANCELED) { - callback.invoke(Result.failure(CanceledException())) - return - } - - processResult(callback, data) - } - - private fun processResult( - callback: (Result) -> Unit, - data: Intent? - ) { - val filePath = data?.getStringExtra(FilePickerActivity.RESULT_FILE_PATH) - - filePath?.let { path -> - val name = File(path).name - callback(Result.success(FileMedia(name, path))) - } - } - - class CallbackData(val callback: (Result) -> Unit) -} diff --git a/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/ImagePickerFragment.kt b/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/ImagePickerFragment.kt deleted file mode 100644 index eaf55cd..0000000 --- a/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/ImagePickerFragment.kt +++ /dev/null @@ -1,181 +0,0 @@ -/* - * Copyright 2021 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. - */ - -package dev.icerock.moko.media.picker - -import android.app.Activity -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.os.Environment -import android.provider.MediaStore -import androidx.core.content.FileProvider -import androidx.fragment.app.Fragment -import dev.icerock.moko.media.BitmapUtils -import java.io.File - -class ImagePickerFragment : Fragment() { - init { - @Suppress("DEPRECATION") - retainInstance = true - } - - private val codeCallbackMap = mutableMapOf() - - private val maxImageWidth - get() = - arguments?.getInt(ARG_IMG_MAX_WIDTH, DEFAULT_MAX_IMAGE_WIDTH) - ?: DEFAULT_MAX_IMAGE_WIDTH - private val maxImageHeight - get() = - arguments?.getInt(ARG_IMG_MAX_HEIGHT, DEFAULT_MAX_IMAGE_HEIGHT) - ?: DEFAULT_MAX_IMAGE_HEIGHT - - private var photoFilePath: String? = null - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - - outState.putString(PHOTO_FILE_PATH_KEY, photoFilePath) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - photoFilePath = savedInstanceState?.getString(PHOTO_FILE_PATH_KEY) - } - - fun pickGalleryImage(callback: (Result) -> Unit) { - val requestCode = codeCallbackMap.keys.sorted().lastOrNull() ?: 0 - - codeCallbackMap[requestCode] = - CallbackData.Gallery( - callback - ) - - val intent = Intent( - Intent.ACTION_PICK, - MediaStore.Images.Media.EXTERNAL_CONTENT_URI - ) - startActivityForResult(intent, requestCode) - } - - fun pickCameraImage(callback: (Result) -> Unit) { - val requestCode = codeCallbackMap.keys.sorted().lastOrNull() ?: 0 - - val outputUri = createPhotoUri() - codeCallbackMap[requestCode] = - CallbackData.Camera( - callback, - outputUri - ) - - val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) - .putExtra(MediaStore.EXTRA_OUTPUT, outputUri) - startActivityForResult(intent, requestCode) - } - - private fun createPhotoUri(): Uri { - val context = requireContext() - val filesDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) - val tmpFile = File(filesDir, DEFAULT_FILE_NAME) - photoFilePath = tmpFile.absolutePath - - return FileProvider.getUriForFile( - context, - context.applicationContext.packageName + FILE_PROVIDER_SUFFIX, - tmpFile - ) - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - - val callbackData = codeCallbackMap[requestCode] ?: return - codeCallbackMap.remove(requestCode) - - val callback = callbackData.callback - - if (resultCode == Activity.RESULT_CANCELED) { - callback.invoke(Result.failure(CanceledException())) - return - } - - when (callbackData) { - is CallbackData.Gallery -> { - val uri = data?.data - if (uri != null) { - processResult(callback, uri) - } else { - callback.invoke(Result.failure(IllegalArgumentException(data?.toString()))) - } - } - is CallbackData.Camera -> { - processResult(callback, callbackData.outputUri) - } - } - } - - @Suppress("ReturnCount") - private fun processResult( - callback: (Result) -> Unit, - uri: Uri - ) { - val contentResolver = requireContext().contentResolver - - val bitmapOptions = contentResolver.openInputStream(uri)?.use { - BitmapUtils.getBitmapOptionsFromStream(it) - } ?: run { - callback.invoke(Result.failure(NoAccessToFileException(uri.toString()))) - return - } - - val sampleSize = - BitmapUtils.calculateInSampleSize(bitmapOptions, maxImageWidth, maxImageHeight) - - val orientation = contentResolver.openInputStream(uri)?.use { - BitmapUtils.getBitmapOrientation(it) - } ?: run { - callback.invoke(Result.failure(NoAccessToFileException(uri.toString()))) - return - } - - val bitmap = contentResolver.openInputStream(uri)?.use { - BitmapUtils.getNormalizedBitmap(it, orientation, sampleSize) - } ?: run { - callback.invoke(Result.failure(NoAccessToFileException(uri.toString()))) - return - } - - callback.invoke(Result.success(bitmap)) - } - - sealed class CallbackData(val callback: (Result) -> Unit) { - class Gallery(callback: (Result) -> Unit) : - CallbackData(callback) - - class Camera( - callback: (Result) -> Unit, - val outputUri: Uri - ) : CallbackData(callback) - } - - companion object { - private const val DEFAULT_FILE_NAME = "image.png" - private const val PHOTO_FILE_PATH_KEY = "photoFilePath" - private const val FILE_PROVIDER_SUFFIX = ".moko.media.provider" - - private const val ARG_IMG_MAX_WIDTH = "args_img_max_width" - private const val ARG_IMG_MAX_HEIGHT = "args_img_max_height" - - fun newInstance(maxWidth: Int, maxHeight: Int): ImagePickerFragment { - val pickerFragment = ImagePickerFragment() - pickerFragment.arguments = Bundle().apply { - putInt(ARG_IMG_MAX_WIDTH, maxWidth) - putInt(ARG_IMG_MAX_HEIGHT, maxHeight) - } - return pickerFragment - } - } -} diff --git a/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerController.kt b/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerController.kt index 498b691..635e36e 100755 --- a/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerController.kt +++ b/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerController.kt @@ -23,15 +23,9 @@ actual interface MediaPickerController { companion object { operator fun invoke( permissionsController: PermissionsController, - mediaPickerFragmentTag: String = "MediaControllerMediaPicker", - imagePickerFragmentTag: String = "MediaControllerImagePicker", - filePickerFragmentTag: String = "FileMediaControllerPicker" ): MediaPickerController { return MediaPickerControllerImpl( permissionsController = permissionsController, - mediaPickerFragmentTag = mediaPickerFragmentTag, - imagePickerFragmentTag = imagePickerFragmentTag, - filePickerFragmentTag = filePickerFragmentTag ) } } diff --git a/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerControllerImpl.kt b/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerControllerImpl.kt index 7c80aa7..e0d7f98 100755 --- a/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerControllerImpl.kt +++ b/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerControllerImpl.kt @@ -14,8 +14,6 @@ import androidx.activity.result.ActivityResultRegistryOwner import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.FileProvider -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.LifecycleOwner @@ -36,24 +34,19 @@ import kotlin.coroutines.suspendCoroutine internal class MediaPickerControllerImpl( override val permissionsController: PermissionsController, - private val mediaPickerFragmentTag: String, - private val imagePickerFragmentTag: String, - private val filePickerFragmentTag: String ) : MediaPickerController { private val activityHolder = MutableStateFlow(null) private var photoFilePath: String? = null - var fragmentManager: FragmentManager? = null - private val key = UUID.randomUUID().toString() - // TODO replace ImagePickerFragment.CallbackData to own CallbackData - private val codeCallbackMap = mutableMapOf() + private val codeCallbackMap = mutableMapOf>() private val takePictureLauncherHolder = MutableStateFlow?>(null) - private val pickMultipleVisualMediaLauncherHolder = MutableStateFlow?>(null) + private val pickVisualMediaLauncherHolder = MutableStateFlow?>(null) + private val pickFileMediaLauncherHolder = MutableStateFlow>?>(null) private val maxImageWidth get() = DEFAULT_MAX_IMAGE_WIDTH @@ -70,11 +63,11 @@ internal class MediaPickerControllerImpl( "TakePicture-$key", ActivityResultContracts.TakePicture() ) { success -> - val callbackData = codeCallbackMap.values.last() + val callbackData = codeCallbackMap.values.last() as CallbackData val callback = callbackData.callback if (success) { when (callbackData) { - is ImagePickerFragment.CallbackData.Camera -> { + is CallbackData.Camera -> { processResult(activity, callback, callbackData.outputUri) } else -> Unit @@ -84,11 +77,11 @@ internal class MediaPickerControllerImpl( } } - val pickMultipleVisualMediaLauncher = activityResultRegistryOwner.activityResultRegistry.register( + val pickVisualMediaLauncher = activityResultRegistryOwner.activityResultRegistry.register( "PickVisualMedia-$key", ActivityResultContracts.PickVisualMedia() ) { uri -> - val callbackData = codeCallbackMap.values.last() + val callbackData = codeCallbackMap.values.last() as CallbackData val callback = callbackData.callback if (uri != null) { processResult(activity, callback, uri) @@ -97,8 +90,30 @@ internal class MediaPickerControllerImpl( } } + val pickFileMediaLauncher = activityResultRegistryOwner.activityResultRegistry.register( + "PickFileMedia-$key", + ActivityResultContracts.OpenDocument() + ) { uri -> + val callbackData = codeCallbackMap.values.last() as CallbackData + val callback = callbackData.callback + + if (uri != null) { + callback.invoke(Result.failure(CanceledException())) + return@register + } + + if (uri?.path == null) { + callback.invoke(Result.failure(java.lang.IllegalStateException("File is null"))) + return@register + } + uri.path?.let { path -> + // TODO pass result + } + } + takePictureLauncherHolder.value = takePictureLauncher - pickMultipleVisualMediaLauncherHolder.value = pickMultipleVisualMediaLauncher + pickVisualMediaLauncherHolder.value = pickVisualMediaLauncher + pickFileMediaLauncherHolder.value = pickFileMediaLauncher val observer = object : LifecycleObserver { @@ -106,7 +121,8 @@ internal class MediaPickerControllerImpl( fun onDestroyed(source: LifecycleOwner) { this@MediaPickerControllerImpl.activityHolder.value = null this@MediaPickerControllerImpl.takePictureLauncherHolder.value = null - this@MediaPickerControllerImpl.pickMultipleVisualMediaLauncherHolder.value = null + this@MediaPickerControllerImpl.pickVisualMediaLauncherHolder.value = null + this@MediaPickerControllerImpl.pickFileMediaLauncherHolder.value = null source.lifecycle.removeObserver(this) } } @@ -143,17 +159,17 @@ internal class MediaPickerControllerImpl( private fun pickGalleryImage(callback: (Result) -> Unit) { val requestCode = codeCallbackMap.keys.sorted().lastOrNull() ?: 0 codeCallbackMap[requestCode] = - ImagePickerFragment.CallbackData.Gallery( + CallbackData.Gallery( callback ) - val launcher = pickMultipleVisualMediaLauncherHolder.value + val launcher = pickVisualMediaLauncherHolder.value launcher?.launch(PickVisualMediaRequest()) } private fun pickCameraImage(outputUri: Uri, callback: (Result) -> Unit) { val requestCode = codeCallbackMap.keys.sorted().lastOrNull() ?: 0 codeCallbackMap[requestCode] = - ImagePickerFragment.CallbackData.Camera( + CallbackData.Camera( callback, outputUri ) @@ -162,6 +178,16 @@ internal class MediaPickerControllerImpl( launcher?.launch(outputUri) } + private fun pickMediaFile(callback: (Result) -> Unit) { + val requestCode = codeCallbackMap.keys.sorted().lastOrNull() ?: 0 + codeCallbackMap[requestCode] = + CallbackData.Media( + callback + ) + val launcher = pickVisualMediaLauncherHolder.value + launcher?.launch(PickVisualMediaRequest()) + } + private suspend fun createPhotoUri(): Uri { val context = awaitActivity() val filesDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) @@ -175,31 +201,44 @@ internal class MediaPickerControllerImpl( ) } - // TODO refactor fragmentManager usage override suspend fun pickMedia(): Media { - val fragmentManager = - fragmentManager ?: error("can't pick image without active window") - permissionsController.providePermission(Permission.GALLERY) - val currentFragment: Fragment? = fragmentManager.findFragmentByTag(pickerFragmentTag) - val pickerFragment: MediaPickerFragment = if (currentFragment != null) { - currentFragment as MediaPickerFragment - } else { - MediaPickerFragment().apply { - fragmentManager - .beginTransaction() - .add(this, pickerFragmentTag) - .commitNow() - } - } - return suspendCoroutine { continuation -> val action: (Result) -> Unit = { continuation.resumeWith(it) } - pickerFragment.pickMedia(action) + pickMediaFile(action) } } + private fun pickFile(callback: (Result) -> Unit) { + val requestCode = codeCallbackMap.keys.sorted().lastOrNull() ?: 0 + codeCallbackMap[requestCode] = + CallbackData.FileMedia( + callback + ) + val launcher = pickFileMediaLauncherHolder.value + launcher?.launch( + arrayOf( + "application/pdf", + "application/octet-stream", + "application/doc", + "application/msword", + "application/ms-doc", + "application/vnd.ms-excel", + "application/vnd.ms-powerpoint", + "application/json", + "application/zip", + "text/plain", + "text/html", + "text/xml", + "audio/mpeg", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + ) + } + @Suppress("ReturnCount") private fun processResult( context: Context, @@ -236,26 +275,11 @@ internal class MediaPickerControllerImpl( } override suspend fun pickFiles(): FileMedia { - val fragmentManager = - fragmentManager ?: error("can't pick image without active window") - permissionsController.providePermission(Permission.STORAGE) - val currentFragment: Fragment? = fragmentManager.findFragmentByTag(filePickerFragmentTag) - val pickerFragment: FilePickerFragment = if (currentFragment != null) { - currentFragment as FilePickerFragment - } else { - FilePickerFragment().apply { - fragmentManager - .beginTransaction() - .add(this, pickerFragmentTag) - .commitNow() - } - } - val path = suspendCoroutine { continuation -> val action: (Result) -> Unit = { continuation.resumeWith(it) } - pickerFragment.pickFile(action) + pickFile(action) } return path @@ -283,6 +307,24 @@ internal class MediaPickerControllerImpl( } } + sealed class CallbackData(val callback: (Result) -> Unit) { + class Gallery(callback: (Result) -> Unit) : + CallbackData(callback) + + class Camera( + callback: (Result) -> Unit, + val outputUri: Uri + ) : CallbackData(callback) + + class Media( + callback: (Result) -> Unit, + ) : CallbackData(callback) + + class FileMedia( + callback: (Result) -> Unit, + ) : CallbackData(callback) + } + companion object { private const val AWAIT_ACTIVITY_TIMEOUT_DURATION_MS = 2000L private const val DEFAULT_FILE_NAME = "image.png" diff --git a/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerFragment.kt b/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerFragment.kt deleted file mode 100644 index 46d113a..0000000 --- a/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerFragment.kt +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright 2021 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. - */ - -package dev.icerock.moko.media.picker - -import android.app.Activity -import android.content.Intent -import androidx.fragment.app.Fragment -import dev.icerock.moko.media.Media -import dev.icerock.moko.media.MediaFactory - -class MediaPickerFragment : Fragment() { - - private val codeCallbackMap = mutableMapOf() - - init { - @Suppress("DEPRECATION") - retainInstance = true - } - - fun pickVideo(callback: (Result) -> Unit) { - val requestCode = codeCallbackMap.keys.maxOrNull() ?: 0 - - codeCallbackMap[requestCode] = CallbackData(callback) - - val intent = Intent().apply { - type = "video/*" - action = Intent.ACTION_GET_CONTENT - } - - startActivityForResult(intent, requestCode) - } - - fun pickMedia(callback: (Result) -> Unit) { - val requestCode = codeCallbackMap.keys.maxOrNull() ?: 0 - - codeCallbackMap[requestCode] = CallbackData(callback) - - val intent = Intent().apply { - type = "image/* video/*" - action = Intent.ACTION_GET_CONTENT - putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("video/*", "image/*")) - } - startActivityForResult(intent, requestCode) - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - - val callbackData = codeCallbackMap[requestCode] ?: return - codeCallbackMap.remove(requestCode) - - val callback = callbackData.callback - - if (resultCode == Activity.RESULT_CANCELED) { - callback.invoke(Result.failure(CanceledException())) - return - } - - processResult(callback, data) - } - - @Suppress("ReturnCount") - private fun processResult( - callback: (Result) -> Unit, - intent: Intent? - ) { - val context = this.context - if (context == null) { - callback(Result.failure(IllegalStateException("context unavailable"))) - return - } - if (intent == null) { - callback(Result.failure(IllegalStateException("intent unavailable"))) - return - } - val intentData = intent.data - if (intentData == null) { - callback(Result.failure(IllegalStateException("intentData unavailable"))) - return - } - - val result = kotlin.runCatching { - MediaFactory.create(context, intentData) - } - callback.invoke(result) - } - - class CallbackData(val callback: (Result) -> Unit) -} diff --git a/sample/android-app/src/main/java/com/icerockdev/MainActivity.kt b/sample/android-app/src/main/java/com/icerockdev/MainActivity.kt index f2a35d3..a23291f 100755 --- a/sample/android-app/src/main/java/com/icerockdev/MainActivity.kt +++ b/sample/android-app/src/main/java/com/icerockdev/MainActivity.kt @@ -28,6 +28,7 @@ class MainActivity : AppCompatActivity() { binding.cameraButton.setOnClickListener { viewModel.onCameraPressed() } binding.galleryButton.setOnClickListener { viewModel.onGalleryPressed() } + binding.fileButton.setOnClickListener { viewModel.onFilePressed() } viewModel.textState.ld().observe(this) { binding.textView.text = it } viewModel.selectedImage.ld() .observe(this) { binding.imageView.setImageBitmap(it?.platformBitmap) } diff --git a/sample/android-app/src/main/res/layout/activity_main.xml b/sample/android-app/src/main/res/layout/activity_main.xml index c3640c1..305ab25 100755 --- a/sample/android-app/src/main/res/layout/activity_main.xml +++ b/sample/android-app/src/main/res/layout/activity_main.xml @@ -31,4 +31,10 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Gallery" /> + +