Add PickVisualMedia & PickMultipleVisualMedia ActivityResultContracts

Add contracts to support the photo picker when available and rely on ACTION_OPEN_DOCUMENT otherwise

Test: Added integration tests
Change-Id: Idab9a0dc5410e4c34ebc2ca9d8af727f5acf4216
diff --git a/activity/activity/src/main/java/androidx/activity/result/contract/ActivityResultContracts.kt b/activity/activity/src/main/java/androidx/activity/result/contract/ActivityResultContracts.kt
index 9af89ed..52d69d3 100644
--- a/activity/activity/src/main/java/androidx/activity/result/contract/ActivityResultContracts.kt
+++ b/activity/activity/src/main/java/androidx/activity/result/contract/ActivityResultContracts.kt
@@ -22,6 +22,7 @@
 import android.graphics.Bitmap
 import android.net.Uri
 import android.os.Build
+import android.os.ext.SdkExtensions.getExtensionVersion
 import android.provider.ContactsContract
 import android.provider.DocumentsContract
 import android.provider.MediaStore
@@ -597,4 +598,158 @@
             return intent.takeIf { resultCode == Activity.RESULT_OK }?.data
         }
     }
+
+    /**
+     * An [ActivityResultContract] to use the photo picker through [MediaStore.ACTION_PICK_IMAGES]
+     * when available, and else rely on ACTION_OPEN_DOCUMENT.
+     *
+     * The input is the mime type to filter by, e.g. `PickVisualMedia.ImageOnly`,
+     * `PickVisualMedia.ImageAndVideo`, `PickVisualMedia.SingleMimeType("image/gif")`.
+     *
+     * The output is a `Uri` when the user has selected a media or `null` when the user hasn't
+     * selected any item. Keep in mind that `Uri` returned by the photo picker isn't writable.
+     *
+     * This can be extended to override [createIntent] if you wish to pass additional
+     * extras to the Intent created by `super.createIntent()`.
+     */
+    open class PickVisualMedia : ActivityResultContract<PickVisualMedia.VisualMediaType, Uri?>() {
+        companion object {
+            @JvmStatic
+            fun isPhotoPickerAvailable(): Boolean {
+                return Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ||
+                    getExtensionVersion(Build.VERSION_CODES.R) >= 2
+            }
+
+            @JvmStatic
+            fun getVisualMimeType(input: VisualMediaType): String? {
+                return when (input) {
+                    is ImageOnly -> "image/*"
+                    is VideoOnly -> "video/*"
+                    is SingleMimeType -> input.mimeType
+                    is ImageAndVideo -> null
+                }
+            }
+        }
+
+        sealed interface VisualMediaType
+        object ImageOnly : VisualMediaType
+        object VideoOnly : VisualMediaType
+        object ImageAndVideo : VisualMediaType
+        class SingleMimeType(val mimeType: String) : VisualMediaType
+
+        @CallSuper
+        override fun createIntent(context: Context, input: VisualMediaType): Intent {
+            // Check if Photo Picker is available on the device
+            return if (isPhotoPickerAvailable()) {
+                Intent(MediaStore.ACTION_PICK_IMAGES).apply {
+                    type = getVisualMimeType(input)
+                }
+            } else {
+                // For older devices running KitKat and higher and devices running Android 12
+                // and 13 without the SDK extension that includes the Photo Picker, rely on the
+                // ACTION_OPEN_DOCUMENT intent
+                Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
+                    type = getVisualMimeType(input)
+
+                    if (type == null) {
+                        // ACTION_OPEN_DOCUMENT requires to set this parameter when launching the
+                        // intent with multiple mime types
+                        type = "*/*"
+                        putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*"))
+                    }
+                }
+            }
+        }
+
+        final override fun getSynchronousResult(
+            context: Context,
+            input: VisualMediaType
+        ): SynchronousResult<Uri?>? = null
+
+        final override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
+            return intent.takeIf { resultCode == Activity.RESULT_OK }?.data
+        }
+    }
+
+    /**
+     * An [ActivityResultContract] to use the Photo Picker through [MediaStore.ACTION_PICK_IMAGES]
+     * when available, and else rely on ACTION_OPEN_DOCUMENT.
+     *
+     * The constructor accepts one parameter `maxItems` to limit the number of selectable items when
+     * using the photo picker to return. Keep in mind that this parameter isn't supported on devices
+     * when the photo picker isn't available.
+     *
+     * The input is the mime type to filter by, e.g. `PickVisualMedia.ImageOnly`,
+     * `PickVisualMedia.ImageAndVideo`, `PickVisualMedia.SingleMimeType("image/gif")`.
+     *
+     * The output is a list `Uri` of the selected media. It can be empty if the user hasn't selected
+     * any items. Keep in mind that `Uri` returned by the photo picker aren't writable.
+     *
+     * This can be extended to override [createIntent] if you wish to pass additional
+     * extras to the Intent created by `super.createIntent()`.
+     */
+    @RequiresApi(19)
+    open class PickMultipleVisualMedia(
+        private val maxItems: Int = getMaxItems()
+    ) : ActivityResultContract<PickVisualMedia.VisualMediaType, List<@JvmSuppressWildcards Uri>>() {
+
+        init {
+            require(maxItems > 1) {
+                "Max items must be higher than 1"
+            }
+        }
+
+        @CallSuper
+        override fun createIntent(
+            context: Context,
+            input: PickVisualMedia.VisualMediaType
+        ): Intent {
+            // Check to see if the photo picker is available
+            return if (PickVisualMedia.isPhotoPickerAvailable()) {
+                Intent(MediaStore.ACTION_PICK_IMAGES).apply {
+                    type = PickVisualMedia.getVisualMimeType(input)
+
+                    require(maxItems <= MediaStore.getPickImagesMaxLimit()) {
+                        "Max items must be less or equals MediaStore.getPickImagesMaxLimit()"
+                    }
+
+                    putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, maxItems)
+                }
+            } else {
+                // For older devices running KitKat and higher and devices running Android 12
+                // and 13 without the SDK extension that includes the Photo Picker, rely on the
+                // ACTION_OPEN_DOCUMENT intent
+                Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
+                    type = PickVisualMedia.getVisualMimeType(input)
+                    putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
+
+                    if (type == null) {
+                        // ACTION_OPEN_DOCUMENT requires to set this parameter when launching the
+                        // intent with multiple mime types
+                        type = "*/*"
+                        putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*"))
+                    }
+                }
+            }
+        }
+
+        final override fun getSynchronousResult(
+            context: Context,
+            input: PickVisualMedia.VisualMediaType
+        ): SynchronousResult<List<@JvmSuppressWildcards Uri>>? = null
+
+        final override fun parseResult(resultCode: Int, intent: Intent?): List<Uri> {
+            return intent.takeIf {
+                resultCode == Activity.RESULT_OK
+            }?.getClipDataUris() ?: emptyList()
+        }
+
+        internal companion object {
+            internal fun getMaxItems() = if (PickVisualMedia.isPhotoPickerAvailable()) {
+                MediaStore.getPickImagesMaxLimit()
+            } else {
+                Integer.MAX_VALUE
+            }
+        }
+    }
 }
