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>