diff --git a/README.md b/README.md index 83049e1..b042071 100755 --- a/README.md +++ b/README.md @@ -85,8 +85,7 @@ val viewModel = getViewModel { } viewModel.mediaController.bind( - lifecycle, - supportFragmentManager + activity ) // permissioncController bind automatically ``` diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 752c5ac..e8ab3f3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,23 +1,21 @@ [versions] -kotlinVersion = "1.8.10" +kotlinVersion = "1.9.10" 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.15.0" +mokoPermissionsVersion = "0.18.0" mokoTestVersion = "0.6.1" mokoMediaVersion = "0.11.0" -composeJetBrainsVersion = "1.3.1" +composeJetBrainsVersion = "1.5.1" [libraries] appCompat = { module = "androidx.appcompat:appcompat", version.ref = "androidAppCompatVersion" } 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-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..d11c528 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 @@ -5,12 +5,11 @@ package dev.icerock.moko.media.compose import android.content.Context +import androidx.activity.ComponentActivity import androidx.compose.runtime.Composable 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: ComponentActivity = checkNotNull(context as? ComponentActivity) { + "$context context is not instance of ComponentActivity" + } - mediaPickerController.bind(lifecycleOwner.lifecycle, fragmentManager) + mediaPickerController.bind(activity) } } diff --git a/media-test/src/androidMain/kotlin/dev/icerock/moko/media/test/MediaPickerControllerMock.kt b/media-test/src/androidMain/kotlin/dev/icerock/moko/media/test/MediaPickerControllerMock.kt index b264656..90fab84 100644 --- a/media-test/src/androidMain/kotlin/dev/icerock/moko/media/test/MediaPickerControllerMock.kt +++ b/media-test/src/androidMain/kotlin/dev/icerock/moko/media/test/MediaPickerControllerMock.kt @@ -4,6 +4,7 @@ package dev.icerock.moko.media.test +import androidx.activity.ComponentActivity import dev.icerock.moko.media.Bitmap import dev.icerock.moko.media.FileMedia import dev.icerock.moko.media.Media @@ -35,8 +36,7 @@ actual open class MediaPickerControllerMock actual constructor( } override fun bind( - lifecycle: androidx.lifecycle.Lifecycle, - fragmentManager: androidx.fragment.app.FragmentManager + activity: ComponentActivity ) { TODO("Not yet implemented") } diff --git a/media-test/src/iosMain/kotlin/dev/icerock/moko/media/test/createBitmapMock.kt b/media-test/src/iosMain/kotlin/dev/icerock/moko/media/test/createBitmapMock.kt index f70fae8..751995a 100644 --- a/media-test/src/iosMain/kotlin/dev/icerock/moko/media/test/createBitmapMock.kt +++ b/media-test/src/iosMain/kotlin/dev/icerock/moko/media/test/createBitmapMock.kt @@ -5,10 +5,12 @@ package dev.icerock.moko.media.test import dev.icerock.moko.media.Bitmap +import kotlinx.cinterop.ExperimentalForeignApi import platform.CoreGraphics.CGRectMake import platform.CoreImage.CIImage import platform.UIKit.UIImage +@OptIn(ExperimentalForeignApi::class) actual fun createBitmapMock(): Bitmap { val image = CIImage.clearImage.imageByCroppingToRect(CGRectMake(0.0, 0.0, 1.0, 1.0)) return Bitmap(UIImage(image)) 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/CameraPickerDelegate.kt b/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/CameraPickerDelegate.kt new file mode 100644 index 0000000..e7847e8 --- /dev/null +++ b/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/CameraPickerDelegate.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2024 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.icerock.moko.media.picker + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.contract.ActivityResultContracts + +internal class CameraPickerDelegate : + ImagePickerDelegate() { + + override fun registerActivityResult( + context: Context, + activityResultRegistry: ActivityResultRegistry + ): ActivityResultLauncher = activityResultRegistry.register( + PICK_CAMERA_IMAGE_KEY, + ActivityResultContracts.TakePicture(), + ) { result -> + val callbackData = callback ?: return@register + callback = null + + if (!result) { + callbackData.callback.invoke(Result.failure(CanceledException())) + return@register + } + + processResult( + context = context, + callback = callbackData.callback, + uri = callbackData.outputUri, + maxImageWidth = callbackData.maxWidth, + maxImageHeight = callbackData.maxHeight, + ) + } + + override fun createCallback( + callback: (Result) -> Unit, + mediaOptions: MediaOptions? + ): CameraPickerCallbackData { + val cameraPickerMediaOptions = mediaOptions as? CameraPickerMediaOptions + val outputUri = cameraPickerMediaOptions?.outputUri ?: Uri.EMPTY + val maxWidth = cameraPickerMediaOptions?.maxWidth ?: 0 + val maxHeight = cameraPickerMediaOptions?.maxHeight ?: 0 + return CameraPickerCallbackData( + callback, + outputUri, + maxWidth, + maxHeight, + ) + } + + override fun launchActivityResult(mediaOptions: MediaOptions?) { + val cameraPickerMediaOptions = mediaOptions as? CameraPickerMediaOptions + val outputUri = cameraPickerMediaOptions?.outputUri ?: Uri.EMPTY + pickerLauncherHolder.value?.launch( + outputUri + ) + } + + class CameraPickerCallbackData( + override val callback: (Result) -> Unit, + val outputUri: Uri, + val maxWidth: Int, + val maxHeight: Int, + ) : CallbackData + + class CameraPickerMediaOptions( + val outputUri: Uri, + val maxWidth: Int, + val maxHeight: Int, + ) : MediaOptions + + companion object { + private const val PICK_CAMERA_IMAGE_KEY = "PickCameraImageKey" + } +} diff --git a/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/FilePickerDelegate.kt b/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/FilePickerDelegate.kt new file mode 100644 index 0000000..af55189 --- /dev/null +++ b/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/FilePickerDelegate.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2024 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.icerock.moko.media.picker + +import android.annotation.SuppressLint +import android.content.ContentResolver +import android.content.Context +import android.provider.OpenableColumns +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.contract.ActivityResultContracts +import dev.icerock.moko.media.FileMedia +import java.io.File + +internal class FilePickerDelegate : + PickerDelegate, FileMedia>() { + + override fun registerActivityResult( + context: Context, + activityResultRegistry: ActivityResultRegistry + ): ActivityResultLauncher> = activityResultRegistry.register( + PICK_FILE_KEY, + ActivityResultContracts.OpenDocument(), + ) { uri -> + val callbackData = callback ?: return@register + callback = null + + val callback = callbackData.callback + + if (uri == null) { + callback.invoke(Result.failure(CanceledException())) + return@register + } + + val path = uri.path + if (path == null) { + callback.invoke(Result.failure(java.lang.IllegalStateException("File is null"))) + return@register + } + + @SuppressLint("Range") + val fileNameWithExtension = if (uri.scheme == ContentResolver.SCHEME_CONTENT) { + val cursor = context.contentResolver.query(uri, null, null, null, null) + cursor?.use { + if (!it.moveToFirst()) null + else it.getString(it.getColumnIndex(OpenableColumns.DISPLAY_NAME)) + } + } else null + + val file = File(path) + val name = file.name + val result = Result.success( + FileMedia( + fileNameWithExtension ?: name, + uri.toString(), + ) + ) + callback.invoke(result) + } + + override fun createCallback( + callback: (Result) -> Unit, + mediaOptions: MediaOptions? + ): FilePickerCallbackData = FilePickerCallbackData(callback) + + override fun launchActivityResult(mediaOptions: MediaOptions?) { + pickerLauncherHolder.value?.launch( + arrayOf( + "*/*", + ) + ) + } + + class FilePickerCallbackData( + override val callback: (Result) -> Unit + ) : CallbackData + + companion object { + private const val PICK_FILE_KEY = "PickFileKey" + } +} 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/GalleryPickerDelegate.kt b/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/GalleryPickerDelegate.kt new file mode 100644 index 0000000..f03de13 --- /dev/null +++ b/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/GalleryPickerDelegate.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2024 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.icerock.moko.media.picker + +import android.content.Context +import android.graphics.Bitmap +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts + +internal class GalleryPickerDelegate : + ImagePickerDelegate() { + + override fun registerActivityResult( + context: Context, + activityResultRegistry: ActivityResultRegistry + ): ActivityResultLauncher = activityResultRegistry.register( + PICK_GALLERY_IMAGE_KEY, + ActivityResultContracts.PickVisualMedia(), + ) { uri -> + val callbackData = callback ?: return@register + callback = null + + if (uri == null) { + callbackData.callback.invoke(Result.failure(CanceledException())) + return@register + } + + processResult( + context = context, + callback = callbackData.callback, + uri = uri, + maxImageWidth = callbackData.maxWidth, + maxImageHeight = callbackData.maxHeight, + ) + } + + override fun createCallback( + callback: (Result) -> Unit, + mediaOptions: MediaOptions? + ): GalleryPickerCallbackData { + val galleryPickerMediaOptions = mediaOptions as? GalleryPickerMediaOptions + val maxWidth = galleryPickerMediaOptions?.maxWidth ?: 0 + val maxHeight = galleryPickerMediaOptions?.maxHeight ?: 0 + return GalleryPickerCallbackData( + callback, + maxWidth, + maxHeight, + ) + } + + override fun launchActivityResult(mediaOptions: MediaOptions?) { + pickerLauncherHolder.value?.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + + class GalleryPickerCallbackData( + override val callback: (Result) -> Unit, + val maxWidth: Int, + val maxHeight: Int, + ) : CallbackData + + class GalleryPickerMediaOptions( + val maxWidth: Int, + val maxHeight: Int, + ) : MediaOptions + + companion object { + private const val PICK_GALLERY_IMAGE_KEY = "PickGalleryImageKey" + } +} diff --git a/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/ImagePickerDelegate.kt b/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/ImagePickerDelegate.kt new file mode 100644 index 0000000..11af7d7 --- /dev/null +++ b/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/ImagePickerDelegate.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2024 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.icerock.moko.media.picker + +import android.content.Context +import android.net.Uri +import dev.icerock.moko.media.BitmapUtils + +internal abstract class ImagePickerDelegate, I, D> : PickerDelegate() { + + @Suppress("ReturnCount") + protected fun processResult( + context: Context, + callback: (Result) -> Unit, + uri: Uri, + maxImageWidth: Int = DEFAULT_MAX_IMAGE_WIDTH, + maxImageHeight: Int = DEFAULT_MAX_IMAGE_HEIGHT, + ) { + 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)) + } +} 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 6916b57..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 @@ -4,8 +4,7 @@ package dev.icerock.moko.media.picker -import androidx.fragment.app.FragmentManager -import androidx.lifecycle.Lifecycle +import androidx.activity.ComponentActivity import dev.icerock.moko.media.Bitmap import dev.icerock.moko.media.FileMedia import dev.icerock.moko.media.Media @@ -19,20 +18,14 @@ actual interface MediaPickerController { actual suspend fun pickMedia(): Media actual suspend fun pickFiles(): FileMedia - fun bind(lifecycle: Lifecycle, fragmentManager: FragmentManager) + fun bind(activity: ComponentActivity) 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 5f24936..2ce81ca 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,41 +4,58 @@ package dev.icerock.moko.media.picker -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager +import android.app.Activity +import android.net.Uri +import android.os.Environment +import androidx.activity.ComponentActivity +import androidx.core.content.FileProvider import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.OnLifecycleEvent import dev.icerock.moko.media.Bitmap 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 kotlin.coroutines.suspendCoroutine internal class MediaPickerControllerImpl( override val permissionsController: PermissionsController, - private val mediaPickerFragmentTag: String, - private val imagePickerFragmentTag: String, - private val filePickerFragmentTag: String ) : MediaPickerController { - var fragmentManager: FragmentManager? = null - override fun bind(lifecycle: Lifecycle, fragmentManager: FragmentManager) { - permissionsController.bind(lifecycle, fragmentManager) + private val activityHolder = MutableStateFlow(null) - this.fragmentManager = fragmentManager + private var photoFilePath: String? = null - val observer = object : LifecycleObserver { + private val galleryPickerDelegate = GalleryPickerDelegate() + private val cameraPickerDelegate = CameraPickerDelegate() + private val mediaPickerDelegate = MediaPickerDelegate() + private val filePickerDelegate = FilePickerDelegate() - @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) - fun onDestroyed(source: LifecycleOwner) { - this@MediaPickerControllerImpl.fragmentManager = null - source.lifecycle.removeObserver(this) + override fun bind(activity: ComponentActivity) { + this.activityHolder.value = activity + permissionsController.bind(activity) + + galleryPickerDelegate.bind(activity) + cameraPickerDelegate.bind(activity) + mediaPickerDelegate.bind(activity) + filePickerDelegate.bind(activity) + + val observer = object : LifecycleEventObserver { + + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (event == Lifecycle.Event.ON_DESTROY) { + this@MediaPickerControllerImpl.activityHolder.value = null + source.lifecycle.removeObserver(this) + } } } - lifecycle.addObserver(observer) + activity.lifecycle.addObserver(observer) } override suspend fun pickImage(source: MediaSource): Bitmap { @@ -51,90 +68,97 @@ 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") + @Suppress("NewApi") 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 bitmap = suspendCoroutine { continuation -> val action: (Result) -> Unit = { continuation.resumeWith(it) } when (source) { - MediaSource.GALLERY -> imagePickerFragment.pickGalleryImage(action) - MediaSource.CAMERA -> imagePickerFragment.pickCameraImage(action) + MediaSource.GALLERY -> galleryPickerDelegate.pick( + callback = action, + mediaOptions = GalleryPickerDelegate.GalleryPickerMediaOptions( + maxWidth = maxWidth, + maxHeight = maxHeight, + ) + ) + + MediaSource.CAMERA -> cameraPickerDelegate.pick( + callback = action, + mediaOptions = CameraPickerDelegate.CameraPickerMediaOptions( + outputUri = outputUri, + maxWidth = maxWidth, + maxHeight = maxHeight, + ) + ) } } return Bitmap(bitmap) } - override suspend fun pickMedia(): Media { - val fragmentManager = - fragmentManager ?: error("can't pick image without active window") + 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 + ) + } + override suspend fun pickMedia(): Media { permissionsController.providePermission(Permission.GALLERY) - val currentFragment: Fragment? = fragmentManager.findFragmentByTag(mediaPickerFragmentTag) - val pickerFragment: MediaPickerFragment = if (currentFragment != null) { - currentFragment as MediaPickerFragment - } else { - MediaPickerFragment().apply { - fragmentManager - .beginTransaction() - .add(this, mediaPickerFragmentTag) - .commitNow() - } - } - return suspendCoroutine { continuation -> val action: (Result) -> Unit = { continuation.resumeWith(it) } - pickerFragment.pickMedia(action) + mediaPickerDelegate.pick(action) } } 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, filePickerFragmentTag) - .commitNow() - } - } - - val path = suspendCoroutine { continuation -> + val path = suspendCoroutine { continuation -> val action: (Result) -> Unit = { continuation.resumeWith(it) } - pickerFragment.pickFile(action) + filePickerDelegate.pick(action) } 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" + } } diff --git a/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerDelegate.kt b/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerDelegate.kt new file mode 100644 index 0000000..708f10b --- /dev/null +++ b/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/MediaPickerDelegate.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2024 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.icerock.moko.media.picker + +import android.content.Context +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import dev.icerock.moko.media.Media +import dev.icerock.moko.media.MediaFactory + +internal class MediaPickerDelegate : + PickerDelegate() { + + override fun registerActivityResult( + context: Context, + activityResultRegistry: ActivityResultRegistry + ): ActivityResultLauncher = activityResultRegistry.register( + PICK_MEDIA_KEY, + ActivityResultContracts.PickVisualMedia(), + ) { uri -> + val callbackData = callback ?: return@register + callback = null + + val callback = callbackData.callback + + if (uri == null) { + callback.invoke(Result.failure(CanceledException())) + return@register + } + + val result = kotlin.runCatching { + MediaFactory.create(context, uri) + } + callback.invoke(result) + } + + override fun createCallback( + callback: (Result) -> Unit, + mediaOptions: MediaOptions? + ): MediaPickerCallbackData = MediaPickerCallbackData(callback) + + override fun launchActivityResult(mediaOptions: MediaOptions?) { + pickerLauncherHolder.value?.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo) + ) + } + + class MediaPickerCallbackData( + override val callback: (Result) -> Unit, + ) : CallbackData + + companion object { + private const val PICK_MEDIA_KEY = "PickMediaKey" + } +} 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/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/PickerDelegate.kt b/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/PickerDelegate.kt new file mode 100644 index 0000000..06afe7b --- /dev/null +++ b/media/src/androidMain/kotlin/dev/icerock/moko/media/picker/PickerDelegate.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2024 IceRock MAG Inc. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.icerock.moko.media.picker + +import android.content.Context +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.ActivityResultRegistryOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * @param type of callback + * @param type of the input required to launch + */ +internal abstract class PickerDelegate, I, D> { + + protected var callback: C? = null + + protected val pickerLauncherHolder = + MutableStateFlow?>(null) + + fun bind(activity: ComponentActivity) { + val activityResultRegistryOwner = activity as ActivityResultRegistryOwner + val activityResultRegistry = activityResultRegistryOwner.activityResultRegistry + + pickerLauncherHolder.value = registerActivityResult( + context = activity, + activityResultRegistry = activityResultRegistry, + ) + + val observer = object : LifecycleEventObserver { + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (event == Lifecycle.Event.ON_DESTROY) { + pickerLauncherHolder.value = null + source.lifecycle.removeObserver(this) + } + } + } + activity.lifecycle.addObserver(observer) + } + + abstract fun registerActivityResult( + context: Context, + activityResultRegistry: ActivityResultRegistry, + ): ActivityResultLauncher + + fun pick( + callback: (Result) -> Unit, + mediaOptions: MediaOptions? = null, + ) { + this.callback?.let { + it.callback.invoke(Result.failure(IllegalStateException("Callback should be null"))) + this.callback = null + } + + this.callback = createCallback(callback, mediaOptions) + + launchActivityResult(mediaOptions) + } + + abstract fun createCallback( + callback: (Result) -> Unit, + mediaOptions: MediaOptions?, + ): C + + abstract fun launchActivityResult(mediaOptions: MediaOptions?) + + interface CallbackData { + val callback: (Result) -> Unit + } + + interface MediaOptions +} diff --git a/media/src/iosMain/kotlin/dev/icerock/moko/media/Bitmap.kt b/media/src/iosMain/kotlin/dev/icerock/moko/media/Bitmap.kt index e575ae4..91906a9 100755 --- a/media/src/iosMain/kotlin/dev/icerock/moko/media/Bitmap.kt +++ b/media/src/iosMain/kotlin/dev/icerock/moko/media/Bitmap.kt @@ -6,6 +6,7 @@ package dev.icerock.moko.media import kotlinx.cinterop.ByteVar import kotlinx.cinterop.CPointer +import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.get import kotlinx.cinterop.reinterpret import kotlinx.cinterop.useContents @@ -18,6 +19,7 @@ import platform.UIKit.UIGraphicsGetImageFromCurrentImageContext import platform.UIKit.UIImage import platform.UIKit.UIImageJPEGRepresentation +@OptIn(ExperimentalForeignApi::class) actual class Bitmap(val image: UIImage) { actual fun toByteArray(): ByteArray { @@ -34,7 +36,7 @@ actual class Bitmap(val image: UIImage) { val imageData = UIImageJPEGRepresentation(image, COMPRESSION_QUALITY) ?: throw IllegalArgumentException("image data is null") - return imageData.base64EncodedStringWithOptions(0) + return imageData.base64EncodedStringWithOptions(0u) } actual fun toBase64WithCompress(maxSize: Int): String { @@ -54,7 +56,7 @@ actual class Bitmap(val image: UIImage) { val imageData = UIImageJPEGRepresentation(newImage!!, COMPRESSION_QUALITY) ?: throw IllegalArgumentException("image data is null") - return imageData.base64EncodedStringWithOptions(0) + return imageData.base64EncodedStringWithOptions(0u) } private companion object { diff --git a/media/src/iosMain/kotlin/dev/icerock/moko/media/FileMediaExt.kt b/media/src/iosMain/kotlin/dev/icerock/moko/media/FileMediaExt.kt index 0df3f7c..4a0d58a 100644 --- a/media/src/iosMain/kotlin/dev/icerock/moko/media/FileMediaExt.kt +++ b/media/src/iosMain/kotlin/dev/icerock/moko/media/FileMediaExt.kt @@ -6,6 +6,7 @@ package dev.icerock.moko.media import kotlinx.cinterop.ByteVar import kotlinx.cinterop.CPointer +import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.get import kotlinx.cinterop.reinterpret import platform.Foundation.NSData @@ -18,6 +19,7 @@ fun FileMedia.toNSData(): NSData { return NSData.dataWithContentsOfURL(url) ?: throw IllegalArgumentException("invalid file data") } +@OptIn(ExperimentalForeignApi::class) actual fun FileMedia.toByteArray(): ByteArray { val data = toNSData() val bytes = data.bytes ?: throw IllegalArgumentException("file bytes is null") diff --git a/media/src/iosMain/kotlin/dev/icerock/moko/media/picker/ImagePickerDelegateToContinuation.kt b/media/src/iosMain/kotlin/dev/icerock/moko/media/picker/ImagePickerDelegateToContinuation.kt index e3ee900..1ba6aca 100644 --- a/media/src/iosMain/kotlin/dev/icerock/moko/media/picker/ImagePickerDelegateToContinuation.kt +++ b/media/src/iosMain/kotlin/dev/icerock/moko/media/picker/ImagePickerDelegateToContinuation.kt @@ -8,6 +8,7 @@ import dev.icerock.moko.media.Bitmap import dev.icerock.moko.media.Media import dev.icerock.moko.media.MediaType import dev.icerock.moko.media.picker.ios.MediaPickerController +import kotlinx.cinterop.ExperimentalForeignApi import platform.AVFoundation.AVAsset import platform.AVFoundation.AVAssetImageGenerator import platform.AVFoundation.AVURLAsset @@ -91,6 +92,7 @@ internal class ImagePickerDelegateToContinuation constructor( } // Стоит сделать асинхронно и придумать что делать с ошибкой + @OptIn(ExperimentalForeignApi::class) private fun fetchThumbnail(videoAsset: AVAsset): UIImage { val imageGenerator = AVAssetImageGenerator( asset = videoAsset diff --git a/media/src/iosMain/kotlin/dev/icerock/moko/media/picker/ios/MediaPickerController.kt b/media/src/iosMain/kotlin/dev/icerock/moko/media/picker/ios/MediaPickerController.kt index a739899..2d135c5 100644 --- a/media/src/iosMain/kotlin/dev/icerock/moko/media/picker/ios/MediaPickerController.kt +++ b/media/src/iosMain/kotlin/dev/icerock/moko/media/picker/ios/MediaPickerController.kt @@ -15,6 +15,7 @@ import dev.icerock.moko.media.picker.ImagePickerDelegateToContinuation import dev.icerock.moko.media.picker.MediaSource import dev.icerock.moko.permissions.Permission import dev.icerock.moko.permissions.PermissionsController +import kotlinx.cinterop.ExperimentalForeignApi import platform.CoreServices.kUTTypeData import platform.CoreServices.kUTTypeImage import platform.CoreServices.kUTTypeMovie @@ -155,6 +156,7 @@ class MediaPickerController( return media } + @OptIn(ExperimentalForeignApi::class) internal companion object { val kVideoType = CFBridgingRelease(kUTTypeVideo) as String val kMovieType = CFBridgingRelease(kUTTypeMovie) as String diff --git a/media/src/iosMain/kotlin/dev/icerock/moko/media/player/MediaPlayerController.kt b/media/src/iosMain/kotlin/dev/icerock/moko/media/player/MediaPlayerController.kt index 817cfc3..08af197 100755 --- a/media/src/iosMain/kotlin/dev/icerock/moko/media/player/MediaPlayerController.kt +++ b/media/src/iosMain/kotlin/dev/icerock/moko/media/player/MediaPlayerController.kt @@ -4,6 +4,7 @@ package dev.icerock.moko.media.player +import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.cValue import platform.AVFoundation.AVPlayer import platform.AVFoundation.AVPlayerItemDidPlayToEndTimeNotification @@ -72,6 +73,7 @@ actual class MediaPlayerController { player?.pause() } + @OptIn(ExperimentalForeignApi::class) actual fun stop() { player?.run { pause() diff --git a/sample/android-app/src/main/AndroidManifest.xml b/sample/android-app/src/main/AndroidManifest.xml index d5d4985..54c1f05 100755 --- a/sample/android-app/src/main/AndroidManifest.xml +++ b/sample/android-app/src/main/AndroidManifest.xml @@ -5,6 +5,10 @@ + + + + + +