Glimpse: Implement data loading using the Repository/ViewModel pattern
Change-Id: Ia152000673b36e7f690ac5f2665ef1a0204bb339
This commit is contained in:
parent
72e7b9b16f
commit
af3e76b1c3
|
@ -13,8 +13,11 @@ import coil.decode.GifDecoder
|
|||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.decode.VideoFrameDecoder
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import org.lineageos.glimpse.repository.MediaRepository
|
||||
|
||||
class GlimpseApplication : Application(), ImageLoaderFactory {
|
||||
val mediaRepository = MediaRepository(this)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
|
|
|
@ -0,0 +1,125 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The LineageOS Project
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.lineageos.glimpse.flow
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.loader.content.GlimpseCursorLoader
|
||||
import androidx.loader.content.Loader.OnLoadCompleteListener
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.lineageos.glimpse.R
|
||||
import org.lineageos.glimpse.models.Album
|
||||
import org.lineageos.glimpse.query.*
|
||||
import org.lineageos.glimpse.utils.MediaStoreBuckets
|
||||
import org.lineageos.glimpse.utils.MediaStoreRequests
|
||||
|
||||
class AlbumsFlow(private val context: Context) {
|
||||
fun flow() = callbackFlow {
|
||||
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 sortOrder = MediaStore.Files.FileColumns.DATE_ADDED + " DESC"
|
||||
val loader = GlimpseCursorLoader(
|
||||
context,
|
||||
MediaStore.Files.getContentUri("external"),
|
||||
projection,
|
||||
imageOrVideo.build(),
|
||||
null,
|
||||
sortOrder,
|
||||
bundleOf().apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE)
|
||||
}
|
||||
})
|
||||
|
||||
val onLoadCompleteListener = OnLoadCompleteListener<Cursor> { _, data: Cursor? ->
|
||||
if (!isActive) return@OnLoadCompleteListener
|
||||
launch(Dispatchers.IO) {
|
||||
val albums = mutableMapOf<Int, Album>().apply {
|
||||
data?.let {
|
||||
val idIndex = it.getColumnIndex(MediaStore.Files.FileColumns._ID)
|
||||
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 bucketIdIndex =
|
||||
it.getColumnIndex(MediaStore.Files.FileColumns.BUCKET_ID)
|
||||
val bucketDisplayNameIndex =
|
||||
it.getColumnIndex(MediaStore.Files.FileColumns.BUCKET_DISPLAY_NAME)
|
||||
|
||||
it.moveToFirst()
|
||||
while (!it.isAfterLast) {
|
||||
val contentUri = when (it.getInt(mediaTypeIndex)) {
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE ->
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO ->
|
||||
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
|
||||
else -> continue
|
||||
}
|
||||
|
||||
val bucketIds = listOfNotNull(
|
||||
when (it.getInt(isTrashedIndex)) {
|
||||
1 -> MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id
|
||||
else -> it.getInt(bucketIdIndex)
|
||||
},
|
||||
MediaStoreBuckets.MEDIA_STORE_BUCKET_FAVORITES.id.takeIf { _ ->
|
||||
it.getInt(isFavoriteIndex) == 1
|
||||
},
|
||||
)
|
||||
|
||||
for (bucketId in bucketIds) {
|
||||
this[bucketId]?.also { album ->
|
||||
album.size += 1
|
||||
} ?: run {
|
||||
this[bucketId] = 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)
|
||||
|
||||
else ->
|
||||
it.getString(bucketDisplayNameIndex) ?: Build.MODEL
|
||||
},
|
||||
ContentUris.withAppendedId(contentUri, it.getLong(idIndex)),
|
||||
).apply { size += 1 }
|
||||
}
|
||||
}
|
||||
it.moveToNext()
|
||||
}
|
||||
}
|
||||
}.values.toList()
|
||||
|
||||
send(albums)
|
||||
}
|
||||
}
|
||||
|
||||
loader.registerListener(
|
||||
MediaStoreRequests.MEDIA_STORE_ALBUMS_LOADER_ID.ordinal,
|
||||
onLoadCompleteListener
|
||||
)
|
||||
launch(Dispatchers.IO) {
|
||||
loader.startLoading()
|
||||
}
|
||||
|
||||
awaitClose { loader.stopLoading() }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,129 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The LineageOS Project
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.lineageos.glimpse.flow
|
||||
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.loader.content.GlimpseCursorLoader
|
||||
import androidx.loader.content.Loader.OnLoadCompleteListener
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.lineageos.glimpse.models.Media
|
||||
import org.lineageos.glimpse.query.*
|
||||
import org.lineageos.glimpse.utils.MediaStoreBuckets
|
||||
import org.lineageos.glimpse.utils.MediaStoreRequests
|
||||
|
||||
class MediaFlow(private val context: Context, private val bucketId: Int?) {
|
||||
fun flow() = callbackFlow {
|
||||
val projection = MediaQuery.MediaProjection
|
||||
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 = bucketId?.let {
|
||||
when (it) {
|
||||
MediaStoreBuckets.MEDIA_STORE_BUCKET_FAVORITES.id ->
|
||||
MediaStore.Files.FileColumns.IS_FAVORITE eq 1
|
||||
|
||||
MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id ->
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
MediaStore.Files.FileColumns.IS_TRASHED eq 1
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
else -> MediaStore.Files.FileColumns.BUCKET_ID eq Query.ARG
|
||||
}
|
||||
}
|
||||
val selection = albumFilter?.let { imageOrVideo and it } ?: imageOrVideo
|
||||
val sortOrder = MediaStore.Files.FileColumns.DATE_ADDED + " DESC"
|
||||
val loader = GlimpseCursorLoader(
|
||||
context,
|
||||
MediaStore.Files.getContentUri("external"),
|
||||
projection,
|
||||
selection.build(),
|
||||
bucketId?.takeIf {
|
||||
MediaStoreBuckets.values().none { bucket -> it == bucket.id }
|
||||
}?.let { arrayOf(it.toString()) },
|
||||
sortOrder,
|
||||
bundleOf().apply {
|
||||
// Exclude trashed media unless we want data for the trashed album
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
putInt(
|
||||
MediaStore.QUERY_ARG_MATCH_TRASHED,
|
||||
when (bucketId) {
|
||||
MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> MediaStore.MATCH_ONLY
|
||||
|
||||
else -> MediaStore.MATCH_EXCLUDE
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
val onLoadCompleteListener = OnLoadCompleteListener<Cursor> { _, data: Cursor? ->
|
||||
if (!isActive) return@OnLoadCompleteListener
|
||||
launch(Dispatchers.IO) {
|
||||
val media = mutableListOf<Media>().apply {
|
||||
data?.let {
|
||||
val idIndex = it.getColumnIndex(MediaStore.Files.FileColumns._ID)
|
||||
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 bucketIdIndex = it.getColumnIndex(MediaStore.MediaColumns.BUCKET_ID)
|
||||
|
||||
it.moveToFirst()
|
||||
while (!it.isAfterLast) {
|
||||
val id = it.getLong(idIndex)
|
||||
val buckedId = it.getInt(bucketIdIndex)
|
||||
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)
|
||||
|
||||
add(
|
||||
Media.fromMediaStore(
|
||||
id,
|
||||
buckedId,
|
||||
isFavorite,
|
||||
isTrashed,
|
||||
mediaType,
|
||||
mimeType,
|
||||
dateAdded,
|
||||
)
|
||||
)
|
||||
it.moveToNext()
|
||||
}
|
||||
}
|
||||
}.toList()
|
||||
|
||||
send(media)
|
||||
}
|
||||
}
|
||||
|
||||
loader.registerListener(
|
||||
MediaStoreRequests.MEDIA_STORE_MEDIA_LOADER_ID.ordinal,
|
||||
onLoadCompleteListener
|
||||
)
|
||||
launch(Dispatchers.IO) {
|
||||
loader.startLoading()
|
||||
}
|
||||
|
||||
awaitClose { loader.stopLoading() }
|
||||
}
|
||||
}
|
|
@ -6,10 +6,7 @@
|
|||
package org.lineageos.glimpse.fragments
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.database.Cursor
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
|
@ -20,9 +17,8 @@ import androidx.core.view.WindowInsetsCompat
|
|||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.loader.app.LoaderManager
|
||||
import androidx.loader.content.GlimpseCursorLoader
|
||||
import androidx.loader.content.Loader
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.ui.AppBarConfiguration
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
|
@ -30,23 +26,26 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.appbar.MaterialToolbar
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import org.lineageos.glimpse.R
|
||||
import org.lineageos.glimpse.ext.getParcelable
|
||||
import org.lineageos.glimpse.ext.getViewProperty
|
||||
import org.lineageos.glimpse.models.Album
|
||||
import org.lineageos.glimpse.query.*
|
||||
import org.lineageos.glimpse.thumbnail.ThumbnailAdapter
|
||||
import org.lineageos.glimpse.thumbnail.ThumbnailLayoutManager
|
||||
import org.lineageos.glimpse.utils.MediaStoreBuckets
|
||||
import org.lineageos.glimpse.utils.MediaStoreRequests
|
||||
import org.lineageos.glimpse.utils.PermissionsUtils
|
||||
import org.lineageos.glimpse.viewmodels.MediaViewModel
|
||||
|
||||
/**
|
||||
* A fragment showing a list of media from a specific album with thumbnails.
|
||||
* Use the [AlbumFragment.newInstance] factory method to
|
||||
* create an instance of this fragment.
|
||||
*/
|
||||
class AlbumFragment : Fragment(R.layout.fragment_album), LoaderManager.LoaderCallbacks<Cursor> {
|
||||
class AlbumFragment : Fragment(R.layout.fragment_album) {
|
||||
// View models
|
||||
private val mediaViewModel: MediaViewModel by viewModels { MediaViewModel.Factory }
|
||||
|
||||
// Views
|
||||
private val albumRecyclerView by getViewProperty<RecyclerView>(R.id.albumRecyclerView)
|
||||
private val appBarLayout by getViewProperty<AppBarLayout>(R.id.appBarLayout)
|
||||
|
@ -64,13 +63,17 @@ class AlbumFragment : Fragment(R.layout.fragment_album), LoaderManager.LoaderCal
|
|||
).show()
|
||||
requireActivity().finish()
|
||||
} else {
|
||||
initCursorLoader()
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
mediaViewModel.setBucketId(album.id)
|
||||
mediaViewModel.mediaForAlbum.collectLatest { data ->
|
||||
thumbnailAdapter.data = data.toTypedArray()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MediaStore
|
||||
private val loaderManagerInstance by lazy { LoaderManager.getInstance(this) }
|
||||
private val thumbnailAdapter by lazy {
|
||||
ThumbnailAdapter { media, position ->
|
||||
findNavController().navigate(
|
||||
|
@ -118,7 +121,12 @@ class AlbumFragment : Fragment(R.layout.fragment_album), LoaderManager.LoaderCal
|
|||
if (!permissionsUtils.mainPermissionsGranted()) {
|
||||
mainPermissionsRequestLauncher.launch(PermissionsUtils.mainPermissions)
|
||||
} else {
|
||||
initCursorLoader()
|
||||
mediaViewModel.setBucketId(album.id)
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
mediaViewModel.mediaForAlbum.collectLatest { data ->
|
||||
thumbnailAdapter.data = data.toTypedArray()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -130,71 +138,6 @@ class AlbumFragment : Fragment(R.layout.fragment_album), LoaderManager.LoaderCal
|
|||
)
|
||||
}
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) = when (id) {
|
||||
MediaStoreRequests.MEDIA_STORE_MEDIA_LOADER_ID.ordinal -> {
|
||||
val projection = MediaQuery.MediaProjection
|
||||
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 (album.id) {
|
||||
MediaStoreBuckets.MEDIA_STORE_BUCKET_FAVORITES.id -> {
|
||||
MediaStore.Files.FileColumns.IS_FAVORITE eq 1
|
||||
}
|
||||
|
||||
MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
MediaStore.Files.FileColumns.IS_TRASHED eq 1
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
MediaStore.Files.FileColumns.BUCKET_ID eq Query.ARG
|
||||
}
|
||||
}
|
||||
val selection = albumFilter?.let { imageOrVideo and it } ?: imageOrVideo
|
||||
val sortOrder = MediaStore.Files.FileColumns.DATE_ADDED + " DESC"
|
||||
val queryArgs = args ?: Bundle()
|
||||
GlimpseCursorLoader(
|
||||
requireContext(),
|
||||
MediaStore.Files.getContentUri("external"),
|
||||
projection,
|
||||
selection.build(),
|
||||
album.takeIf {
|
||||
MediaStoreBuckets.values().none { bucket -> it.id == bucket.id }
|
||||
}?.let { arrayOf(it.id.toString()) },
|
||||
sortOrder,
|
||||
queryArgs
|
||||
)
|
||||
}
|
||||
|
||||
else -> throw Exception("Unknown ID $id")
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<Cursor>) {
|
||||
thumbnailAdapter.changeCursor(null)
|
||||
}
|
||||
|
||||
override fun onLoadFinished(loader: Loader<Cursor>, data: Cursor?) {
|
||||
thumbnailAdapter.changeCursor(data)
|
||||
}
|
||||
|
||||
private fun initCursorLoader() {
|
||||
loaderManagerInstance.initLoader(
|
||||
MediaStoreRequests.MEDIA_STORE_MEDIA_LOADER_ID.ordinal,
|
||||
bundleOf().apply {
|
||||
when (album.id) {
|
||||
MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, this
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_ALBUM_ID = "album_id"
|
||||
|
||||
|
|
|
@ -5,11 +5,7 @@
|
|||
|
||||
package org.lineageos.glimpse.fragments
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.database.Cursor
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
|
@ -20,26 +16,27 @@ import androidx.core.view.WindowInsetsCompat
|
|||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.loader.app.LoaderManager
|
||||
import androidx.loader.content.GlimpseCursorLoader
|
||||
import androidx.loader.content.Loader
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
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.query.*
|
||||
import org.lineageos.glimpse.thumbnail.AlbumThumbnailAdapter
|
||||
import org.lineageos.glimpse.utils.MediaStoreBuckets
|
||||
import org.lineageos.glimpse.utils.MediaStoreRequests
|
||||
import org.lineageos.glimpse.viewmodels.MediaViewModel
|
||||
|
||||
/**
|
||||
* An albums list visualizer.
|
||||
* Use the [AlbumsFragment.newInstance] factory method to
|
||||
* create an instance of this fragment.
|
||||
*/
|
||||
class AlbumsFragment : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
|
||||
class AlbumsFragment : Fragment() {
|
||||
// View models
|
||||
private val mediaViewModel: MediaViewModel by viewModels { MediaViewModel.Factory }
|
||||
|
||||
// Views
|
||||
private val albumsRecyclerView by getViewProperty<RecyclerView>(R.id.albumsRecyclerView)
|
||||
|
||||
|
@ -49,7 +46,6 @@ class AlbumsFragment : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
|
|||
}
|
||||
|
||||
// MediaStore
|
||||
private val loaderManagerInstance by lazy { LoaderManager.getInstance(this) }
|
||||
private val albumThumbnailAdapter by lazy { AlbumThumbnailAdapter(parentNavController) }
|
||||
|
||||
override fun onCreateView(
|
||||
|
@ -77,107 +73,11 @@ class AlbumsFragment : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
|
|||
windowInsets
|
||||
}
|
||||
|
||||
loaderManagerInstance.initLoader(
|
||||
MediaStoreRequests.MEDIA_STORE_ALBUMS_LOADER_ID.ordinal,
|
||||
bundleOf().apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE)
|
||||
}
|
||||
},
|
||||
this
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) = when (id) {
|
||||
MediaStoreRequests.MEDIA_STORE_ALBUMS_LOADER_ID.ordinal -> {
|
||||
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 sortOrder = MediaStore.Files.FileColumns.DATE_ADDED + " DESC"
|
||||
val queryArgs = args ?: Bundle()
|
||||
GlimpseCursorLoader(
|
||||
requireContext(),
|
||||
MediaStore.Files.getContentUri("external"),
|
||||
projection,
|
||||
imageOrVideo.build(),
|
||||
null,
|
||||
sortOrder,
|
||||
queryArgs
|
||||
)
|
||||
}
|
||||
|
||||
else -> throw Exception("Unknown ID $id")
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<Cursor>) {
|
||||
albumThumbnailAdapter.changeArray(null)
|
||||
}
|
||||
|
||||
override fun onLoadFinished(loader: Loader<Cursor>, data: Cursor?) {
|
||||
// Google killed GROUP BY in Android 10, forget about it
|
||||
|
||||
val albums = mutableMapOf<Int, Album>()
|
||||
|
||||
data?.let { cursor ->
|
||||
if (cursor.count <= 0) {
|
||||
return@let
|
||||
}
|
||||
|
||||
val idIndex = cursor.getColumnIndex(MediaStore.Files.FileColumns._ID)
|
||||
val isFavoriteIndex = cursor.getColumnIndex(MediaStore.Files.FileColumns.IS_FAVORITE)
|
||||
val isTrashedIndex = cursor.getColumnIndex(MediaStore.Files.FileColumns.IS_TRASHED)
|
||||
val mediaTypeIndex = cursor.getColumnIndex(MediaStore.Files.FileColumns.MEDIA_TYPE)
|
||||
val bucketIdIndex = cursor.getColumnIndex(MediaStore.Files.FileColumns.BUCKET_ID)
|
||||
val bucketDisplayNameIndex =
|
||||
cursor.getColumnIndex(MediaStore.Files.FileColumns.BUCKET_DISPLAY_NAME)
|
||||
|
||||
cursor.moveToFirst()
|
||||
|
||||
while (!cursor.isAfterLast) {
|
||||
val contentUri = when (cursor.getInt(mediaTypeIndex)) {
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE ->
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
|
||||
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO ->
|
||||
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
|
||||
else -> return@let
|
||||
}
|
||||
|
||||
val bucketIds = listOfNotNull(
|
||||
when (cursor.getInt(isTrashedIndex)) {
|
||||
1 -> MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id
|
||||
else -> cursor.getInt(bucketIdIndex)
|
||||
},
|
||||
MediaStoreBuckets.MEDIA_STORE_BUCKET_FAVORITES.id.takeIf {
|
||||
cursor.getInt(isFavoriteIndex) == 1
|
||||
},
|
||||
)
|
||||
|
||||
for (bucketId in bucketIds) {
|
||||
albums[bucketId]?.also {
|
||||
it.size += 1
|
||||
} ?: run {
|
||||
albums[bucketId] = Album(
|
||||
bucketId,
|
||||
when (bucketId) {
|
||||
MediaStoreBuckets.MEDIA_STORE_BUCKET_FAVORITES.id ->
|
||||
getString(R.string.album_favorites)
|
||||
MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id ->
|
||||
getString(R.string.album_trash)
|
||||
else -> cursor.getString(bucketDisplayNameIndex) ?: Build.MODEL
|
||||
},
|
||||
ContentUris.withAppendedId(contentUri, cursor.getLong(idIndex)),
|
||||
).apply { size += 1 }
|
||||
}
|
||||
}
|
||||
|
||||
cursor.moveToNext()
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
mediaViewModel.albums.collectLatest {
|
||||
albumThumbnailAdapter.changeArray(it.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
albumThumbnailAdapter.changeArray(albums.values.toTypedArray())
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -7,10 +7,8 @@ package org.lineageos.glimpse.fragments
|
|||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.database.Cursor
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageButton
|
||||
|
@ -24,10 +22,8 @@ import androidx.core.view.ViewCompat
|
|||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.loader.app.LoaderManager
|
||||
import androidx.loader.content.GlimpseCursorLoader
|
||||
import androidx.loader.content.Loader
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
|
@ -36,16 +32,16 @@ import androidx.viewpager2.widget.ViewPager2
|
|||
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
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.ext.*
|
||||
import org.lineageos.glimpse.models.Album
|
||||
import org.lineageos.glimpse.models.Media
|
||||
import org.lineageos.glimpse.models.MediaType
|
||||
import org.lineageos.glimpse.query.*
|
||||
import org.lineageos.glimpse.thumbnail.MediaViewerAdapter
|
||||
import org.lineageos.glimpse.utils.MediaStoreBuckets
|
||||
import org.lineageos.glimpse.utils.MediaStoreRequests
|
||||
import org.lineageos.glimpse.utils.PermissionsUtils
|
||||
import org.lineageos.glimpse.viewmodels.MediaViewModel
|
||||
import java.text.SimpleDateFormat
|
||||
|
||||
/**
|
||||
|
@ -53,9 +49,10 @@ import java.text.SimpleDateFormat
|
|||
* Use the [MediaViewerFragment.newInstance] factory method to
|
||||
* create an instance of this fragment.
|
||||
*/
|
||||
class MediaViewerFragment : Fragment(
|
||||
R.layout.fragment_media_viewer
|
||||
), LoaderManager.LoaderCallbacks<Cursor> {
|
||||
class MediaViewerFragment : Fragment(R.layout.fragment_media_viewer) {
|
||||
// View models
|
||||
private val mediaViewModel: MediaViewModel by viewModels { MediaViewModel.Factory }
|
||||
|
||||
// Views
|
||||
private val adjustButton by getViewProperty<ImageButton>(R.id.adjustButton)
|
||||
private val backButton by getViewProperty<ImageButton>(R.id.backButton)
|
||||
|
@ -82,7 +79,10 @@ class MediaViewerFragment : Fragment(
|
|||
).show()
|
||||
requireActivity().finish()
|
||||
} else {
|
||||
initCursorLoader()
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
mediaViewModel.setBucketId(album?.id)
|
||||
mediaViewModel.mediaForAlbum.collectLatest(::initData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -96,19 +96,10 @@ class MediaViewerFragment : Fragment(
|
|||
|
||||
// Adapter
|
||||
private val mediaViewerAdapter by lazy {
|
||||
MediaViewerAdapter(exoPlayer, currentPositionLiveData)
|
||||
MediaViewerAdapter(exoPlayer, mediaViewModel.mediaPositionLiveData)
|
||||
}
|
||||
|
||||
// MediaStore
|
||||
private val loaderManagerInstance by lazy { LoaderManager.getInstance(this) }
|
||||
|
||||
// Arguments
|
||||
private val currentPositionLiveData = MutableLiveData(-1)
|
||||
private var position: Int
|
||||
get() = currentPositionLiveData.value!!
|
||||
set(value) {
|
||||
currentPositionLiveData.value = value
|
||||
}
|
||||
private val album by lazy { arguments?.getParcelable(KEY_ALBUM, Album::class) }
|
||||
|
||||
// Contracts
|
||||
|
@ -167,7 +158,7 @@ class MediaViewerFragment : Fragment(
|
|||
}
|
||||
private val favoriteContract =
|
||||
registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) {
|
||||
mediaViewerAdapter.getMediaFromMediaStore(viewPager.currentItem)?.let {
|
||||
mediaViewerAdapter.getItemAtPosition(viewPager.currentItem).let {
|
||||
favoriteButton.isSelected = it.isFavorite
|
||||
}
|
||||
}
|
||||
|
@ -184,9 +175,9 @@ class MediaViewerFragment : Fragment(
|
|||
return
|
||||
}
|
||||
|
||||
this@MediaViewerFragment.position = position
|
||||
this@MediaViewerFragment.mediaViewModel.mediaPosition = position
|
||||
|
||||
val media = mediaViewerAdapter.getMediaFromMediaStore(position) ?: return
|
||||
val media = mediaViewerAdapter.getItemAtPosition(position)
|
||||
|
||||
dateTextView.text = dateFormatter.format(media.dateAdded)
|
||||
timeTextView.text = timeFormatter.format(media.dateAdded)
|
||||
|
@ -218,28 +209,24 @@ class MediaViewerFragment : Fragment(
|
|||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
position = arguments?.getInt(KEY_POSITION, -1)!!
|
||||
mediaViewModel.mediaPosition = arguments?.getInt(KEY_POSITION, -1)!!
|
||||
|
||||
backButton.setOnClickListener {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
deleteButton.setOnClickListener {
|
||||
mediaViewerAdapter.getMediaFromMediaStore(viewPager.currentItem)?.let {
|
||||
trashMedia(it)
|
||||
}
|
||||
trashMedia(mediaViewerAdapter.getItemAtPosition(viewPager.currentItem))
|
||||
}
|
||||
|
||||
deleteButton.setOnLongClickListener {
|
||||
mediaViewerAdapter.getMediaFromMediaStore(viewPager.currentItem)?.let {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.file_deletion_confirm_title)
|
||||
mediaViewerAdapter.getItemAtPosition(viewPager.currentItem).let {
|
||||
MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.file_deletion_confirm_title)
|
||||
.setMessage(
|
||||
resources.getQuantityString(
|
||||
R.plurals.file_deletion_confirm_message, 1, 1
|
||||
)
|
||||
)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
).setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
deleteUriContract.launch(
|
||||
requireContext().contentResolver.createDeleteRequest(
|
||||
|
@ -262,7 +249,7 @@ class MediaViewerFragment : Fragment(
|
|||
}
|
||||
|
||||
favoriteButton.setOnClickListener {
|
||||
mediaViewerAdapter.getMediaFromMediaStore(viewPager.currentItem)?.let {
|
||||
mediaViewerAdapter.getItemAtPosition(viewPager.currentItem).let {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
favoriteContract.launch(
|
||||
requireContext().contentResolver.createFavoriteRequest(
|
||||
|
@ -298,14 +285,14 @@ class MediaViewerFragment : Fragment(
|
|||
viewPager.registerOnPageChangeCallback(onPageChangeCallback)
|
||||
|
||||
shareButton.setOnClickListener {
|
||||
mediaViewerAdapter.getMediaFromMediaStore(viewPager.currentItem)?.let {
|
||||
mediaViewerAdapter.getItemAtPosition(viewPager.currentItem).let {
|
||||
val intent = Intent().shareIntent(it)
|
||||
startActivity(Intent.createChooser(intent, null))
|
||||
}
|
||||
}
|
||||
|
||||
adjustButton.setOnClickListener {
|
||||
mediaViewerAdapter.getMediaFromMediaStore(viewPager.currentItem)?.let {
|
||||
mediaViewerAdapter.getItemAtPosition(viewPager.currentItem).let {
|
||||
val intent = Intent().editIntent(it)
|
||||
startActivity(Intent.createChooser(intent, null))
|
||||
}
|
||||
|
@ -314,7 +301,10 @@ class MediaViewerFragment : Fragment(
|
|||
if (!permissionsUtils.mainPermissionsGranted()) {
|
||||
mainPermissionsRequestLauncher.launch(PermissionsUtils.mainPermissions)
|
||||
} else {
|
||||
initCursorLoader()
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
mediaViewModel.setBucketId(album?.id)
|
||||
mediaViewModel.mediaForAlbum.collectLatest(::initData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -338,75 +328,10 @@ class MediaViewerFragment : Fragment(
|
|||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) = when (id) {
|
||||
MediaStoreRequests.MEDIA_STORE_MEDIA_LOADER_ID.ordinal -> {
|
||||
val projection = MediaQuery.MediaProjection
|
||||
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 = album?.let {
|
||||
when (it.id) {
|
||||
MediaStoreBuckets.MEDIA_STORE_BUCKET_FAVORITES.id -> {
|
||||
MediaStore.Files.FileColumns.IS_FAVORITE eq 1
|
||||
}
|
||||
|
||||
MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
MediaStore.Files.FileColumns.IS_TRASHED eq 1
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
MediaStore.Files.FileColumns.BUCKET_ID eq Query.ARG
|
||||
}
|
||||
}
|
||||
}
|
||||
val selection = albumFilter?.let { imageOrVideo and it } ?: imageOrVideo
|
||||
val sortOrder = MediaStore.Files.FileColumns.DATE_ADDED + " DESC"
|
||||
val queryArgs = args ?: Bundle()
|
||||
GlimpseCursorLoader(
|
||||
requireContext(),
|
||||
MediaStore.Files.getContentUri("external"),
|
||||
projection,
|
||||
selection.build(),
|
||||
album?.takeIf {
|
||||
MediaStoreBuckets.values().none { bucket -> it.id == bucket.id }
|
||||
}?.let { arrayOf(it.id.toString()) },
|
||||
sortOrder,
|
||||
queryArgs
|
||||
)
|
||||
}
|
||||
|
||||
else -> throw Exception("Unknown ID $id")
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<Cursor>) {
|
||||
mediaViewerAdapter.changeCursor(null)
|
||||
}
|
||||
|
||||
override fun onLoadFinished(loader: Loader<Cursor>, data: Cursor?) {
|
||||
mediaViewerAdapter.changeCursor(data)
|
||||
viewPager.setCurrentItem(position, false)
|
||||
onPageChangeCallback.onPageSelected(position)
|
||||
}
|
||||
|
||||
private fun initCursorLoader() {
|
||||
loaderManagerInstance.initLoader(
|
||||
MediaStoreRequests.MEDIA_STORE_MEDIA_LOADER_ID.ordinal,
|
||||
bundleOf().apply {
|
||||
album?.let {
|
||||
when (it.id) {
|
||||
MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, this
|
||||
)
|
||||
private fun initData(data: List<Media>) {
|
||||
mediaViewerAdapter.data = data.toTypedArray()
|
||||
viewPager.setCurrentItem(mediaViewModel.mediaPosition, false)
|
||||
onPageChangeCallback.onPageSelected(mediaViewModel.mediaPosition)
|
||||
}
|
||||
|
||||
private fun trashMedia(media: Media, trash: Boolean = !media.isTrashed) {
|
||||
|
|
|
@ -6,10 +6,7 @@
|
|||
package org.lineageos.glimpse.fragments
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.database.Cursor
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
|
@ -20,25 +17,28 @@ import androidx.core.view.WindowInsetsCompat
|
|||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.loader.app.LoaderManager
|
||||
import androidx.loader.content.CursorLoader
|
||||
import androidx.loader.content.Loader
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
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.query.*
|
||||
import org.lineageos.glimpse.thumbnail.ThumbnailAdapter
|
||||
import org.lineageos.glimpse.thumbnail.ThumbnailLayoutManager
|
||||
import org.lineageos.glimpse.utils.MediaStoreRequests
|
||||
import org.lineageos.glimpse.utils.PermissionsUtils
|
||||
import org.lineageos.glimpse.viewmodels.MediaViewModel
|
||||
|
||||
/**
|
||||
* A fragment showing a list of media with thumbnails.
|
||||
* Use the [ReelsFragment.newInstance] factory method to
|
||||
* create an instance of this fragment.
|
||||
*/
|
||||
class ReelsFragment : Fragment(R.layout.fragment_reels), LoaderManager.LoaderCallbacks<Cursor> {
|
||||
class ReelsFragment : Fragment(R.layout.fragment_reels) {
|
||||
// View models
|
||||
private val mediaViewModel: MediaViewModel by viewModels { MediaViewModel.Factory }
|
||||
|
||||
// Views
|
||||
private val reelsRecyclerView by getViewProperty<RecyclerView>(R.id.reelsRecyclerView)
|
||||
|
||||
|
@ -59,14 +59,17 @@ class ReelsFragment : Fragment(R.layout.fragment_reels), LoaderManager.LoaderCal
|
|||
).show()
|
||||
requireActivity().finish()
|
||||
} else {
|
||||
initCursorLoader()
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
mediaViewModel.media.collectLatest { data ->
|
||||
thumbnailAdapter.data = data.toTypedArray()
|
||||
}
|
||||
}
|
||||
permissionsUtils.showManageMediaPermissionDialogIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MediaStore
|
||||
private val loaderManagerInstance by lazy { LoaderManager.getInstance(this) }
|
||||
private val thumbnailAdapter by lazy {
|
||||
ThumbnailAdapter { media, position ->
|
||||
parentNavController.navigate(
|
||||
|
@ -101,7 +104,11 @@ class ReelsFragment : Fragment(R.layout.fragment_reels), LoaderManager.LoaderCal
|
|||
if (!permissionsUtils.mainPermissionsGranted()) {
|
||||
mainPermissionsRequestLauncher.launch(PermissionsUtils.mainPermissions)
|
||||
} else {
|
||||
initCursorLoader()
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
mediaViewModel.media.collectLatest { data ->
|
||||
thumbnailAdapter.data = data.toTypedArray()
|
||||
}
|
||||
}
|
||||
permissionsUtils.showManageMediaPermissionDialogIfNeeded()
|
||||
}
|
||||
}
|
||||
|
@ -114,53 +121,6 @@ class ReelsFragment : Fragment(R.layout.fragment_reels), LoaderManager.LoaderCal
|
|||
)
|
||||
}
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) = when (id) {
|
||||
MediaStoreRequests.MEDIA_STORE_MEDIA_LOADER_ID.ordinal -> {
|
||||
val projection = MediaQuery.MediaProjection
|
||||
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 isNotTrashed = MediaStore.Files.FileColumns.IS_TRASHED eq 0
|
||||
val selection = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
// Exclude trashed medias
|
||||
imageOrVideo and isNotTrashed
|
||||
} else {
|
||||
imageOrVideo
|
||||
}
|
||||
val sortOrder = MediaStore.Files.FileColumns.DATE_ADDED + " DESC"
|
||||
CursorLoader(
|
||||
requireContext(),
|
||||
MediaStore.Files.getContentUri("external"),
|
||||
projection,
|
||||
selection.build(),
|
||||
null,
|
||||
sortOrder
|
||||
)
|
||||
}
|
||||
|
||||
else -> throw Exception("Unknown ID $id")
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<Cursor>) {
|
||||
thumbnailAdapter.changeCursor(null)
|
||||
}
|
||||
|
||||
override fun onLoadFinished(loader: Loader<Cursor>, data: Cursor?) {
|
||||
thumbnailAdapter.changeCursor(data)
|
||||
}
|
||||
|
||||
private fun initCursorLoader() {
|
||||
loaderManagerInstance.initLoader(
|
||||
MediaStoreRequests.MEDIA_STORE_MEDIA_LOADER_ID.ordinal,
|
||||
bundleOf().apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
// Exclude trashed media
|
||||
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_EXCLUDE)
|
||||
}
|
||||
},
|
||||
this
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun createBundle() = bundleOf()
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The LineageOS Project
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.lineageos.glimpse.repository
|
||||
|
||||
import android.content.Context
|
||||
import org.lineageos.glimpse.flow.AlbumsFlow
|
||||
import org.lineageos.glimpse.flow.MediaFlow
|
||||
|
||||
class MediaRepository(private val context: Context) {
|
||||
fun media(bucketId: Int? = null) = MediaFlow(context, bucketId).flow()
|
||||
fun albums() = AlbumsFlow(context).flow()
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The LineageOS Project
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.lineageos.glimpse.thumbnail
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.database.Cursor
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
abstract class BaseCursorAdapter<T : RecyclerView.ViewHolder> : RecyclerView.Adapter<T>() {
|
||||
protected var cursor: Cursor? = null
|
||||
|
||||
override fun getItemCount() = cursor?.count ?: 0
|
||||
|
||||
fun changeCursor(cursor: Cursor?) {
|
||||
swapCursor(cursor)
|
||||
onChangedCursor(cursor)
|
||||
}
|
||||
|
||||
protected open fun onChangedCursor(cursor: Cursor?) {}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
private fun swapCursor(cursor: Cursor?) {
|
||||
if (this.cursor == cursor) {
|
||||
return
|
||||
}
|
||||
|
||||
val oldCursor = this.cursor
|
||||
this.cursor = cursor
|
||||
|
||||
cursor?.let {
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
oldCursor?.close()
|
||||
}
|
||||
}
|
|
@ -5,8 +5,6 @@
|
|||
|
||||
package org.lineageos.glimpse.thumbnail
|
||||
|
||||
import android.database.Cursor
|
||||
import android.provider.MediaStore
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
|
@ -25,21 +23,27 @@ import org.lineageos.glimpse.models.MediaType
|
|||
class MediaViewerAdapter(
|
||||
private val exoPlayer: ExoPlayer,
|
||||
private val currentPositionLiveData: LiveData<Int>,
|
||||
) : BaseCursorAdapter<MediaViewerAdapter.MediaViewHolder>() {
|
||||
// Cursor indexes
|
||||
private var idIndex = -1
|
||||
private var bucketIdIndex = -1
|
||||
private var isFavoriteIndex = -1
|
||||
private var isTrashedIndex = -1
|
||||
private var mediaTypeIndex = -1
|
||||
private var mimeTypeIndex = -1
|
||||
private var dateAddedIndex = -1
|
||||
) : RecyclerView.Adapter<MediaViewerAdapter.MediaViewHolder>() {
|
||||
var data: Array<Media> = arrayOf()
|
||||
set(value) {
|
||||
if (value.contentEquals(field)) {
|
||||
return
|
||||
}
|
||||
|
||||
field = value
|
||||
|
||||
field.let {
|
||||
@Suppress("NotifyDataSetChanged") notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
setHasStableIds(true)
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int) = getIdFromMediaStore(position)
|
||||
override fun getItemCount() = data.size
|
||||
|
||||
override fun getItemId(position: Int) = data[position].id
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = MediaViewHolder(
|
||||
LayoutInflater.from(parent.context).inflate(R.layout.media_view, parent, false),
|
||||
|
@ -47,7 +51,7 @@ class MediaViewerAdapter(
|
|||
)
|
||||
|
||||
override fun onBindViewHolder(holder: MediaViewHolder, position: Int) {
|
||||
getMediaFromMediaStore(position)?.let { holder.bind(it, position) }
|
||||
holder.bind(data[position], position)
|
||||
}
|
||||
|
||||
override fun onViewAttachedToWindow(holder: MediaViewHolder) {
|
||||
|
@ -60,49 +64,7 @@ class MediaViewerAdapter(
|
|||
holder.onViewDetachedFromWindow()
|
||||
}
|
||||
|
||||
override fun onChangedCursor(cursor: Cursor?) {
|
||||
super.onChangedCursor(cursor)
|
||||
|
||||
cursor?.let {
|
||||
idIndex = it.getColumnIndex(MediaStore.Files.FileColumns._ID)
|
||||
bucketIdIndex = it.getColumnIndex(MediaStore.Files.FileColumns.BUCKET_ID)
|
||||
isFavoriteIndex = it.getColumnIndex(MediaStore.Files.FileColumns.IS_FAVORITE)
|
||||
isTrashedIndex = it.getColumnIndex(MediaStore.Files.FileColumns.IS_TRASHED)
|
||||
mediaTypeIndex = it.getColumnIndex(MediaStore.Files.FileColumns.MEDIA_TYPE)
|
||||
mimeTypeIndex = it.getColumnIndex(MediaStore.Files.FileColumns.MIME_TYPE)
|
||||
dateAddedIndex = it.getColumnIndex(MediaStore.Files.FileColumns.DATE_ADDED)
|
||||
}
|
||||
}
|
||||
|
||||
fun getMediaFromMediaStore(position: Int): Media? {
|
||||
val cursor = cursor ?: return null
|
||||
|
||||
cursor.moveToPosition(position)
|
||||
|
||||
val id = cursor.getLong(idIndex)
|
||||
val bucketId = cursor.getInt(bucketIdIndex)
|
||||
val isFavorite = cursor.getInt(isFavoriteIndex)
|
||||
val isTrashed = cursor.getInt(isTrashedIndex)
|
||||
val mediaType = cursor.getInt(mediaTypeIndex)
|
||||
val mimeType = cursor.getString(mimeTypeIndex)
|
||||
val dateAdded = cursor.getLong(dateAddedIndex)
|
||||
|
||||
return Media.fromMediaStore(
|
||||
id,
|
||||
bucketId,
|
||||
isFavorite,
|
||||
isTrashed,
|
||||
mediaType,
|
||||
mimeType,
|
||||
dateAdded,
|
||||
)
|
||||
}
|
||||
|
||||
private fun getIdFromMediaStore(position: Int): Long {
|
||||
val cursor = cursor ?: return 0
|
||||
cursor.moveToPosition(position)
|
||||
return cursor.getLong(idIndex)
|
||||
}
|
||||
fun getItemAtPosition(position: Int) = data[position]
|
||||
|
||||
class MediaViewHolder(
|
||||
private val view: View,
|
||||
|
|
|
@ -5,8 +5,6 @@
|
|||
|
||||
package org.lineageos.glimpse.thumbnail
|
||||
|
||||
import android.database.Cursor
|
||||
import android.provider.MediaStore
|
||||
import android.text.format.DateUtils
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
|
@ -26,35 +24,40 @@ import java.util.Date
|
|||
|
||||
class ThumbnailAdapter(
|
||||
private val onItemSelected: (media: Media, position: Int) -> Unit,
|
||||
) : BaseCursorAdapter<RecyclerView.ViewHolder>() {
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
private val headersPositions = sortedSetOf<Int>()
|
||||
|
||||
// Cursor indexes
|
||||
private var idIndex = -1
|
||||
private var bucketIdIndex = -1
|
||||
private var isFavoriteIndex = -1
|
||||
private var isTrashedIndex = -1
|
||||
private var mediaTypeIndex = -1
|
||||
private var mimeTypeIndex = -1
|
||||
private var dateAddedIndex = -1
|
||||
var data: Array<Media> = arrayOf()
|
||||
set(value) {
|
||||
if (value.contentEquals(field)) {
|
||||
return
|
||||
}
|
||||
|
||||
field = value
|
||||
|
||||
field.let {
|
||||
@Suppress("NotifyDataSetChanged") notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
setHasStableIds(true)
|
||||
}
|
||||
|
||||
override fun getItemCount() =
|
||||
super.getItemCount().takeIf { it > 0 }
|
||||
?.let { it + (headersPositions.size.takeIf { headerCount -> headerCount > 0 } ?: 1) }
|
||||
?: 0
|
||||
override fun getItemCount() = data.size.takeIf { it > 0 }
|
||||
?.let { it + (headersPositions.size.takeIf { headerCount -> headerCount > 0 } ?: 1) } ?: 0
|
||||
|
||||
override fun getItemId(position: Int) = getIdFromMediaStore(position)
|
||||
override fun getItemId(position: Int) = if (headersPositions.contains(position)) {
|
||||
position.toLong()
|
||||
} else {
|
||||
data[getTruePosition(position)].id
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
||||
LayoutInflater.from(parent.context).let { layoutInflater ->
|
||||
when (viewType) {
|
||||
ViewTypes.ITEM.ordinal -> ThumbnailViewHolder(
|
||||
layoutInflater.inflate(R.layout.thumbnail_view, parent, false),
|
||||
onItemSelected
|
||||
layoutInflater.inflate(R.layout.thumbnail_view, parent, false), onItemSelected
|
||||
)
|
||||
|
||||
ViewTypes.HEADER.ordinal -> DateHeaderViewHolder(
|
||||
|
@ -71,16 +74,12 @@ class ThumbnailAdapter(
|
|||
when (holder.itemViewType) {
|
||||
ViewTypes.ITEM.ordinal -> {
|
||||
val thumbnailViewHolder = holder as ThumbnailViewHolder
|
||||
getMediaFromMediaStore(truePosition)?.let {
|
||||
thumbnailViewHolder.bind(it, truePosition)
|
||||
}
|
||||
thumbnailViewHolder.bind(data[truePosition], truePosition)
|
||||
}
|
||||
|
||||
ViewTypes.HEADER.ordinal -> {
|
||||
val dateHeaderViewHolder = holder as DateHeaderViewHolder
|
||||
getMediaFromMediaStore(truePosition)?.let {
|
||||
dateHeaderViewHolder.bind(it.dateAdded)
|
||||
}
|
||||
dateHeaderViewHolder.bind(data[truePosition].dateAdded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -106,8 +105,8 @@ class ThumbnailAdapter(
|
|||
val truePosition = getTruePosition(position)
|
||||
val previousTruePosition = truePosition - 1
|
||||
|
||||
val currentMedia = getMediaFromMediaStore(truePosition)!!
|
||||
val previousMedia = getMediaFromMediaStore(previousTruePosition)!!
|
||||
val currentMedia = data[truePosition]
|
||||
val previousMedia = data[previousTruePosition]
|
||||
|
||||
val before = previousMedia.dateAdded.toInstant().atZone(ZoneId.systemDefault())
|
||||
val after = currentMedia.dateAdded.toInstant().atZone(ZoneId.systemDefault())
|
||||
|
@ -121,22 +120,6 @@ class ThumbnailAdapter(
|
|||
return ViewTypes.ITEM.ordinal
|
||||
}
|
||||
|
||||
override fun onChangedCursor(cursor: Cursor?) {
|
||||
super.onChangedCursor(cursor)
|
||||
|
||||
headersPositions.clear()
|
||||
|
||||
cursor?.let {
|
||||
idIndex = it.getColumnIndex(MediaStore.Files.FileColumns._ID)
|
||||
bucketIdIndex = it.getColumnIndex(MediaStore.Files.FileColumns.BUCKET_ID)
|
||||
isFavoriteIndex = it.getColumnIndex(MediaStore.Files.FileColumns.IS_FAVORITE)
|
||||
isTrashedIndex = it.getColumnIndex(MediaStore.Files.FileColumns.IS_TRASHED)
|
||||
mediaTypeIndex = it.getColumnIndex(MediaStore.Files.FileColumns.MEDIA_TYPE)
|
||||
mimeTypeIndex = it.getColumnIndex(MediaStore.Files.FileColumns.MIME_TYPE)
|
||||
dateAddedIndex = it.getColumnIndex(MediaStore.Files.FileColumns.DATE_ADDED)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getTruePosition(position: Int) =
|
||||
position - headersPositions.filter { it < position }.size
|
||||
|
||||
|
@ -144,36 +127,6 @@ class ThumbnailAdapter(
|
|||
headersPositions.add(position)
|
||||
}
|
||||
|
||||
private fun getIdFromMediaStore(position: Int): Long {
|
||||
val cursor = cursor ?: return 0
|
||||
cursor.moveToPosition(getTruePosition(position))
|
||||
return cursor.getLong(idIndex)
|
||||
}
|
||||
|
||||
private fun getMediaFromMediaStore(position: Int): Media? {
|
||||
val cursor = cursor ?: return null
|
||||
|
||||
cursor.moveToPosition(position)
|
||||
|
||||
val id = cursor.getLong(idIndex)
|
||||
val bucketId = cursor.getInt(bucketIdIndex)
|
||||
val isFavorite = cursor.getInt(isFavoriteIndex)
|
||||
val isTrashed = cursor.getInt(isTrashedIndex)
|
||||
val mediaType = cursor.getInt(mediaTypeIndex)
|
||||
val mimeType = cursor.getString(mimeTypeIndex)
|
||||
val dateAdded = cursor.getLong(dateAddedIndex)
|
||||
|
||||
return Media.fromMediaStore(
|
||||
id,
|
||||
bucketId,
|
||||
isFavorite,
|
||||
isTrashed,
|
||||
mediaType,
|
||||
mimeType,
|
||||
dateAdded,
|
||||
)
|
||||
}
|
||||
|
||||
class ThumbnailViewHolder(
|
||||
private val view: View,
|
||||
private val onItemSelected: (media: Media, position: Int) -> Unit,
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The LineageOS Project
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.lineageos.glimpse.viewmodels
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
|
||||
import androidx.lifecycle.createSavedStateHandle
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import org.lineageos.glimpse.GlimpseApplication
|
||||
import org.lineageos.glimpse.repository.MediaRepository
|
||||
|
||||
class MediaViewModel(
|
||||
private val savedStateHandle: SavedStateHandle, private val mediaRepository: MediaRepository
|
||||
) : ViewModel() {
|
||||
private val mediaPositionInternal = savedStateHandle.getLiveData<Int>(MEDIA_POSITION_KEY)
|
||||
val mediaPositionLiveData: LiveData<Int> = mediaPositionInternal
|
||||
var mediaPosition: Int
|
||||
set(value) {
|
||||
mediaPositionInternal.value = value
|
||||
}
|
||||
get() = mediaPositionInternal.value!!
|
||||
|
||||
val media = mediaRepository.media(null)
|
||||
val albums = mediaRepository.albums()
|
||||
|
||||
private val bucketId = MutableStateFlow<Int?>(null)
|
||||
fun setBucketId(bucketId: Int?) {
|
||||
this.bucketId.value = bucketId
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val mediaForAlbum = bucketId.flatMapLatest { mediaRepository.media(it) }
|
||||
|
||||
companion object {
|
||||
private const val MEDIA_POSITION_KEY = "position"
|
||||
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
MediaViewModel(
|
||||
savedStateHandle = createSavedStateHandle(),
|
||||
mediaRepository = (this[APPLICATION_KEY] as GlimpseApplication).mediaRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue