Glimpse: Add support for ACTION_GET_CONTENT and ACTION_PICK

Change-Id: Id7de265904e62418d9d3aada8b6946ee19519ca0
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b87a298..61b6202 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -77,6 +77,28 @@
 
         </activity>
 
+        <activity
+            android:name=".PickerActivity"
+            android:configChanges="orientation|screenLayout|screenSize|smallestScreenSize|keyboardHidden"
+            android:exported="true">
+
+            <intent-filter>
+                <action android:name="android.intent.action.GET_CONTENT" />
+                <action android:name="android.intent.action.PICK" />
+
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.OPENABLE" />
+
+                <data android:mimeType="image/*" />
+
+                <data android:mimeType="video/*" />
+
+                <data android:mimeType="vnd.android.cursor.dir/image" />
+                <data android:mimeType="vnd.android.cursor.dir/video" />
+            </intent-filter>
+
+        </activity>
+
     </application>
 
 </manifest>
diff --git a/app/src/main/java/org/lineageos/glimpse/PickerActivity.kt b/app/src/main/java/org/lineageos/glimpse/PickerActivity.kt
new file mode 100644
index 0000000..8ee5e6f
--- /dev/null
+++ b/app/src/main/java/org/lineageos/glimpse/PickerActivity.kt
@@ -0,0 +1,84 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.glimpse
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.MenuItem
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.WindowCompat
+import com.google.android.material.appbar.AppBarLayout
+import com.google.android.material.appbar.MaterialToolbar
+import com.google.android.material.shape.MaterialShapeDrawable
+import org.lineageos.glimpse.models.MediaType
+import org.lineageos.glimpse.utils.PickerUtils
+
+class PickerActivity : AppCompatActivity(R.layout.activity_picker) {
+    // Views
+    private val appBarLayout by lazy { findViewById<AppBarLayout>(R.id.appBarLayout)!! }
+    private val toolbar by lazy { findViewById<MaterialToolbar>(R.id.toolbar)!! }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        // Setup edge-to-edge
+        WindowCompat.setDecorFitsSystemWindows(window, false)
+
+        appBarLayout.statusBarForeground = MaterialShapeDrawable.createWithElevationOverlay(this)
+
+        setSupportActionBar(toolbar)
+        supportActionBar?.apply {
+            setDisplayHomeAsUpEnabled(true)
+            setDisplayShowHomeEnabled(true)
+        }
+
+        // Parse intent
+        if (intent.action !in supportedIntentActions) {
+            Toast.makeText(
+                this, R.string.intent_action_not_supported, Toast.LENGTH_SHORT
+            ).show()
+            finish()
+            return
+        }
+
+        val mimeType = PickerUtils.translateMimeType(intent.type) ?: run {
+            Toast.makeText(
+                this, R.string.intent_media_type_not_supported, Toast.LENGTH_SHORT
+            ).show()
+            finish()
+            return
+        }
+
+        val mediaType = MediaType.fromMimeType(mimeType)
+
+        toolbar.setTitle(
+            when (mediaType) {
+                MediaType.IMAGE -> R.string.pick_a_photo
+                MediaType.VIDEO -> R.string.pick_a_video
+                else -> R.string.pick_a_media
+            }
+        )
+    }
+
+    override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
+        android.R.id.home -> {
+            onBackPressedDispatcher.onBackPressed()
+            true
+        }
+
+        else -> {
+            super.onOptionsItemSelected(item)
+        }
+    }
+
+    companion object {
+        private val supportedIntentActions = listOf(
+            Intent.ACTION_GET_CONTENT,
+            Intent.ACTION_PICK,
+        )
+    }
+}
diff --git a/app/src/main/java/org/lineageos/glimpse/flow/AlbumFlow.kt b/app/src/main/java/org/lineageos/glimpse/flow/AlbumFlow.kt
index 9eac117..fc63752 100644
--- a/app/src/main/java/org/lineageos/glimpse/flow/AlbumFlow.kt
+++ b/app/src/main/java/org/lineageos/glimpse/flow/AlbumFlow.kt
@@ -18,26 +18,31 @@
 import org.lineageos.glimpse.ext.queryFlow
 import org.lineageos.glimpse.models.Album
 import org.lineageos.glimpse.models.MediaStoreMedia
+import org.lineageos.glimpse.models.MediaType
 import org.lineageos.glimpse.query.*
 import org.lineageos.glimpse.utils.MediaStoreBuckets
+import org.lineageos.glimpse.utils.PickerUtils
 
 class AlbumFlow(
     private val context: Context,
     private val bucketId: Int,
+    private val mimeType: String? = null,
 ) : QueryFlow<Album>() {
     override fun flowCursor(): Flow<Cursor?> {
         val uri = MediaQuery.MediaStoreFileUri
         val projection = MediaQuery.AlbumsProjection
-        val image =
-            MediaStore.Files.FileColumns.MEDIA_TYPE eq MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE
-        val video =
-            MediaStore.Files.FileColumns.MEDIA_TYPE eq MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO
-        val imageOrVideo = when (bucketId) {
-            MediaStoreBuckets.MEDIA_STORE_BUCKET_PHOTOS.id -> image
+        val imageOrVideo = PickerUtils.mediaTypeFromGenericMimeType(mimeType)?.let {
+            when (it) {
+                MediaType.IMAGE -> MediaQuery.Selection.image
 
-            MediaStoreBuckets.MEDIA_STORE_BUCKET_VIDEOS.id -> video
+                MediaType.VIDEO -> MediaQuery.Selection.video
+            }
+        } ?: when (bucketId) {
+            MediaStoreBuckets.MEDIA_STORE_BUCKET_PHOTOS.id -> MediaQuery.Selection.image
 
-            else -> image or video
+            MediaStoreBuckets.MEDIA_STORE_BUCKET_VIDEOS.id -> MediaQuery.Selection.video
+
+            else -> MediaQuery.Selection.imageOrVideo
         }
         val albumFilter = when (bucketId) {
             MediaStoreBuckets.MEDIA_STORE_BUCKET_FAVORITES.id -> MediaStore.Files.FileColumns.IS_FAVORITE eq 1
@@ -51,15 +56,31 @@
 
             else -> MediaStore.Files.FileColumns.BUCKET_ID eq Query.ARG
         }
-        val selection = albumFilter?.let { imageOrVideo and it } ?: imageOrVideo
-        val selectionArgs = bucketId.takeIf {
-            MediaStoreBuckets.values().none { bucket -> it == bucket.id }
-        }?.let { arrayOf(it.toString()) }
+        val rawMimeType = mimeType?.takeIf { PickerUtils.isMimeTypeNotGeneric(it) }
+        val mimeTypeQuery = rawMimeType?.let {
+            MediaStore.Files.FileColumns.MIME_TYPE eq Query.ARG
+        }
+
+        // Join all the non-null queries
+        val selection = listOfNotNull(
+            imageOrVideo,
+            albumFilter,
+            mimeTypeQuery,
+        ).join(Query::and)
+
+        val selectionArgs = listOfNotNull(
+            bucketId.takeIf {
+                MediaStoreBuckets.values().none { bucket -> it == bucket.id }
+            }?.toString(),
+            rawMimeType,
+        ).toTypedArray()
+
         val sortOrder = "${MediaStore.Files.FileColumns.DATE_ADDED} DESC"
+
         val queryArgs = Bundle().apply {
             putAll(
                 bundleOf(
-                    ContentResolver.QUERY_ARG_SQL_SELECTION to selection.build(),
+                    ContentResolver.QUERY_ARG_SQL_SELECTION to selection?.build(),
                     ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS to selectionArgs,
                     ContentResolver.QUERY_ARG_SQL_SORT_ORDER to sortOrder,
                     ContentResolver.QUERY_ARG_SQL_LIMIT to 1,
diff --git a/app/src/main/java/org/lineageos/glimpse/flow/AlbumsFlow.kt b/app/src/main/java/org/lineageos/glimpse/flow/AlbumsFlow.kt
index 015dd21..b181c63 100644
--- a/app/src/main/java/org/lineageos/glimpse/flow/AlbumsFlow.kt
+++ b/app/src/main/java/org/lineageos/glimpse/flow/AlbumsFlow.kt
@@ -17,20 +17,46 @@
 import org.lineageos.glimpse.ext.queryFlow
 import org.lineageos.glimpse.models.Album
 import org.lineageos.glimpse.models.MediaStoreMedia
+import org.lineageos.glimpse.models.MediaType
 import org.lineageos.glimpse.query.*
+import org.lineageos.glimpse.utils.PickerUtils
 
-class AlbumsFlow(private val context: Context) : QueryFlow<Album>() {
+class AlbumsFlow(
+    private val context: Context,
+    private val mimeType: String? = null,
+) : QueryFlow<Album>() {
     override fun flowCursor(): Flow<Cursor?> {
         val uri = MediaQuery.MediaStoreFileUri
         val projection = MediaQuery.AlbumsProjection
-        val imageOrVideo =
-            (MediaStore.Files.FileColumns.MEDIA_TYPE eq MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) or
-                    (MediaStore.Files.FileColumns.MEDIA_TYPE eq MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO)
+        val imageOrVideo = PickerUtils.mediaTypeFromGenericMimeType(mimeType)?.let {
+            when (it) {
+                MediaType.IMAGE -> MediaQuery.Selection.image
+
+                MediaType.VIDEO -> MediaQuery.Selection.video
+            }
+        } ?: MediaQuery.Selection.imageOrVideo
+        val rawMimeType = mimeType?.takeIf { PickerUtils.isMimeTypeNotGeneric(it) }
+        val mimeTypeQuery = rawMimeType?.let {
+            MediaStore.Files.FileColumns.MIME_TYPE eq Query.ARG
+        }
+
+        // Join all the non-null queries
+        val selection = listOfNotNull(
+            mimeTypeQuery,
+            imageOrVideo,
+        ).join(Query::and)
+
+        val selectionArgs = listOfNotNull(
+            rawMimeType,
+        ).toTypedArray()
+
         val sortOrder = MediaStore.Files.FileColumns.DATE_ADDED + " DESC"
+
         val queryArgs = Bundle().apply {
             putAll(
                 bundleOf(
-                    ContentResolver.QUERY_ARG_SQL_SELECTION to imageOrVideo.build(),
+                    ContentResolver.QUERY_ARG_SQL_SELECTION to selection?.build(),
+                    ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS to selectionArgs,
                     ContentResolver.QUERY_ARG_SQL_SORT_ORDER to sortOrder,
                 )
             )
diff --git a/app/src/main/java/org/lineageos/glimpse/flow/MediaFlow.kt b/app/src/main/java/org/lineageos/glimpse/flow/MediaFlow.kt
index abf0952..549b072 100644
--- a/app/src/main/java/org/lineageos/glimpse/flow/MediaFlow.kt
+++ b/app/src/main/java/org/lineageos/glimpse/flow/MediaFlow.kt
@@ -14,10 +14,16 @@
 import kotlinx.coroutines.flow.Flow
 import org.lineageos.glimpse.ext.*
 import org.lineageos.glimpse.models.MediaStoreMedia
+import org.lineageos.glimpse.models.MediaType
 import org.lineageos.glimpse.query.*
 import org.lineageos.glimpse.utils.MediaStoreBuckets
+import org.lineageos.glimpse.utils.PickerUtils
 
-class MediaFlow(private val context: Context, private val bucketId: Int) : QueryFlow<MediaStoreMedia>() {
+class MediaFlow(
+    private val context: Context,
+    private val bucketId: Int,
+    private val mimeType: String? = null,
+) : QueryFlow<MediaStoreMedia>() {
     init {
         assert(bucketId != MediaStoreBuckets.MEDIA_STORE_BUCKET_PLACEHOLDER.id) {
             "MEDIA_STORE_BUCKET_PLACEHOLDER found"
@@ -27,16 +33,18 @@
     override fun flowCursor(): Flow<Cursor?> {
         val uri = MediaQuery.MediaStoreFileUri
         val projection = MediaQuery.MediaProjection
-        val image =
-            MediaStore.Files.FileColumns.MEDIA_TYPE eq MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE
-        val video =
-            MediaStore.Files.FileColumns.MEDIA_TYPE eq MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO
-        val imageOrVideo = when (bucketId) {
-            MediaStoreBuckets.MEDIA_STORE_BUCKET_PHOTOS.id -> image
+        val imageOrVideo = PickerUtils.mediaTypeFromGenericMimeType(mimeType)?.let {
+            when (it) {
+                MediaType.IMAGE -> MediaQuery.Selection.image
 
-            MediaStoreBuckets.MEDIA_STORE_BUCKET_VIDEOS.id -> video
+                MediaType.VIDEO -> MediaQuery.Selection.video
+            }
+        } ?: when (bucketId) {
+            MediaStoreBuckets.MEDIA_STORE_BUCKET_PHOTOS.id -> MediaQuery.Selection.image
 
-            else -> image or video
+            MediaStoreBuckets.MEDIA_STORE_BUCKET_VIDEOS.id -> MediaQuery.Selection.video
+
+            else -> MediaQuery.Selection.imageOrVideo
         }
         val albumFilter = when (bucketId) {
             MediaStoreBuckets.MEDIA_STORE_BUCKET_FAVORITES.id -> MediaStore.Files.FileColumns.IS_FAVORITE eq 1
@@ -50,20 +58,36 @@
 
             else -> MediaStore.Files.FileColumns.BUCKET_ID eq Query.ARG
         }
-        val selection = albumFilter?.let { imageOrVideo and it } ?: imageOrVideo
-        val selectionArgs = bucketId.takeIf {
-            MediaStoreBuckets.values().none { bucket -> it == bucket.id }
-        }?.let { arrayOf(it.toString()) }
+        val rawMimeType = mimeType?.takeIf { PickerUtils.isMimeTypeNotGeneric(it) }
+        val mimeTypeQuery = rawMimeType?.let {
+            MediaStore.Files.FileColumns.MIME_TYPE eq Query.ARG
+        }
+
+        // Join all the non-null queries
+        val selection = listOfNotNull(
+            imageOrVideo,
+            albumFilter,
+            mimeTypeQuery,
+        ).join(Query::and)
+
+        val selectionArgs = listOfNotNull(
+            bucketId.takeIf {
+                MediaStoreBuckets.values().none { bucket -> it == bucket.id }
+            }?.toString(),
+            rawMimeType,
+        ).toTypedArray()
+
         val sortOrder = when (bucketId) {
             MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id ->
                 "${MediaStore.Files.FileColumns.DATE_EXPIRES} DESC"
 
             else -> "${MediaStore.Files.FileColumns.DATE_ADDED} DESC"
         }
+
         val queryArgs = Bundle().apply {
             putAll(
                 bundleOf(
-                    ContentResolver.QUERY_ARG_SQL_SELECTION to selection.build(),
+                    ContentResolver.QUERY_ARG_SQL_SELECTION to selection?.build(),
                     ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS to selectionArgs,
                     ContentResolver.QUERY_ARG_SQL_SORT_ORDER to sortOrder,
                 )
diff --git a/app/src/main/java/org/lineageos/glimpse/fragments/AlbumsFragment.kt b/app/src/main/java/org/lineageos/glimpse/fragments/AlbumsFragment.kt
index 2d629fd..0fd8ead 100644
--- a/app/src/main/java/org/lineageos/glimpse/fragments/AlbumsFragment.kt
+++ b/app/src/main/java/org/lineageos/glimpse/fragments/AlbumsFragment.kt
@@ -42,7 +42,9 @@
  */
 class AlbumsFragment : Fragment() {
     // View models
-    private val albumsViewModel: AlbumsViewModel by viewModels()
+    private val albumsViewModel: AlbumsViewModel by viewModels {
+        AlbumsViewModel.factory(requireActivity().application)
+    }
 
     // Views
     private val albumsRecyclerView by getViewProperty<RecyclerView>(R.id.albumsRecyclerView)
diff --git a/app/src/main/java/org/lineageos/glimpse/fragments/picker/AlbumSelectorFragment.kt b/app/src/main/java/org/lineageos/glimpse/fragments/picker/AlbumSelectorFragment.kt
new file mode 100644
index 0000000..a780f96
--- /dev/null
+++ b/app/src/main/java/org/lineageos/glimpse/fragments/picker/AlbumSelectorFragment.kt
@@ -0,0 +1,97 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.glimpse.fragments.picker
+
+import android.os.Bundle
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updateLayoutParams
+import androidx.core.view.updatePadding
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.navigation.fragment.findNavController
+import androidx.recyclerview.widget.RecyclerView
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import org.lineageos.glimpse.R
+import org.lineageos.glimpse.ext.getViewProperty
+import org.lineageos.glimpse.recyclerview.AlbumThumbnailAdapter
+import org.lineageos.glimpse.recyclerview.AlbumThumbnailLayoutManager
+import org.lineageos.glimpse.utils.PermissionsGatedCallback
+import org.lineageos.glimpse.utils.PickerUtils
+import org.lineageos.glimpse.viewmodels.AlbumsViewModel
+import org.lineageos.glimpse.viewmodels.QueryResult
+
+class AlbumSelectorFragment : Fragment(R.layout.fragment_picker_album_selector) {
+    // View models
+    private val model: AlbumsViewModel by viewModels {
+        AlbumsViewModel.factory(
+            requireActivity().application,
+            mimeType,
+        )
+    }
+
+    // Views
+    private val albumsRecyclerView by getViewProperty<RecyclerView>(R.id.albumsRecyclerView)
+
+    // Intent data
+    private val mimeType by lazy { PickerUtils.translateMimeType(activity?.intent?.type) }
+
+    // Recyclerview
+    private val albumThumbnailAdapter by lazy {
+        AlbumThumbnailAdapter { album ->
+            findNavController().navigate(
+                R.id.action_pickerAlbumSelectorFragment_to_pickerMediaSelectorFragment,
+                MediaSelectorFragment.createBundle(album.id)
+            )
+        }
+    }
+
+    // Permissions
+    private val permissionsGatedCallback = PermissionsGatedCallback(this) {
+        viewLifecycleOwner.lifecycleScope.launch {
+            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+                model.albums.collectLatest {
+                    when (it) {
+                        is QueryResult.Data -> {
+                            albumThumbnailAdapter.submitList(it.values)
+                        }
+
+                        is QueryResult.Empty -> Unit
+                    }
+                }
+            }
+        }
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+
+        val context = requireContext()
+
+        albumsRecyclerView.layoutManager = AlbumThumbnailLayoutManager(context)
+        albumsRecyclerView.adapter = albumThumbnailAdapter
+
+        ViewCompat.setOnApplyWindowInsetsListener(view) { _, windowInsets ->
+            val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+
+            albumsRecyclerView.updateLayoutParams<ViewGroup.MarginLayoutParams> {
+                leftMargin = insets.left
+                rightMargin = insets.right
+            }
+            albumsRecyclerView.updatePadding(bottom = insets.bottom)
+
+            windowInsets
+        }
+
+        permissionsGatedCallback.runAfterPermissionsCheck()
+    }
+}
diff --git a/app/src/main/java/org/lineageos/glimpse/fragments/picker/MediaSelectorFragment.kt b/app/src/main/java/org/lineageos/glimpse/fragments/picker/MediaSelectorFragment.kt
new file mode 100644
index 0000000..83bb550
--- /dev/null
+++ b/app/src/main/java/org/lineageos/glimpse/fragments/picker/MediaSelectorFragment.kt
@@ -0,0 +1,312 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.glimpse.fragments.picker
+
+import android.app.Activity
+import android.content.ClipData
+import android.content.Intent
+import android.os.Bundle
+import android.view.ActionMode
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import android.widget.LinearLayout
+import androidx.core.os.bundleOf
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.isVisible
+import androidx.core.view.updateLayoutParams
+import androidx.core.view.updatePadding
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.Observer
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.recyclerview.selection.MutableSelection
+import androidx.recyclerview.selection.SelectionPredicates
+import androidx.recyclerview.selection.SelectionTracker
+import androidx.recyclerview.selection.StorageStrategy
+import androidx.recyclerview.widget.RecyclerView
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import org.lineageos.glimpse.R
+import org.lineageos.glimpse.ext.getViewProperty
+import org.lineageos.glimpse.models.Album
+import org.lineageos.glimpse.models.MediaStoreMedia
+import org.lineageos.glimpse.recyclerview.ThumbnailAdapter
+import org.lineageos.glimpse.recyclerview.ThumbnailItemDetailsLookup
+import org.lineageos.glimpse.recyclerview.ThumbnailLayoutManager
+import org.lineageos.glimpse.utils.PermissionsGatedCallback
+import org.lineageos.glimpse.utils.PickerUtils
+import org.lineageos.glimpse.viewmodels.AlbumViewerViewModel
+import org.lineageos.glimpse.viewmodels.QueryResult
+
+/**
+ * A fragment showing a list of media from a specific album with thumbnails.
+ * Use the [MediaSelectorFragment.newInstance] factory method to
+ * create an instance of this fragment.
+ */
+class MediaSelectorFragment : Fragment(R.layout.fragment_picker_media_selector) {
+    // View models
+    private val model: AlbumViewerViewModel by viewModels {
+        bucketId?.let {
+            AlbumViewerViewModel.factory(requireActivity().application, it, mimeType)
+        } ?: AlbumViewerViewModel.factory(requireActivity().application, mimeType = mimeType)
+    }
+
+    // Views
+    private val mediasRecyclerView by getViewProperty<RecyclerView>(R.id.mediasRecyclerView)
+    private val noMediaLinearLayout by getViewProperty<LinearLayout>(R.id.noMediaLinearLayout)
+
+    // Arguments
+    private val bucketId by lazy { arguments?.getInt(KEY_BUCKET_ID) }
+
+    // Intent data
+    private val mimeType by lazy { PickerUtils.translateMimeType(activity?.intent?.type) }
+
+    // Recyclerview
+    private val thumbnailAdapter by lazy {
+        ThumbnailAdapter(model) { media ->
+            selectionTracker?.select(media)
+        }
+    }
+
+    // Selection
+    private var selectionTracker: SelectionTracker<MediaStoreMedia>? = null
+
+    private val selectionTrackerObserver =
+        object : SelectionTracker.SelectionObserver<MediaStoreMedia>() {
+            override fun onSelectionChanged() {
+                super.onSelectionChanged()
+
+                updateSelection()
+            }
+
+            override fun onSelectionRefresh() {
+                super.onSelectionRefresh()
+
+                updateSelection()
+            }
+
+            override fun onSelectionRestored() {
+                super.onSelectionRestored()
+
+                updateSelection()
+            }
+        }
+
+    private var actionMode: ActionMode? = null
+
+    private val actionModeCallback = object : ActionMode.Callback {
+        override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
+            requireActivity().menuInflater.inflate(
+                R.menu.picker_media_selector_action_bar,
+                menu
+            )
+            return true
+        }
+
+        override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?) = false
+
+        override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?) =
+            MutableSelection<MediaStoreMedia>().apply {
+                selectionTracker?.let {
+                    it.copySelection(this)
+                    it.clearSelection()
+                }
+            }.toList().toTypedArray().takeUnless {
+                it.isEmpty()
+            }?.let { selection ->
+                when (item?.itemId) {
+                    R.id.done -> {
+                        sendResult(*selection)
+                        true
+                    }
+
+                    else -> false
+                }
+            } ?: false
+
+        override fun onDestroyActionMode(mode: ActionMode?) {
+            selectionTracker?.clearSelection()
+        }
+    }
+
+    private val inSelectionModeObserver = Observer { inSelectionMode: Boolean ->
+        if (inSelectionMode) {
+            startSelectionMode()
+        } else {
+            endSelectionMode()
+        }
+    }
+
+    // Permissions
+    private val permissionsGatedCallback = PermissionsGatedCallback(this) {
+        viewLifecycleOwner.lifecycleScope.launch {
+            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+                model.mediaWithHeaders.collectLatest {
+                    when (it) {
+                        is QueryResult.Data -> {
+                            thumbnailAdapter.submitList(it.values)
+
+                            val noMedia = it.values.isEmpty()
+                            mediasRecyclerView.isVisible = !noMedia
+                            noMediaLinearLayout.isVisible = noMedia
+                        }
+
+                        is QueryResult.Empty -> Unit
+                    }
+                }
+            }
+        }
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+
+        val context = requireContext()
+
+        mediasRecyclerView.layoutManager = ThumbnailLayoutManager(
+            context, thumbnailAdapter
+        )
+        mediasRecyclerView.adapter = thumbnailAdapter
+
+        ViewCompat.setOnApplyWindowInsetsListener(view) { _, windowInsets ->
+            val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+
+            mediasRecyclerView.updateLayoutParams<ViewGroup.MarginLayoutParams> {
+                leftMargin = insets.left
+                rightMargin = insets.right
+            }
+            mediasRecyclerView.updatePadding(bottom = insets.bottom)
+
+            windowInsets
+        }
+
+        selectionTracker = SelectionTracker.Builder(
+            "thumbnail-${model.bucketId}",
+            mediasRecyclerView,
+            thumbnailAdapter.itemKeyProvider,
+            ThumbnailItemDetailsLookup(mediasRecyclerView),
+            StorageStrategy.createParcelableStorage(MediaStoreMedia::class.java),
+        ).withSelectionPredicate(
+            when (allowMultipleSelection) {
+                true -> SelectionPredicates.createSelectAnything()
+                false -> SelectionPredicates.createSelectSingleAnything()
+            }
+        ).build().also {
+            thumbnailAdapter.selectionTracker = it
+            it.addObserver(selectionTrackerObserver)
+        }
+
+        model.inSelectionMode.observe(viewLifecycleOwner, inSelectionModeObserver)
+
+        permissionsGatedCallback.runAfterPermissionsCheck()
+    }
+
+    override fun onDestroyView() {
+        super.onDestroyView()
+
+        // Clear action mode if still active
+        endSelectionMode()
+    }
+
+    private fun updateSelection() {
+        model.inSelectionMode.value = selectionTracker?.hasSelection() == true
+
+        selectionTracker?.selection?.count()?.takeIf { it > 0 }?.let {
+            startSelectionMode()?.apply {
+                title = getString(R.string.thumbnail_selection_count, it)
+            }
+        }
+    }
+
+    private fun startSelectionMode() = actionMode ?: activity?.startActionMode(
+        actionModeCallback
+    ).also {
+        actionMode = it
+    }
+
+    private fun endSelectionMode() {
+        actionMode?.finish()
+        actionMode = null
+    }
+
+    /**
+     * Set the activity result and close the activity.
+     * @param medias The selected medias
+     */
+    private fun sendResult(vararg medias: MediaStoreMedia) {
+        activity?.let {
+            it.setResult(
+                Activity.RESULT_OK,
+                Intent().apply {
+                    if (allowMultipleSelection) {
+                        clipData = ClipData.newUri(
+                            it.contentResolver, "", medias.first().uri
+                        ).also { clipData ->
+                            for (media in 1 until medias.size) {
+                                clipData.addItem(
+                                    ClipData.Item(medias[media].uri)
+                                )
+                            }
+                        }
+                    } else {
+                        require(medias.size == 1) {
+                            "More than one media provided when only one was requested"
+                        }
+
+                        data = medias.first().uri
+                    }
+
+                    flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
+                }
+            )
+
+            it.finish()
+        }
+    }
+
+    /**
+     * Whether we can provide multiple items or only one.
+     * @see Intent.EXTRA_ALLOW_MULTIPLE
+     */
+    private val allowMultipleSelection: Boolean
+        get() = activity?.intent?.extras?.getBoolean(
+            Intent.EXTRA_ALLOW_MULTIPLE, false
+        ) ?: false
+
+    companion object {
+        private const val KEY_BUCKET_ID = "bucket_id"
+
+        /**
+         * Create a [Bundle] to use as the arguments for this fragment.
+         * @param bucketId The [Album] to display's bucket ID, if null, reels will be shown
+         */
+        fun createBundle(
+            bucketId: Int? = null,
+        ) = bundleOf(
+            KEY_BUCKET_ID to bucketId,
+        )
+
+        /**
+         * Use this factory method to create a new instance of
+         * this fragment using the provided parameters.
+         *
+         * @see createBundle
+         * @return A new instance of fragment [MediaSelectorFragment].
+         */
+        fun newInstance(
+            bucketId: Int,
+        ) = MediaSelectorFragment().apply {
+            arguments = createBundle(
+                bucketId,
+            )
+        }
+    }
+}
diff --git a/app/src/main/java/org/lineageos/glimpse/query/MediaQuery.kt b/app/src/main/java/org/lineageos/glimpse/query/MediaQuery.kt
index d7920cb..310ff25 100644
--- a/app/src/main/java/org/lineageos/glimpse/query/MediaQuery.kt
+++ b/app/src/main/java/org/lineageos/glimpse/query/MediaQuery.kt
@@ -27,4 +27,12 @@
     val AlbumsProjection = arrayOf(
         MediaStore.Files.FileColumns.BUCKET_DISPLAY_NAME,
     ) + MediaProjection
+
+    object Selection {
+        val image =
+            MediaStore.Files.FileColumns.MEDIA_TYPE eq MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE
+        val video =
+            MediaStore.Files.FileColumns.MEDIA_TYPE eq MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO
+        val imageOrVideo = image or video
+    }
 }
diff --git a/app/src/main/java/org/lineageos/glimpse/query/Query.kt b/app/src/main/java/org/lineageos/glimpse/query/Query.kt
index 679a81d..1a18508 100644
--- a/app/src/main/java/org/lineageos/glimpse/query/Query.kt
+++ b/app/src/main/java/org/lineageos/glimpse/query/Query.kt
@@ -33,3 +33,11 @@
 infix fun Query.and(other: Query) = Query(And(this.root, other.root))
 infix fun Query.eq(other: Query) = Query(Eq(this.root, other.root))
 infix fun <T> Column.eq(other: T) = Query(Literal(this)) eq Query(Literal(other))
+
+fun Iterable<Query>.join(
+    func: Query.(other: Query) -> Query,
+): Query? = fold(null) { sum: Query?, item ->
+    sum?.let {
+        it.func(item)
+    } ?: item
+}
diff --git a/app/src/main/java/org/lineageos/glimpse/repository/MediaRepository.kt b/app/src/main/java/org/lineageos/glimpse/repository/MediaRepository.kt
index 24fddd7..634c678 100644
--- a/app/src/main/java/org/lineageos/glimpse/repository/MediaRepository.kt
+++ b/app/src/main/java/org/lineageos/glimpse/repository/MediaRepository.kt
@@ -12,10 +12,37 @@
 
 @Suppress("Unused")
 object MediaRepository {
-    fun media(context: Context, bucketId: Int) = MediaFlow(context, bucketId).flowData()
-    fun mediaCursor(context: Context, bucketId: Int) = MediaFlow(context, bucketId).flowCursor()
-    fun album(context: Context, bucketId: Int) = AlbumFlow(context, bucketId).flowData()
-    fun albumCursor(context: Context, bucketId: Int) = AlbumFlow(context, bucketId).flowCursor()
-    fun albums(context: Context) = AlbumsFlow(context).flowData()
-    fun albumsCursor(context: Context) = AlbumsFlow(context).flowCursor()
+    fun media(
+        context: Context,
+        bucketId: Int,
+        mimeType: String? = null,
+    ) = MediaFlow(context, bucketId, mimeType).flowData()
+
+    fun mediaCursor(
+        context: Context,
+        bucketId: Int,
+        mimeType: String? = null,
+    ) = MediaFlow(context, bucketId, mimeType).flowCursor()
+
+    fun album(
+        context: Context,
+        bucketId: Int,
+        mimeType: String? = null,
+    ) = AlbumFlow(context, bucketId, mimeType).flowData()
+
+    fun albumCursor(
+        context: Context,
+        bucketId: Int,
+        mimeType: String? = null,
+    ) = AlbumFlow(context, bucketId, mimeType).flowCursor()
+
+    fun albums(
+        context: Context,
+        mimeType: String? = null,
+    ) = AlbumsFlow(context, mimeType).flowData()
+
+    fun albumsCursor(
+        context: Context,
+        mimeType: String? = null,
+    ) = AlbumsFlow(context, mimeType).flowCursor()
 }
diff --git a/app/src/main/java/org/lineageos/glimpse/utils/PickerUtils.kt b/app/src/main/java/org/lineageos/glimpse/utils/PickerUtils.kt
new file mode 100644
index 0000000..2a116b6
--- /dev/null
+++ b/app/src/main/java/org/lineageos/glimpse/utils/PickerUtils.kt
@@ -0,0 +1,60 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.glimpse.utils
+
+import android.content.Intent
+import android.provider.MediaStore
+import org.lineageos.glimpse.models.MediaType
+
+object PickerUtils {
+    private const val MIME_TYPE_IMAGE_ANY = "image/*"
+    private const val MIME_TYPE_VIDEO_ANY = "video/*"
+    private const val MIME_TYPE_ANY = "*/*"
+
+    /**
+     * Fix-up a MIME type coming from an [Intent].
+     * @param mimeType A MIME type coming from an [Intent]
+     * @return A simpler MIME type, null if not supported
+     */
+    fun translateMimeType(mimeType: String?) = (mimeType ?: MIME_TYPE_ANY).let {
+        when (it) {
+            MediaStore.Images.Media.CONTENT_TYPE -> MIME_TYPE_IMAGE_ANY
+            MediaStore.Video.Media.CONTENT_TYPE -> MIME_TYPE_VIDEO_ANY
+            else -> when {
+                it == MIME_TYPE_ANY
+                        || it.startsWith("image/")
+                        || it.startsWith("video/") -> it
+
+                else -> null
+            }
+        }
+    }
+
+    /**
+     * Get a [MediaType] only if the provided MIME type is a generic one, else return null.
+     * @param mimeType A MIME type
+     * @return [MediaType] if the MIME type is generic, else null
+     *         (assume MIME type represent either a specific file format or any)
+     */
+    fun mediaTypeFromGenericMimeType(mimeType: String?) = when (mimeType) {
+        MIME_TYPE_IMAGE_ANY -> MediaType.IMAGE
+        MIME_TYPE_VIDEO_ANY -> MediaType.VIDEO
+        else -> null
+    }
+
+    /**
+     * Given a MIME type, check if it specifies both a content type and a sub type.
+     * @param mimeType A MIME type
+     * @return true if it specifies both a file category and a specific type
+     */
+    fun isMimeTypeNotGeneric(mimeType: String?) = mimeType?.let {
+        it !in listOf(
+            MIME_TYPE_IMAGE_ANY,
+            MIME_TYPE_VIDEO_ANY,
+            MIME_TYPE_ANY,
+        )
+    } ?: false
+}
diff --git a/app/src/main/java/org/lineageos/glimpse/viewmodels/AlbumViewerViewModel.kt b/app/src/main/java/org/lineageos/glimpse/viewmodels/AlbumViewerViewModel.kt
index 7b18dea..ee04fb9 100644
--- a/app/src/main/java/org/lineageos/glimpse/viewmodels/AlbumViewerViewModel.kt
+++ b/app/src/main/java/org/lineageos/glimpse/viewmodels/AlbumViewerViewModel.kt
@@ -30,9 +30,10 @@
 class AlbumViewerViewModel(
     application: Application,
     val bucketId: Int,
+    mimeType: String? = null,
     addHeaders: Boolean,
 ) : AndroidViewModel(application) {
-    val mediaWithHeaders = MediaRepository.media(context, bucketId).flowOn(
+    val mediaWithHeaders = MediaRepository.media(context, bucketId, mimeType).flowOn(
         Dispatchers.IO
     ).map { medias ->
         val data = when (addHeaders) {
@@ -73,7 +74,9 @@
         initialValue = QueryResult.Empty(),
     )
 
-    val album = MediaRepository.album(context, bucketId).flowOn(Dispatchers.IO).mapNotNull {
+    val album = MediaRepository.album(
+        context, bucketId, mimeType
+    ).flowOn(Dispatchers.IO).mapNotNull {
         it.firstOrNull()
     }.stateIn(
         viewModelScope,
@@ -99,12 +102,14 @@
         fun factory(
             application: Application,
             bucketId: Int = MediaStoreBuckets.MEDIA_STORE_BUCKET_REELS.id,
+            mimeType: String? = null,
             showHeaders: Boolean = bucketId != MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id,
         ) = viewModelFactory {
             initializer {
                 AlbumViewerViewModel(
                     application = application,
                     bucketId = bucketId,
+                    mimeType = mimeType,
                     addHeaders = showHeaders,
                 )
             }
diff --git a/app/src/main/java/org/lineageos/glimpse/viewmodels/AlbumsViewModel.kt b/app/src/main/java/org/lineageos/glimpse/viewmodels/AlbumsViewModel.kt
index 2af90de..70d53a3 100644
--- a/app/src/main/java/org/lineageos/glimpse/viewmodels/AlbumsViewModel.kt
+++ b/app/src/main/java/org/lineageos/glimpse/viewmodels/AlbumsViewModel.kt
@@ -8,6 +8,8 @@
 import android.app.Application
 import androidx.lifecycle.AndroidViewModel
 import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.flowOn
@@ -18,12 +20,27 @@
 
 class AlbumsViewModel(
     application: Application,
+    val mimeType: String? = null,
 ) : AndroidViewModel(application) {
-    val albums = MediaRepository.albums(context).flowOn(Dispatchers.IO).map {
+    val albums = MediaRepository.albums(context, mimeType).flowOn(Dispatchers.IO).map {
         QueryResult.Data(it)
     }.stateIn(
         viewModelScope,
         started = SharingStarted.WhileSubscribed(),
         initialValue = QueryResult.Empty()
     )
+
+    companion object {
+        fun factory(
+            application: Application,
+            mimeType: String? = null,
+        ) = viewModelFactory {
+            initializer {
+                AlbumsViewModel(
+                    application = application,
+                    mimeType = mimeType,
+                )
+            }
+        }
+    }
 }
diff --git a/app/src/main/java/org/lineageos/glimpse/viewmodels/MediaViewerViewModel.kt b/app/src/main/java/org/lineageos/glimpse/viewmodels/MediaViewerViewModel.kt
index b565a3f..229a130 100644
--- a/app/src/main/java/org/lineageos/glimpse/viewmodels/MediaViewerViewModel.kt
+++ b/app/src/main/java/org/lineageos/glimpse/viewmodels/MediaViewerViewModel.kt
@@ -26,8 +26,9 @@
     application: Application,
     savedStateHandle: SavedStateHandle,
     bucketId: Int,
+    mimeType: String? = null,
 ) : AndroidViewModel(application) {
-    val media = MediaRepository.media(context, bucketId).flowOn(Dispatchers.IO).map {
+    val media = MediaRepository.media(context, bucketId, mimeType).flowOn(Dispatchers.IO).map {
         QueryResult.Data(it)
     }.stateIn(
         viewModelScope,
@@ -48,13 +49,15 @@
 
         fun factory(
             application: Application,
-            bucketId: Int = MediaStoreBuckets.MEDIA_STORE_BUCKET_REELS.id
+            bucketId: Int = MediaStoreBuckets.MEDIA_STORE_BUCKET_REELS.id,
+            mimeType: String? = null,
         ) = viewModelFactory {
             initializer {
                 MediaViewerViewModel(
                     application = application,
                     savedStateHandle = createSavedStateHandle(),
                     bucketId = bucketId,
+                    mimeType = mimeType,
                 )
             }
         }
diff --git a/app/src/main/res/drawable/ic_done.xml b/app/src/main/res/drawable/ic_done.xml
new file mode 100644
index 0000000..8933b31
--- /dev/null
+++ b/app/src/main/res/drawable/ic_done.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     SPDX-FileCopyrightText: Material Design Authors / Google LLC
+     SPDX-License-Identifier: Apache-2.0
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:tint="#000000"
+    android:viewportWidth="960"
+    android:viewportHeight="960">
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M382,720L154,492L211,435L382,606L749,239L806,296L382,720Z" />
+</vector>
diff --git a/app/src/main/res/layout/activity_picker.xml b/app/src/main/res/layout/activity_picker.xml
new file mode 100644
index 0000000..c4ceedc
--- /dev/null
+++ b/app/src/main/res/layout/activity_picker.xml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     SPDX-FileCopyrightText: 2024 The LineageOS Project
+     SPDX-License-Identifier: Apache-2.0
+-->
+<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    xmlns:tools="http://schemas.android.com/tools"
+    tools:context=".PickerActivity">
+
+    <com.google.android.material.appbar.AppBarLayout
+        android:id="@+id/appBarLayout"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:fitsSystemWindows="true"
+        app:liftOnScrollTargetViewId="@+id/navHostFragment">
+
+        <com.google.android.material.appbar.MaterialToolbar
+            android:id="@+id/toolbar"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            app:layout_scrollFlags="scroll|enterAlways|snap"
+            app:title="@string/pick_a_media" />
+
+    </com.google.android.material.appbar.AppBarLayout>
+
+    <androidx.fragment.app.FragmentContainerView
+        android:id="@+id/navHostFragment"
+        android:name="androidx.navigation.fragment.NavHostFragment"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        app:defaultNavHost="true"
+        app:layout_behavior="@string/appbar_scrolling_view_behavior"
+        app:navGraph="@navigation/picker_navigation" />
+
+</androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/app/src/main/res/layout/fragment_picker_album_selector.xml b/app/src/main/res/layout/fragment_picker_album_selector.xml
new file mode 100644
index 0000000..e220a61
--- /dev/null
+++ b/app/src/main/res/layout/fragment_picker_album_selector.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     SPDX-FileCopyrightText: 2024 The LineageOS Project
+     SPDX-License-Identifier: Apache-2.0
+-->
+<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/albumsRecyclerView"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:clipToPadding="false"
+    android:scrollbars="vertical"
+    tools:context=".fragments.picker.AlbumSelectorFragment" />
diff --git a/app/src/main/res/layout/fragment_picker_media_selector.xml b/app/src/main/res/layout/fragment_picker_media_selector.xml
new file mode 100644
index 0000000..763a70e
--- /dev/null
+++ b/app/src/main/res/layout/fragment_picker_media_selector.xml
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     SPDX-FileCopyrightText: 2024 The LineageOS Project
+     SPDX-License-Identifier: Apache-2.0
+-->
+<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context=".fragments.picker.MediaSelectorFragment">
+
+    <LinearLayout
+        android:id="@+id/noMediaLinearLayout"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:gravity="center"
+        android:orientation="vertical"
+        android:visibility="gone">
+
+        <ImageView
+            android:layout_width="72dp"
+            android:layout_height="72dp"
+            android:contentDescription="@string/no_media"
+            android:padding="12dp"
+            android:src="@drawable/ic_no_photography"
+            app:tint="?attr/colorOnBackground" />
+
+        <TextView
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="@string/no_media"
+            android:textAppearance="?attr/textAppearanceBodyLarge" />
+
+    </LinearLayout>
+
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/mediasRecyclerView"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:clipToPadding="false"
+        android:scrollbars="vertical" />
+
+</androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/app/src/main/res/menu/picker_media_selector_action_bar.xml b/app/src/main/res/menu/picker_media_selector_action_bar.xml
new file mode 100644
index 0000000..bfb4ab7
--- /dev/null
+++ b/app/src/main/res/menu/picker_media_selector_action_bar.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     SPDX-FileCopyrightText: 2024 The LineageOS Project
+     SPDX-License-Identifier: Apache-2.0
+-->
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+
+    <item
+        style="@style/Theme.Glimpse.TopAppBarOption"
+        android:id="@+id/done"
+        android:icon="@drawable/ic_done"
+        android:title="@string/picker_done"
+        android:contentDescription="@string/picker_done"
+        app:showAsAction="always" />
+
+</menu>
diff --git a/app/src/main/res/navigation/picker_navigation.xml b/app/src/main/res/navigation/picker_navigation.xml
new file mode 100644
index 0000000..b62a797
--- /dev/null
+++ b/app/src/main/res/navigation/picker_navigation.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     SPDX-FileCopyrightText: 2024 The LineageOS Project
+     SPDX-License-Identifier: Apache-2.0
+-->
+<navigation xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/get_content_navigation"
+    app:startDestination="@id/pickerAlbumSelectorFragment">
+
+    <fragment
+        android:id="@+id/pickerAlbumSelectorFragment"
+        android:name="org.lineageos.glimpse.fragments.picker.AlbumSelectorFragment"
+        tools:layout="@layout/fragment_picker_album_selector">
+
+        <action
+            android:id="@+id/action_pickerAlbumSelectorFragment_to_pickerMediaSelectorFragment"
+            app:destination="@id/pickerMediaSelectorFragment"
+            app:enterAnim="@anim/nav_default_enter_anim"
+            app:exitAnim="@anim/nav_default_exit_anim"
+            app:popEnterAnim="@anim/nav_default_pop_enter_anim"
+            app:popExitAnim="@anim/nav_default_pop_exit_anim" />
+
+    </fragment>
+
+    <fragment
+        android:id="@+id/pickerMediaSelectorFragment"
+        android:name="org.lineageos.glimpse.fragments.picker.MediaSelectorFragment"
+        tools:layout="@layout/fragment_picker_media_selector" />
+
+</navigation>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 24cdad9..f37c6fd 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -105,4 +105,10 @@
 
     <!-- Selection -->
     <string name="thumbnail_selection_count">%d selected</string>
+
+    <!-- Picker -->
+    <string name="pick_a_photo">Pick a photo</string>
+    <string name="pick_a_video">Pick a video</string>
+    <string name="pick_a_media">Pick a media</string>
+    <string name="picker_done">Done</string>
 </resources>