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 -->