Glimpse: Implement thumbnail selection and actions

Change-Id: I4f368017df49bfcb66fe581b9b5813693665bddc
diff --git a/app/Android.bp b/app/Android.bp
index 8ab7d17..af385d8 100644
--- a/app/Android.bp
+++ b/app/Android.bp
@@ -31,6 +31,8 @@
         "androidx.navigation_navigation-fragment-ktx",
         "androidx.navigation_navigation-ui-ktx",
         "Glimpse_com.squareup.okhttp3_okhttp",
+        "androidx.recyclerview_recyclerview",
+        "androidx.recyclerview_recyclerview-selection",
         "Glimpse_io.coil-kt_coil",
         "Glimpse_io.coil-kt_coil-gif",
         "Glimpse_io.coil-kt_coil-video",
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index a059cc7..9e5aa4a 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -98,6 +98,10 @@
     // okhttp
     implementation("com.squareup.okhttp3:okhttp:4.10.0")
 
+    // Recyclerview
+    implementation("androidx.recyclerview:recyclerview:1.3.2")
+    implementation("androidx.recyclerview:recyclerview-selection:1.1.0")
+
     // Coil
     implementation("io.coil-kt:coil:2.2.2")
     implementation("io.coil-kt:coil-gif:2.2.2")
diff --git a/app/src/main/java/org/lineageos/glimpse/fragments/AlbumViewerFragment.kt b/app/src/main/java/org/lineageos/glimpse/fragments/AlbumViewerFragment.kt
index 98f7722..c1a7787 100644
--- a/app/src/main/java/org/lineageos/glimpse/fragments/AlbumViewerFragment.kt
+++ b/app/src/main/java/org/lineageos/glimpse/fragments/AlbumViewerFragment.kt
@@ -5,13 +5,18 @@
 
 package org.lineageos.glimpse.fragments
 
+import android.app.Activity
 import android.content.Intent
 import android.content.res.Configuration
 import android.os.Bundle
 import android.provider.MediaStore
+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.activity.result.contract.ActivityResultContracts
 import androidx.core.os.bundleOf
 import androidx.core.view.ViewCompat
 import androidx.core.view.WindowInsetsCompat
@@ -21,23 +26,32 @@
 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.navigation.fragment.findNavController
 import androidx.navigation.ui.AppBarConfiguration
 import androidx.navigation.ui.setupWithNavController
+import androidx.recyclerview.selection.SelectionPredicates
+import androidx.recyclerview.selection.SelectionTracker
+import androidx.recyclerview.selection.StorageStrategy
 import androidx.recyclerview.widget.RecyclerView
 import com.google.android.material.appbar.AppBarLayout
 import com.google.android.material.appbar.MaterialToolbar
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
 import com.google.android.material.shape.MaterialShapeDrawable
+import com.google.android.material.snackbar.Snackbar
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.launch
 import org.lineageos.glimpse.R
 import org.lineageos.glimpse.ViewActivity
 import org.lineageos.glimpse.ext.*
 import org.lineageos.glimpse.models.Album
+import org.lineageos.glimpse.models.Media
 import org.lineageos.glimpse.recyclerview.ThumbnailAdapter
+import org.lineageos.glimpse.recyclerview.ThumbnailItemDetailsLookup
 import org.lineageos.glimpse.recyclerview.ThumbnailLayoutManager
+import org.lineageos.glimpse.utils.MediaStoreBuckets
 import org.lineageos.glimpse.utils.PermissionsGatedCallback
 import org.lineageos.glimpse.viewmodels.AlbumViewerViewModel
 import org.lineageos.glimpse.viewmodels.QueryResult.Data
@@ -85,7 +99,7 @@
 
     // MediaStore
     private val thumbnailAdapter by lazy {
-        ThumbnailAdapter { media ->
+        ThumbnailAdapter(model) { media ->
             startActivity(
                 Intent(requireContext(), ViewActivity::class.java).apply {
                     action = MediaStore.ACTION_REVIEW
@@ -96,6 +110,207 @@
         }
     }
 
+    // Selection
+    private var selectionTracker: SelectionTracker<Media>? = null
+
+    private val selectionTrackerObserver = object : SelectionTracker.SelectionObserver<Media>() {
+        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(
+                when (album?.id) {
+                    MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> R.menu.album_action_bar_trash
+                    else -> R.menu.album_action_bar
+                },
+                menu
+            )
+            return true
+        }
+
+        override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?) = false
+
+        override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?) =
+            selectionTracker?.selection?.toList()?.toTypedArray()?.takeUnless {
+                it.isEmpty()
+            }?.let { selection ->
+                val count = selection.count()
+
+                when (item?.itemId) {
+                    R.id.deleteForever -> {
+                        MaterialAlertDialogBuilder(requireContext())
+                            .setTitle(R.string.file_action_delete_forever)
+                            .setMessage(
+                                resources.getQuantityString(
+                                    R.plurals.delete_file_forever_confirm_message, count, count
+                                )
+                            ).setPositiveButton(android.R.string.ok) { _, _ ->
+                                deleteForeverContract.launch(
+                                    requireContext().contentResolver.createDeleteRequest(
+                                        *selection.map { media ->
+                                            media.externalContentUri
+                                        }.toTypedArray()
+                                    )
+                                )
+                            }
+                            .setNegativeButton(android.R.string.cancel) { _, _ ->
+                                // Do nothing
+                            }
+                            .show()
+
+                        true
+                    }
+
+                    R.id.restoreFromTrash -> {
+                        MaterialAlertDialogBuilder(requireContext())
+                            .setTitle(R.string.file_action_restore_from_trash)
+                            .setMessage(
+                                resources.getQuantityString(
+                                    R.plurals.restore_file_from_trash_confirm_message, count, count
+                                )
+                            ).setPositiveButton(android.R.string.ok) { _, _ ->
+                                trashMedias(false, *selection)
+                            }
+                            .setNegativeButton(android.R.string.cancel) { _, _ ->
+                                // Do nothing
+                            }
+                            .show()
+
+                        true
+                    }
+
+                    R.id.share -> {
+                        requireActivity().startActivity(buildShareIntent(*selection))
+
+                        true
+                    }
+
+                    R.id.moveToTrash -> {
+                        MaterialAlertDialogBuilder(requireContext())
+                            .setTitle(R.string.file_action_move_to_trash)
+                            .setMessage(
+                                resources.getQuantityString(
+                                    R.plurals.move_file_to_trash_confirm_message, count, count
+                                )
+                            ).setPositiveButton(android.R.string.ok) { _, _ ->
+                                trashMedias(true, *selection)
+                            }
+                            .setNegativeButton(android.R.string.cancel) { _, _ ->
+                                // Do nothing
+                            }
+                            .show()
+
+                        true
+                    }
+
+                    else -> false
+                }
+            } ?: false
+
+        override fun onDestroyActionMode(mode: ActionMode?) {
+            selectionTracker?.clearSelection()
+        }
+    }
+
+    private val inSelectionModeObserver = Observer { inSelectionMode: Boolean ->
+        if (inSelectionMode) {
+            startSelectionMode()
+        } else {
+            endSelectionMode()
+        }
+    }
+
+    // Contracts
+    private var lastProcessedSelection: Array<out Media>? = null
+    private var undoTrashSnackbar: Snackbar? = null
+
+    private val deleteForeverContract =
+        registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) {
+            val count = lastProcessedSelection?.count() ?: 1
+
+            Snackbar.make(
+                requireView(),
+                resources.getQuantityString(
+                    if (it.resultCode == Activity.RESULT_CANCELED) {
+                        R.plurals.delete_file_forever_unsuccessful
+                    } else {
+                        R.plurals.delete_file_forever_successful
+                    },
+                    count, count
+                ),
+                Snackbar.LENGTH_LONG,
+            ).show()
+
+            lastProcessedSelection = null
+        }
+
+    private val trashContract =
+        registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) {
+            val succeeded = it.resultCode != Activity.RESULT_CANCELED
+            val count = lastProcessedSelection?.count() ?: 1
+
+            Snackbar.make(
+                requireView(),
+                resources.getQuantityString(
+                    if (succeeded) {
+                        R.plurals.move_file_to_trash_successful
+                    } else {
+                        R.plurals.move_file_to_trash_unsuccessful
+                    },
+                    count, count
+                ),
+                Snackbar.LENGTH_LONG,
+            ).apply {
+                lastProcessedSelection?.takeIf { succeeded }?.let { trashedMedias ->
+                    setAction(R.string.move_file_to_trash_undo) {
+                        trashMedias(false, *trashedMedias)
+                    }
+                }
+                undoTrashSnackbar = this
+            }.show()
+
+            lastProcessedSelection = null
+        }
+
+    private val restoreFromTrashContract =
+        registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) {
+            val count = lastProcessedSelection?.count() ?: 1
+
+            Snackbar.make(
+                requireView(),
+                resources.getQuantityString(
+                    if (it.resultCode == Activity.RESULT_CANCELED) {
+                        R.plurals.restore_file_from_trash_unsuccessful
+                    } else {
+                        R.plurals.restore_file_from_trash_successful
+                    },
+                    count, count
+                ),
+                Snackbar.LENGTH_LONG,
+            ).show()
+
+            lastProcessedSelection = null
+        }
+
     // Arguments
     private val album by lazy { arguments?.getParcelable(KEY_ALBUM, Album::class) }
 
