Glimpse: Don't use CursorLoader

Instead manually invoke the query and listen for updates
on the external media Uri to trigger updates.

Change-Id: I0d86484dfa453cce9910d2bcadee6191de5aed28
This commit is contained in:
Luca Stefani 2023-08-09 18:59:01 +02:00 committed by Sebastiano Barezzi
parent af3e76b1c3
commit d4b1bfd6b2
No known key found for this signature in database
GPG Key ID: 763BD3AE91A7A13F
9 changed files with 238 additions and 254 deletions

View File

@ -1,66 +0,0 @@
/*
* SPDX-FileCopyrightText: 2023 The LineageOS Project
* SPDX-License-Identifier: Apache-2.0
*/
package androidx.loader.content
import android.content.ContentResolver
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.Bundle
import androidx.core.os.CancellationSignal
import androidx.core.os.OperationCanceledException
import androidx.core.os.bundleOf
/**
* A custom [CursorLoader] that uses the new [ContentResolver.query]'s
* queryArgs argument.
*/
class GlimpseCursorLoader(
context: Context,
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
private val queryArgs: Bundle = Bundle()
) : CursorLoader(context, uri, projection, selection, selectionArgs, sortOrder) {
init {
queryArgs.putAll(
bundleOf(
ContentResolver.QUERY_ARG_SQL_SELECTION to selection,
ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS to selectionArgs,
ContentResolver.QUERY_ARG_SQL_SORT_ORDER to sortOrder,
)
)
}
override fun onLoadInBackground(): Cursor? {
synchronized(this) {
if (isLoadInBackgroundCanceled) {
throw OperationCanceledException()
}
mCancellationSignal = CancellationSignal()
}
return try {
context.contentResolver.query(
mUri, mProjection, queryArgs,
mCancellationSignal.cancellationSignalObject as android.os.CancellationSignal?
)?.also {
try {
// Ensure the cursor window is filled.
it.count
it.registerContentObserver(mObserver)
} catch (ex: RuntimeException) {
it.close()
throw ex
}
}
} finally {
synchronized(this) { mCancellationSignal = null }
}
}
}

View File

@ -6,11 +6,19 @@
package org.lineageos.glimpse.ext
import android.content.ContentResolver
import android.database.ContentObserver
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.CancellationSignal
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import androidx.activity.result.IntentSenderRequest
import androidx.annotation.RequiresApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.map
@RequiresApi(Build.VERSION_CODES.R)
fun ContentResolver.createDeleteRequest(vararg uris: Uri) = IntentSenderRequest.Builder(
@ -28,3 +36,29 @@ fun ContentResolver.createTrashRequest(value: Boolean, vararg uris: Uri) =
IntentSenderRequest.Builder(
MediaStore.createTrashRequest(this, uris.toCollection(ArrayList()), value)
).build()
fun ContentResolver.uriFlow(uri: Uri) = callbackFlow {
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) {
trySend(Unit)
}
}
registerContentObserver(uri, true, observer)
trySend(Unit)
awaitClose {
unregisterContentObserver(observer)
}
}
fun ContentResolver.queryFlow(
uri: Uri,
projection: Array<String>? = null,
queryArgs: Bundle? = Bundle(),
cancellationSignal: CancellationSignal? = null
) = uriFlow(uri).map {
query(
uri, projection, queryArgs, cancellationSignal
)
}

View File

@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: 2023 The LineageOS Project
* SPDX-License-Identifier: Apache-2.0
*/
package org.lineageos.glimpse.ext
import android.database.Cursor
fun <T> Cursor?.mapEachRow(mapping: (Cursor) -> T) = this?.use {
if (!moveToFirst()) {
emptyList<T>()
}
val data = mutableListOf<T>()
while (!isAfterLast) {
val element = mapping(this)
data.add(element)
moveToNext()
}
data.toList()
} ?: emptyList()

View File

@ -0,0 +1,12 @@
/*
* SPDX-FileCopyrightText: 2023 The LineageOS Project
* SPDX-License-Identifier: Apache-2.0
*/
package org.lineageos.glimpse.ext
import android.database.Cursor
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
fun <T> Flow<Cursor?>.mapEachRow(mapping: (Cursor) -> T) = map { it.mapEachRow(mapping) }

View File