diff --git a/activity/integration-tests/testapp/src/main/java/androidx/activity/integration/testapp/MainActivity.kt b/activity/integration-tests/testapp/src/main/java/androidx/activity/integration/testapp/MainActivity.kt
index 11780b3..a68119b 100644
--- a/activity/integration-tests/testapp/src/main/java/androidx/activity/integration/testapp/MainActivity.kt
+++ b/activity/integration-tests/testapp/src/main/java/androidx/activity/integration/testapp/MainActivity.kt
@@ -41,9 +41,12 @@
 import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
 import androidx.activity.result.contract.ActivityResultContracts.GetContent
 import androidx.activity.result.contract.ActivityResultContracts.OpenMultipleDocuments
+import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia
+import androidx.activity.result.contract.ActivityResultContracts.PickMultipleVisualMedia
 import androidx.activity.result.contract.ActivityResultContracts.RequestPermission
 import androidx.activity.result.contract.ActivityResultContracts.TakePicture
 import androidx.activity.result.contract.ActivityResultContracts.TakePicturePreview
+import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia.VisualMediaType
 import androidx.activity.result.launch
 import androidx.activity.result.registerForActivityResult
 import androidx.core.content.FileProvider
@@ -77,6 +80,10 @@
         toast("Got image: $uri")
     }
 
+    lateinit var pickVisualMedia: ActivityResultLauncher<VisualMediaType>
+
+    lateinit var pickMultipleVisualMedia: ActivityResultLauncher<VisualMediaType>
+
     lateinit var createDocument: ActivityResultLauncher<String>
 
     lateinit var openDocuments: ActivityResultLauncher<Array<String>>
@@ -92,6 +99,17 @@
         super.onCreate(savedInstanceState)
 
         if (android.os.Build.VERSION.SDK_INT >= 19) {
+            pickVisualMedia = registerForActivityResult(PickVisualMedia()) { uri ->
+                toast("Got image: $uri")
+            }
+            pickMultipleVisualMedia =
+                registerForActivityResult(PickMultipleVisualMedia(5)) { uris ->
+                    var media = ""
+                    uris.forEach {
+                        media += "uri: $it \n"
+                    }
+                    toast("Got media files: $media")
+                }
             createDocument = registerForActivityResult(CreateDocument("image/png")) { uri ->
                 toast("Created document: $uri")
             }
@@ -124,10 +142,19 @@
                     val uri = FileProvider.getUriForFile(this@MainActivity, packageName, file)
                     captureVideo.launch(uri)
                 }
-                button("Pick an image") {
+                button("Pick an image (w/ GET_CONTENT)") {
                     getContent.launch("image/*")
                 }
                 if (android.os.Build.VERSION.SDK_INT >= 19) {
+                    button("Pick an image (w/ photo picker)") {
+                        pickVisualMedia.launch(PickVisualMedia.ImageOnly)
+                    }
+                    button("Pick a GIF (w/ photo picker)") {
+                        pickVisualMedia.launch(PickVisualMedia.SingleMimeType("image/gif"))
+                    }
+                    button("Pick 5 visual media max (w/ photo picker)") {
+                        pickMultipleVisualMedia.launch(PickVisualMedia.ImageAndVideo)
+                    }
                     button("Create document") {
                         createDocument.launch("Temp")
                     }