@@ -131,9 +346,31 @@
             windowInsets
         }
 
+        selectionTracker = SelectionTracker.Builder(
+            "thumbnail-${model.bucketId}",
+            recyclerView,
+            thumbnailAdapter.itemKeyProvider,
+            ThumbnailItemDetailsLookup(recyclerView),
+            StorageStrategy.createParcelableStorage(Media::class.java),
+        ).withSelectionPredicate(
+            SelectionPredicates.createSelectAnything()
+        ).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()
+    }
+
     override fun onConfigurationChanged(newConfig: Configuration) {
         super.onConfigurationChanged(newConfig)
 
@@ -142,6 +379,42 @@
         )
     }
 
+    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 ?: toolbar.startActionMode(
+        actionModeCallback
+    ).also {
+        actionMode = it
+    }
+
+    private fun endSelectionMode() {
+        actionMode?.finish()
+        actionMode = null
+    }
+
+    private fun trashMedias(trash: Boolean, vararg medias: Media) {
+        lastProcessedSelection = medias
+
+        val contract = when (trash) {
+            true -> trashContract
+            false -> restoreFromTrashContract
+        }
+
+        contract.launch(
+            requireContext().contentResolver.createTrashRequest(
+                trash, *medias.map { it.externalContentUri }.toTypedArray()
+            )
+        )
+    }
+
     companion object {
         private const val KEY_ALBUM = "album"
 
diff --git a/app/src/main/java/org/lineageos/glimpse/recyclerview/ThumbnailAdapter.kt b/app/src/main/java/org/lineageos/glimpse/recyclerview/ThumbnailAdapter.kt
index 1b8b9f5..3893a47 100644
--- a/app/src/main/java/org/lineageos/glimpse/recyclerview/ThumbnailAdapter.kt
+++ b/app/src/main/java/org/lineageos/glimpse/recyclerview/ThumbnailAdapter.kt
@@ -12,6 +12,11 @@
 import android.widget.ImageView
 import android.widget.TextView
 import androidx.core.view.isVisible
+import androidx.lifecycle.Observer
+import androidx.lifecycle.findViewTreeLifecycleOwner
+import androidx.recyclerview.selection.ItemDetailsLookup
+import androidx.recyclerview.selection.ItemKeyProvider
+import androidx.recyclerview.selection.SelectionTracker
 import androidx.recyclerview.widget.DiffUtil
 import androidx.recyclerview.widget.ListAdapter
 import androidx.recyclerview.widget.RecyclerView
@@ -19,18 +24,34 @@
 import org.lineageos.glimpse.R
 import org.lineageos.glimpse.models.Media
 import org.lineageos.glimpse.models.MediaType
+import org.lineageos.glimpse.viewmodels.AlbumViewerViewModel
 import org.lineageos.glimpse.viewmodels.AlbumViewerViewModel.DataType
 import java.util.Date
+import kotlin.reflect.safeCast
 
 class ThumbnailAdapter(
+    private val model: AlbumViewerViewModel,
     private val onItemSelected: (media: Media) -> Unit,
 ) : ListAdapter<DataType, RecyclerView.ViewHolder>(DATA_TYPE_COMPARATOR) {
+    // We store a reverse lookup list for performance reasons
+    private var mediaToIndex: Map<Media, Int>? = null
+
+    var selectionTracker: SelectionTracker<Media>? = null
+
+    val itemKeyProvider = object : ItemKeyProvider<Media>(SCOPE_CACHED) {
+        override fun getKey(position: Int) = getItem(position).let {
+            DataType.Thumbnail::class.safeCast(it)?.media
+        }
+
+        override fun getPosition(key: Media) = mediaToIndex?.get(key) ?: -1
+    }
+
     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
         LayoutInflater.from(parent.context).let { layoutInflater ->
             when (viewType) {
                 ViewTypes.THUMBNAIL.ordinal -> ThumbnailViewHolder(
                     layoutInflater.inflate(R.layout.thumbnail_view, parent, false),
-                    onItemSelected
+                    model, onItemSelected
                 )
 
                 ViewTypes.DATE_HEADER.ordinal -> DateHeaderViewHolder(
@@ -45,7 +66,10 @@
         when (holder.itemViewType) {
             ViewTypes.THUMBNAIL.ordinal -> {
                 val thumbnailViewHolder = holder as ThumbnailViewHolder
-                thumbnailViewHolder.bind((getItem(position) as DataType.Thumbnail).media)
+                val media = (getItem(position) as DataType.Thumbnail).media
+                thumbnailViewHolder.bind(
+                    media, selectionTracker?.isSelected(media) == true,
+                )
             }
 
             ViewTypes.DATE_HEADER.ordinal -> {
@@ -55,6 +79,38 @@
         }
     }
 
+    override fun onCurrentListChanged(
+        previousList: MutableList<DataType>,
+        currentList: MutableList<DataType>
+    ) {
+        super.onCurrentListChanged(previousList, currentList)
+
+        // This gets randomly called with null as argument
+        if (currentList == null) {
+            return
+        }
+
+        val dataTypeToIndex = mutableMapOf<Media, Int>()
+        for (i in currentList.indices) {
+            DataType.Thumbnail::class.safeCast(currentList[i])?.let {
+                dataTypeToIndex[it.media] = i
+            }
+        }
+        this.mediaToIndex = dataTypeToIndex.toMap()
+    }
+
+    override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) {
+        super.onViewAttachedToWindow(holder)
+
+        ThumbnailViewHolder::class.safeCast(holder)?.onViewAttachedToWindow()
+    }
+
+    override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) {
+        super.onViewDetachedFromWindow(holder)
+
+        ThumbnailViewHolder::class.safeCast(holder)?.onViewDetachedToWindow()
+    }
+
     override fun getItemViewType(position: Int) = getItem(position).viewType
 
     companion object {
@@ -83,18 +139,43 @@
     }
 
     class ThumbnailViewHolder(
-        view: View,
+        private val view: View,
+        private val model: AlbumViewerViewModel,
         private val onItemSelected: (media: Media) -> Unit,
     ) : RecyclerView.ViewHolder(view) {
         // Views
+        private val selectionCheckedImageView =
+            itemView.findViewById<ImageView>(R.id.selectionCheckedImageView)
+        private val selectionScrimView = itemView.findViewById<View>(R.id.selectionScrimView)
         private val videoOverlayImageView =
             itemView.findViewById<ImageView>(R.id.videoOverlayImageView)!!
         private val thumbnailImageView = itemView.findViewById<ImageView>(R.id.thumbnailImageView)!!
 
         private lateinit var media: Media
+        private var isSelected = false
 
-        fun bind(media: Media) {
+        private val inSelectionModeObserver = Observer { inSelectionMode: Boolean ->
+            selectionCheckedImageView.isVisible = inSelectionMode
+        }
+
+        val itemDetails = object : ItemDetailsLookup.ItemDetails<Media>() {
+            override fun getPosition() = bindingAdapterPosition
+            override fun getSelectionKey() = media
+        }
+
+        fun onViewAttachedToWindow() {
+            view.findViewTreeLifecycleOwner()?.let {
+                model.inSelectionMode.observe(it, inSelectionModeObserver)
+            }
+        }
+
+        fun onViewDetachedToWindow() {
+            model.inSelectionMode.removeObserver(inSelectionModeObserver)
+        }
+
+        fun bind(media: Media, isSelected: Boolean = false) {
             this.media = media
+            this.isSelected = isSelected
 
             itemView.setOnClickListener {
                 onItemSelected(media)
@@ -106,6 +187,14 @@
                 placeholder(R.drawable.thumbnail_placeholder)
             }
             videoOverlayImageView.isVisible = media.mediaType == MediaType.VIDEO
+
+            selectionScrimView.isVisible = isSelected
+            selectionCheckedImageView.setImageResource(
+                when (isSelected) {
+                    true -> R.drawable.ic_check_circle
+                    false -> R.drawable.ic_check_circle_outline
+                }
+            )
         }
     }
 
diff --git a/app/src/main/java/org/lineageos/glimpse/recyclerview/ThumbnailItemDetailsLookup.kt b/app/src/main/java/org/lineageos/glimpse/recyclerview/ThumbnailItemDetailsLookup.kt
new file mode 100644
index 0000000..e4c541a
--- /dev/null
+++ b/app/src/main/java/org/lineageos/glimpse/recyclerview/ThumbnailItemDetailsLookup.kt
@@ -0,0 +1,23 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.glimpse.recyclerview
+
+import android.view.MotionEvent
+import androidx.recyclerview.selection.ItemDetailsLookup
+import androidx.recyclerview.widget.RecyclerView
+import org.lineageos.glimpse.models.Media
+import kotlin.reflect.safeCast
+
+class ThumbnailItemDetailsLookup(
+    private val recyclerView: RecyclerView,
+) : ItemDetailsLookup<Media>() {
+    override fun getItemDetails(e: MotionEvent) =
+        recyclerView.findChildViewUnder(e.x, e.y)?.let { childView ->
+            recyclerView.getChildViewHolder(childView)?.let { viewHolder ->
+                ThumbnailAdapter.ThumbnailViewHolder::class.safeCast(viewHolder)?.itemDetails
+            }
+        }
+}
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 3bdca2a..0625682 100644
--- a/app/src/main/java/org/lineageos/glimpse/viewmodels/AlbumViewerViewModel.kt
+++ b/app/src/main/java/org/lineageos/glimpse/viewmodels/AlbumViewerViewModel.kt
@@ -6,6 +6,7 @@
 package org.lineageos.glimpse.viewmodels
 
 import android.app.Application
+import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.viewModelScope
 import androidx.lifecycle.viewmodel.initializer
 import androidx.lifecycle.viewmodel.viewModelFactory
@@ -64,6 +65,8 @@
         initialValue = QueryResult.Empty(),
     )
 
+    val inSelectionMode = MutableLiveData(false)
+
     sealed class DataType(val viewType: Int) {
         class Thumbnail(val media: Media) : DataType(ThumbnailAdapter.ViewTypes.THUMBNAIL.ordinal) {
             override fun equals(other: Any?) = media == other
diff --git a/app/src/main/res/drawable/ic_check_circle.xml b/app/src/main/res/drawable/ic_check_circle.xml
new file mode 100644
index 0000000..89c0a05
--- /dev/null
+++ b/app/src/main/res/drawable/ic_check_circle.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="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z" />
+</vector>
diff --git a/app/src/main/res/drawable/ic_check_circle_outline.xml b/app/src/main/res/drawable/ic_check_circle_outline.xml
new file mode 100644
index 0000000..b4f8fa8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_check_circle_outline.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="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM16.59,7.58L10,14.17l-2.59,-2.58L6,13l4,4 8,-8z" />
+</vector>
diff --git a/app/src/main/res/drawable/ic_delete_forever.xml b/app/src/main/res/drawable/ic_delete_forever.xml
new file mode 100644
index 0000000..af67680
--- /dev/null
+++ b/app/src/main/res/drawable/ic_delete_forever.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="24"
+    android:viewportHeight="24">
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M14.12,10.47L12,12.59l-2.13,-2.12 -1.41,1.41L10.59,14l-2.12,2.12 1.41,1.41L12,15.41l2.12,2.12 1.41,-1.41L13.41,14l2.12,-2.12zM15.5,4l-1,-1h-5l-1,1H5v2h14V4zM6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM8,9h8v10H8V9z" />
+</vector>
diff --git a/app/src/main/res/layout/thumbnail_view.xml b/app/src/main/res/layout/thumbnail_view.xml
index df1517c..f5df6a7 100644
--- a/app/src/main/res/layout/thumbnail_view.xml
+++ b/app/src/main/res/layout/thumbnail_view.xml
@@ -33,4 +33,28 @@
         app:layout_constraintTop_toTopOf="@+id/thumbnailImageView"
         app:layout_constraintWidth_percent="0.5"
         app:tint="@android:color/white" />
+
+    <View
+        android:id="@+id/selectionScrimView"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        android:background="@android:color/darker_gray"
+        android:visibility="gone"
+        app:layout_constraintBottom_toBottomOf="@+id/thumbnailImageView"
+        app:layout_constraintEnd_toEndOf="@+id/thumbnailImageView"
+        app:layout_constraintStart_toStartOf="@+id/thumbnailImageView"
+        app:layout_constraintTop_toTopOf="@+id/thumbnailImageView" />
+
+    <ImageView
+        android:id="@+id/selectionCheckedImageView"
+        android:layout_width="24dp"
+        android:layout_height="24dp"
+        android:layout_marginStart="8dp"
+        android:layout_marginTop="8dp"
+        android:src="@drawable/ic_check_circle_outline"
+        android:visibility="gone"
+        app:layout_constraintStart_toStartOf="@+id/thumbnailImageView"
+        app:layout_constraintTop_toTopOf="@+id/thumbnailImageView"
+        app:tint="@android:color/white" />
+
 </androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/src/main/res/menu/album_action_bar.xml b/app/src/main/res/menu/album_action_bar.xml
new file mode 100644
index 0000000..e9c6620
--- /dev/null
+++ b/app/src/main/res/menu/album_action_bar.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     SPDX-FileCopyrightText: 2023 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/share"
+        android:icon="@drawable/ic_share"
+        android:title="@string/file_action_share"
+        android:contentDescription="@string/file_action_share"
+        app:showAsAction="ifRoom" />
+
+    <item
+        style="@style/Theme.Glimpse.TopAppBarOption"
+        android:id="@+id/moveToTrash"
+        android:icon="@drawable/ic_delete"
+        android:title="@string/file_action_move_to_trash"
+        android:contentDescription="@string/file_action_move_to_trash"
+        app:showAsAction="ifRoom" />
+
+</menu>
diff --git a/app/src/main/res/menu/album_action_bar_trash.xml b/app/src/main/res/menu/album_action_bar_trash.xml
new file mode 100644
index 0000000..486a7ba
--- /dev/null
+++ b/app/src/main/res/menu/album_action_bar_trash.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     SPDX-FileCopyrightText: 2023 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/restoreFromTrash"
+        android:icon="@drawable/ic_restore_from_trash"
+        android:title="@string/file_action_restore_from_trash"
+        android:contentDescription="@string/file_action_restore_from_trash"
+        app:showAsAction="ifRoom" />
+
+    <item
+        style="@style/Theme.Glimpse.TopAppBarOption"
+        android:id="@+id/deleteForever"
+        android:icon="@drawable/ic_delete_forever"
+        android:title="@string/file_action_delete_forever"
+        android:contentDescription="@string/file_action_delete_forever"
+        app:showAsAction="ifRoom" />
+
+</menu>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 38f2423..fde668a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -99,4 +99,7 @@
 
     <!-- No media -->
     <string name="no_media">No media</string>
+
+    <!-- Selection -->
+    <string name="thumbnail_selection_count">%d selected</string>
 </resources>
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 5502387..c7c4214 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -10,6 +10,7 @@
         <item name="android:statusBarColor">@android:color/transparent</item>
         <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
         <item name="android:windowLightStatusBar">?attr/isLightTheme</item>
+        <item name="windowActionModeOverlay">true</item>
     </style>
 
     <!-- Collapsing toolbar style -->