@ -5,70 +5,70 @@
package org.lineageos.glimpse.flow
import android.content.ContentResolver
import android.content.ContentUris
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 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 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.query.*
import org.lineageos.glimpse.utils.MediaStoreBuckets
import org.lineageos.glimpse.utils.MediaStoreRequests
class AlbumsFlow(private val context: Context) {
fun flow() = callbackFlow {
class AlbumsFlow(private val context: Context) : 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 sortOrder = MediaStore.Files.FileColumns.DATE_ADDED + " DESC"
val loader = GlimpseCursorLoader(
context,
MediaStore.Files.getContentUri("external"),
projection,
imageOrVideo.build(),
null,
sortOrder,
bundleOf().apply {
val queryArgs = Bundle().apply {
putAll(
bundleOf(
ContentResolver.QUERY_ARG_SQL_SELECTION to imageOrVideo.build(),
ContentResolver.QUERY_ARG_SQL_SORT_ORDER to sortOrder,
)
)
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 {
return context.contentResolver.queryFlow(
uri,
projection,
queryArgs,
)
}
override fun flowData() = flowCursor().map {
mutableMapOf<Int, Album>().apply {
it?.use {
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 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()
if (!it.moveToFirst()) {
return@use
}
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_IMAGE -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO ->
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
else -> continue
}
@ -90,14 +90,15 @@ class AlbumsFlow(private val context: Context) {
this[bucketId] = Album(
bucketId,
when (bucketId) {
MediaStoreBuckets.MEDIA_STORE_BUCKET_FAVORITES.id ->
context.getString(R.string.album_favorites)
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_TRASH.id -> context.getString(
R.string.album_trash
)
else ->
it.getString(bucketDisplayNameIndex) ?: Build.MODEL
else -> it.getString(bucketDisplayNameIndex) ?: Build.MODEL
},
ContentUris.withAppendedId(contentUri, it.getLong(idIndex)),
).apply { size += 1 }
@ -107,19 +108,5 @@ class AlbumsFlow(private val context: Context) {
}
}
}.values.toList()
send(albums)
}
}
loader.registerListener(
MediaStoreRequests.MEDIA_STORE_ALBUMS_LOADER_ID.ordinal,
onLoadCompleteListener
)
launch(Dispatchers.IO) {
loader.startLoading()
}
awaitClose { loader.stopLoading() }
}
}

View File

@ -5,36 +5,32 @@
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 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 kotlinx.coroutines.flow.Flow
import org.lineageos.glimpse.ext.mapEachRow
import org.lineageos.glimpse.ext.queryFlow
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 {
class MediaFlow(private val context: Context, private val bucketId: Int?) : QueryFlow<Media>() {
override fun flowCursor(): Flow<Cursor?> {
val uri = MediaQuery.MediaStoreFileUri
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_FAVORITES.id -> MediaStore.Files.FileColumns.IS_FAVORITE eq 1
MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id ->
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
MediaStore.Files.FileColumns.IS_TRASHED eq 1
} else {
null
@ -44,50 +40,47 @@ class MediaFlow(private val context: Context, private val bucketId: Int?) {
}
}
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 {
val selectionArgs = bucketId?.takeIf {
MediaStoreBuckets.values().none { bucket -> it == bucket.id }
}?.let { arrayOf(it.toString()) },
sortOrder,
bundleOf().apply {
}?.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,
)
)
// 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) {
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 {
return context.contentResolver.queryFlow(
uri,
projection,
queryArgs,
null,
)
}
override fun flowData() = flowCursor().mapEachRow {
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 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)
@ -96,7 +89,6 @@ class MediaFlow(private val context: Context, private val bucketId: Int?) {
val mimeType = it.getString(mimeTypeIndex)
val dateAdded = it.getLong(dateAddedIndex)
add(
Media.fromMediaStore(
id,
buckedId,
@ -106,24 +98,5 @@ class MediaFlow(private val context: Context, private val bucketId: Int?) {
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() }
}
}

View File

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2023 The LineageOS Project
* SPDX-License-Identifier: Apache-2.0
*/
package org.lineageos.glimpse.flow
import android.database.Cursor
import kotlinx.coroutines.flow.Flow
abstract class QueryFlow<T> {
/** A flow of the data specified by the query */
abstract fun flowData(): Flow<List<T>>
/** A flow of the cursor specified by the query */
abstract fun flowCursor(): Flow<Cursor?>
}

View File

@ -5,9 +5,11 @@
package org.lineageos.glimpse.query
import android.net.Uri
import android.provider.MediaStore
object MediaQuery {
val MediaStoreFileUri: Uri = MediaStore.Files.getContentUri("external")
val MediaProjection = arrayOf(
MediaStore.Files.FileColumns._ID,
MediaStore.Files.FileColumns.BUCKET_ID,

View File

@ -9,7 +9,10 @@ import android.content.Context
import org.lineageos.glimpse.flow.AlbumsFlow
import org.lineageos.glimpse.flow.MediaFlow
@Suppress("Unused")
class MediaRepository(private val context: Context) {
fun media(bucketId: Int? = null) = MediaFlow(context, bucketId).flow()
fun albums() = AlbumsFlow(context).flow()
fun media(bucketId: Int? = null) = MediaFlow(context, bucketId).flowData()
fun mediaCursor(bucketId: Int? = null) = MediaFlow(context, bucketId).flowCursor()
fun albums() = AlbumsFlow(context).flowData()
fun albumsCursor() = AlbumsFlow(context).flowCursor()
}