Glimpse: Introduce AlbumFlow

* AlbumViewerFragment can now get album's info on its own, so change
  album argument to just bucket ID
* Since an album might be empty (e.g. our own bucket IDs) make the
  thumbnail optional

Change-Id: Iab2fcb8c120ead658216f842309491edff5afa77
diff --git a/app/src/main/java/org/lineageos/glimpse/flow/AlbumFlow.kt b/app/src/main/java/org/lineageos/glimpse/flow/AlbumFlow.kt
new file mode 100644
index 0000000..3f2212c
--- /dev/null
+++ b/app/src/main/java/org/lineageos/glimpse/flow/AlbumFlow.kt
@@ -0,0 +1,160 @@
+/*
+ * SPDX-FileCopyrightText: 2023 The LineageOS Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.lineageos.glimpse.flow
+
+import android.content.ContentResolver
+import android.content.Context
+import android.database.Cursor
+import android.os.Build
+import android.os.Bundle
+import android.provider.MediaStore
+import androidx.core.os.bundleOf
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import org.lineageos.glimpse.R
+import org.lineageos.glimpse.ext.queryFlow
+import org.lineageos.glimpse.models.Album
+import org.lineageos.glimpse.models.MediaStoreMedia
+import org.lineageos.glimpse.query.*
+import org.lineageos.glimpse.utils.MediaStoreBuckets
+
+class AlbumFlow(
+    private val context: Context,
+    private val bucketId: Int,
+) : 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 albumFilter = when (bucketId) {
+            MediaStoreBuckets.MEDIA_STORE_BUCKET_FAVORITES.id -> MediaStore.Files.FileColumns.IS_FAVORITE eq 1
+
+            MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id ->
+                MediaStore.Files.FileColumns.IS_TRASHED eq 1
+
+            MediaStoreBuckets.MEDIA_STORE_BUCKET_REELS.id -> null
+
+            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 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_ARGS to selectionArgs,
+                    ContentResolver.QUERY_ARG_SQL_SORT_ORDER to sortOrder,
+                    ContentResolver.QUERY_ARG_SQL_LIMIT to 1,
+                )
+            )
+
+            // Exclude trashed media unless we want data for the trashed album
+            putInt(
+                MediaStore.QUERY_ARG_MATCH_TRASHED, when (bucketId) {
+                    MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> MediaStore.MATCH_ONLY
+
+                    else -> MediaStore.MATCH_EXCLUDE
+                }
+            )
+        }
+
+        return context.contentResolver.queryFlow(
+            uri,
+            projection,
+            queryArgs,
+        )
+    }
+
+    override fun flowData() = flowCursor().map {
+        mutableListOf<Album>().apply {
+            it?.use {
+                val idIndex = it.getColumnIndex(MediaStore.Files.FileColumns._ID)
+                val bucketIdIndex = it.getColumnIndex(MediaStore.Files.FileColumns.BUCKET_ID)
+                val displayNameIndex = it.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME)
+                val isFavoriteIndex =
+                    it.getColumnIndex(MediaStore.Files.FileColumns.IS_FAVORITE)
+                val isTrashedIndex = it.getColumnIndex(MediaStore.Files.FileColumns.IS_TRASHED)
+                val mediaTypeIndex = it.getColumnIndex(MediaStore.Files.FileColumns.MEDIA_TYPE)
+                val mimeTypeIndex = it.getColumnIndex(MediaStore.Files.FileColumns.MIME_TYPE)
+                val dateAddedIndex = it.getColumnIndex(MediaStore.Files.FileColumns.DATE_ADDED)
+                val dateModifiedIndex =
+                    it.getColumnIndex(MediaStore.Files.FileColumns.DATE_MODIFIED)
+                val widthIndex = it.getColumnIndex(MediaStore.Files.FileColumns.WIDTH)
+                val heightIndex = it.getColumnIndex(MediaStore.Files.FileColumns.HEIGHT)
+                val orientationIndex =
+                    it.getColumnIndex(MediaStore.Files.FileColumns.ORIENTATION)
+                val bucketDisplayNameIndex =
+                    it.getColumnIndex(MediaStore.Files.FileColumns.BUCKET_DISPLAY_NAME)
+
+                val cursorNotEmpty = it.moveToFirst()
+
+                val media = if (cursorNotEmpty) {
+                    val id = it.getLong(idIndex)
+                    val mediaBucketId = it.getInt(bucketIdIndex)
+                    val displayName = it.getString(displayNameIndex)
+                    val isFavorite = it.getInt(isFavoriteIndex)
+                    val isTrashed = it.getInt(isTrashedIndex)
+                    val mediaType = it.getInt(mediaTypeIndex)
+                    val mimeType = it.getString(mimeTypeIndex)
+                    val dateAdded = it.getLong(dateAddedIndex)
+                    val dateModified = it.getLong(dateModifiedIndex)
+                    val width = it.getInt(widthIndex)
+                    val height = it.getInt(heightIndex)
+                    val orientation = it.getInt(orientationIndex)
+
+                    MediaStoreMedia.fromMediaStore(
+                        id,
+                        mediaBucketId,
+                        displayName,
+                        isFavorite,
+                        isTrashed,
+                        mediaType,
+                        mimeType,
+                        dateAdded,
+                        dateModified,
+                        width,
+                        height,
+                        orientation,
+                    )
+                } else {
+                    null
+                }
+
+                val bucketDisplayName = when (cursorNotEmpty) {
+                    true -> it.getString(bucketDisplayNameIndex)
+                    false -> null
+                }
+
+                val album = Album(
+                    bucketId,
+                    when (bucketId) {
+                        MediaStoreBuckets.MEDIA_STORE_BUCKET_FAVORITES.id -> context.getString(
+                            R.string.album_favorites
+                        )
+
+                        MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> context.getString(
+                            R.string.album_trash
+                        )
+
+                        MediaStoreBuckets.MEDIA_STORE_BUCKET_REELS.id -> context.getString(
+                            R.string.album_reels
+                        )
+
+                        else -> bucketDisplayName ?: Build.MODEL
+                    },
+                    media
+                )
+
+                add(album)
+            }
+        }.toList()
+    }
+}
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 69013a6..331be7f 100644
--- a/app/src/main/java/org/lineageos/glimpse/fragments/AlbumViewerFragment.kt
+++ b/app/src/main/java/org/lineageos/glimpse/fragments/AlbumViewerFragment.kt
@@ -65,8 +65,8 @@
 class AlbumViewerFragment : Fragment(R.layout.fragment_album_viewer) {
     // View models
     private val model: AlbumViewerViewModel by viewModels {
-        album?.let {
-            AlbumViewerViewModel.factory(requireActivity().application, it.id)
+        bucketId?.let {
+            AlbumViewerViewModel.factory(requireActivity().application, it)
         } ?: AlbumViewerViewModel.factory(requireActivity().application)
     }
 
@@ -95,6 +95,16 @@
                 }
             }
         }
+
+        viewLifecycleOwner.lifecycleScope.launch {
+            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+                model.album.collectLatest {
+                    activity?.runOnUiThread {
+                        toolbar.title = it.name
+                    }
+                }
+            }
+        }
     }
 
     // MediaStore
@@ -138,7 +148,7 @@
     private val actionModeCallback = object : ActionMode.Callback {
         override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
             requireActivity().menuInflater.inflate(
-                when (album?.id) {
+                when (bucketId) {
                     MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> R.menu.album_action_bar_trash
                     else -> R.menu.album_action_bar
                 },
@@ -266,7 +276,7 @@
         }
 
     // Arguments
-    private val album by lazy { arguments?.getParcelable(KEY_ALBUM, Album::class) }
+    private val bucketId by lazy { arguments?.getInt(KEY_BUCKET_ID) }
 
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
         super.onViewCreated(view, savedInstanceState)
@@ -276,14 +286,10 @@
         appBarLayout.statusBarForeground =
             MaterialShapeDrawable.createWithElevationOverlay(requireContext())
 
-        album?.let {
-            toolbar.title = it.name
-        }
-
         val appBarConfiguration = AppBarConfiguration(navController.graph)
         toolbar.setupWithNavController(navController, appBarConfiguration)
 
-        when (album?.id) {
+        when (bucketId) {
             MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id ->
                 R.menu.fragment_album_viewer_toolbar_trash
             else -> null
@@ -404,16 +410,16 @@
     }
 
     companion object {
-        private const val KEY_ALBUM = "album"
+        private const val KEY_BUCKET_ID = "bucket_id"
 
         /**
          * Create a [Bundle] to use as the arguments for this fragment.
-         * @param album The [Album] to display, if null, reels will be shown
+         * @param bucketId The [Album] to display's bucket ID, if null, reels will be shown
          */
         fun createBundle(
-            album: Album? = null,
+            bucketId: Int? = null,
         ) = bundleOf(
-            KEY_ALBUM to album,
+            KEY_BUCKET_ID to bucketId,
         )
 
         /**
@@ -424,9 +430,9 @@
          * @return A new instance of fragment [AlbumViewerFragment].
          */
         fun newInstance(
-            album: Album,
+            bucketId: Int? = null,
         ) = AlbumViewerFragment().apply {
-            arguments = createBundle(album)
+            arguments = createBundle(bucketId)
         }
     }
 }
diff --git a/app/src/main/java/org/lineageos/glimpse/models/Album.kt b/app/src/main/java/org/lineageos/glimpse/models/Album.kt
index f7d7cc7..691d3e9 100644
--- a/app/src/main/java/org/lineageos/glimpse/models/Album.kt
+++ b/app/src/main/java/org/lineageos/glimpse/models/Album.kt
@@ -17,7 +17,7 @@
 data class Album(
     val id: Int,
     val name: String,
-    val thumbnail: MediaStoreMedia,
+    val thumbnail: MediaStoreMedia? = null,
     var size: Int = 0,
 ) : Comparable<Album>, Parcelable {
     constructor(parcel: Parcel) : this(
diff --git a/app/src/main/java/org/lineageos/glimpse/recyclerview/AlbumThumbnailAdapter.kt b/app/src/main/java/org/lineageos/glimpse/recyclerview/AlbumThumbnailAdapter.kt
index 9e7161b..e56909f 100644
--- a/app/src/main/java/org/lineageos/glimpse/recyclerview/AlbumThumbnailAdapter.kt
+++ b/app/src/main/java/org/lineageos/glimpse/recyclerview/AlbumThumbnailAdapter.kt
@@ -59,8 +59,10 @@
                 R.plurals.album_thumbnail_items, album.size, album.size
             )
 
-            thumbnailImageView.load(album.thumbnail.uri) {
-                memoryCacheKey("thumbnail_${album.thumbnail.id}")
+            thumbnailImageView.load(album.thumbnail?.uri) {
+                album.thumbnail?.let {
+                    memoryCacheKey("thumbnail_${it.id}")
+                }
                 size(DisplayAwareGridLayoutManager.MAX_THUMBNAIL_SIZE)
                 placeholder(R.drawable.thumbnail_placeholder)
             }
@@ -68,7 +70,7 @@
             itemView.setOnClickListener {
                 navController.navigate(
                     R.id.action_mainFragment_to_albumViewerFragment,
-                    AlbumViewerFragment.createBundle(album)
+                    AlbumViewerFragment.createBundle(album.id)
                 )
             }
         }
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 8a5cf1f..24fddd7 100644
--- a/app/src/main/java/org/lineageos/glimpse/repository/MediaRepository.kt
+++ b/app/src/main/java/org/lineageos/glimpse/repository/MediaRepository.kt
@@ -6,6 +6,7 @@
 package org.lineageos.glimpse.repository
 
 import android.content.Context
+import org.lineageos.glimpse.flow.AlbumFlow
 import org.lineageos.glimpse.flow.AlbumsFlow
 import org.lineageos.glimpse.flow.MediaFlow
 
@@ -13,6 +14,8 @@
 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()
 }
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 e3d2913..7b18dea 100644
--- a/app/src/main/java/org/lineageos/glimpse/viewmodels/AlbumViewerViewModel.kt
+++ b/app/src/main/java/org/lineageos/glimpse/viewmodels/AlbumViewerViewModel.kt
@@ -15,8 +15,10 @@
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.mapNotNull
 import kotlinx.coroutines.flow.stateIn
 import org.lineageos.glimpse.ext.*
+import org.lineageos.glimpse.models.Album
 import org.lineageos.glimpse.models.MediaStoreMedia
 import org.lineageos.glimpse.recyclerview.ThumbnailAdapter
 import org.lineageos.glimpse.repository.MediaRepository
@@ -71,6 +73,14 @@
         initialValue = QueryResult.Empty(),
     )
 
+    val album = MediaRepository.album(context, bucketId).flowOn(Dispatchers.IO).mapNotNull {
+        it.firstOrNull()
+    }.stateIn(
+        viewModelScope,
+        started = SharingStarted.WhileSubscribed(),
+        initialValue = Album(-1, ""),
+    )
+
     val inSelectionMode = MutableLiveData(false)
 
     sealed class DataType(val viewType: Int) {