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 package org.lineageos.glimpse.ext
import android.content.ContentResolver import android.content.ContentResolver
import android.database.ContentObserver
import android.net.Uri import android.net.Uri
import android.os.Build 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 android.provider.MediaStore
import androidx.activity.result.IntentSenderRequest import androidx.activity.result.IntentSenderRequest
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.map
@RequiresApi(Build.VERSION_CODES.R) @RequiresApi(Build.VERSION_CODES.R)
fun ContentResolver.createDeleteRequest(vararg uris: Uri) = IntentSenderRequest.Builder( fun ContentResolver.createDeleteRequest(vararg uris: Uri) = IntentSenderRequest.Builder(
@ -28,3 +36,29 @@ fun ContentResolver.createTrashRequest(value: Boolean, vararg uris: Uri) =
IntentSenderRequest.Builder( IntentSenderRequest.Builder(
MediaStore.createTrashRequest(this, uris.toCollection(ArrayList()), value) MediaStore.createTrashRequest(this, uris.toCollection(ArrayList()), value)
).build() ).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 package org.lineageos.glimpse.flow
import android.content.ContentResolver
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import android.os.Build import android.os.Build
import android.os.Bundle
import android.provider.MediaStore import android.provider.MediaStore
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.loader.content.GlimpseCursorLoader import kotlinx.coroutines.flow.Flow
import androidx.loader.content.Loader.OnLoadCompleteListener import kotlinx.coroutines.flow.map
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.R
import org.lineageos.glimpse.ext.queryFlow
import org.lineageos.glimpse.models.Album import org.lineageos.glimpse.models.Album
import org.lineageos.glimpse.query.* import org.lineageos.glimpse.query.*
import org.lineageos.glimpse.utils.MediaStoreBuckets import org.lineageos.glimpse.utils.MediaStoreBuckets
import org.lineageos.glimpse.utils.MediaStoreRequests
class AlbumsFlow(private val context: Context) { class AlbumsFlow(private val context: Context) : QueryFlow<Album>() {
fun flow() = callbackFlow { override fun flowCursor(): Flow<Cursor?> {
val uri = MediaQuery.MediaStoreFileUri
val projection = MediaQuery.AlbumsProjection val projection = MediaQuery.AlbumsProjection
val imageOrVideo = 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_IMAGE) or
(MediaStore.Files.FileColumns.MEDIA_TYPE eq MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO) (MediaStore.Files.FileColumns.MEDIA_TYPE eq MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO)
val sortOrder = MediaStore.Files.FileColumns.DATE_ADDED + " DESC" val sortOrder = MediaStore.Files.FileColumns.DATE_ADDED + " DESC"
val loader = GlimpseCursorLoader( val queryArgs = Bundle().apply {
context, putAll(
MediaStore.Files.getContentUri("external"), bundleOf(
projection, ContentResolver.QUERY_ARG_SQL_SELECTION to imageOrVideo.build(),
imageOrVideo.build(), ContentResolver.QUERY_ARG_SQL_SORT_ORDER to sortOrder,
null, )
sortOrder, )
bundleOf().apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE) putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_INCLUDE)
} }
}) }
val onLoadCompleteListener = OnLoadCompleteListener<Cursor> { _, data: Cursor? -> return context.contentResolver.queryFlow(
if (!isActive) return@OnLoadCompleteListener uri,
launch(Dispatchers.IO) { projection,
val albums = mutableMapOf<Int, Album>().apply { queryArgs,
data?.let { )
}
override fun flowData() = flowCursor().map {
mutableMapOf<Int, Album>().apply {
it?.use {
val idIndex = it.getColumnIndex(MediaStore.Files.FileColumns._ID) val idIndex = it.getColumnIndex(MediaStore.Files.FileColumns._ID)
val isFavoriteIndex = val isFavoriteIndex =
it.getColumnIndex(MediaStore.Files.FileColumns.IS_FAVORITE) it.getColumnIndex(MediaStore.Files.FileColumns.IS_FAVORITE)
val isTrashedIndex = val isTrashedIndex = it.getColumnIndex(MediaStore.Files.FileColumns.IS_TRASHED)
it.getColumnIndex(MediaStore.Files.FileColumns.IS_TRASHED) val mediaTypeIndex = it.getColumnIndex(MediaStore.Files.FileColumns.MEDIA_TYPE)
val mediaTypeIndex = val bucketIdIndex = it.getColumnIndex(MediaStore.Files.FileColumns.BUCKET_ID)
it.getColumnIndex(MediaStore.Files.FileColumns.MEDIA_TYPE)
val bucketIdIndex =
it.getColumnIndex(MediaStore.Files.FileColumns.BUCKET_ID)
val bucketDisplayNameIndex = val bucketDisplayNameIndex =
it.getColumnIndex(MediaStore.Files.FileColumns.BUCKET_DISPLAY_NAME) it.getColumnIndex(MediaStore.Files.FileColumns.BUCKET_DISPLAY_NAME)
it.moveToFirst() if (!it.moveToFirst()) {
return@use
}
while (!it.isAfterLast) { while (!it.isAfterLast) {
val contentUri = when (it.getInt(mediaTypeIndex)) { val contentUri = when (it.getInt(mediaTypeIndex)) {
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
else -> continue else -> continue
} }
@ -90,14 +90,15 @@ class AlbumsFlow(private val context: Context) {
this[bucketId] = Album( this[bucketId] = Album(
bucketId, bucketId,
when (bucketId) { when (bucketId) {
MediaStoreBuckets.MEDIA_STORE_BUCKET_FAVORITES.id -> MediaStoreBuckets.MEDIA_STORE_BUCKET_FAVORITES.id -> context.getString(
context.getString(R.string.album_favorites) R.string.album_favorites
)
MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> context.getString(
context.getString(R.string.album_trash) R.string.album_trash
)
else -> else -> it.getString(bucketDisplayNameIndex) ?: Build.MODEL
it.getString(bucketDisplayNameIndex) ?: Build.MODEL
}, },
ContentUris.withAppendedId(contentUri, it.getLong(idIndex)), ContentUris.withAppendedId(contentUri, it.getLong(idIndex)),
).apply { size += 1 } ).apply { size += 1 }
@ -107,19 +108,5 @@ class AlbumsFlow(private val context: Context) {
} }
} }
}.values.toList() }.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 package org.lineageos.glimpse.flow
import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import android.os.Build import android.os.Build
import android.os.Bundle
import android.provider.MediaStore import android.provider.MediaStore
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.loader.content.GlimpseCursorLoader import kotlinx.coroutines.flow.Flow
import androidx.loader.content.Loader.OnLoadCompleteListener import org.lineageos.glimpse.ext.mapEachRow
import kotlinx.coroutines.Dispatchers import org.lineageos.glimpse.ext.queryFlow
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.models.Media
import org.lineageos.glimpse.query.* import org.lineageos.glimpse.query.*
import org.lineageos.glimpse.utils.MediaStoreBuckets import org.lineageos.glimpse.utils.MediaStoreBuckets
import org.lineageos.glimpse.utils.MediaStoreRequests
class MediaFlow(private val context: Context, private val bucketId: Int?) { class MediaFlow(private val context: Context, private val bucketId: Int?) : QueryFlow<Media>() {
fun flow() = callbackFlow { override fun flowCursor(): Flow<Cursor?> {
val uri = MediaQuery.MediaStoreFileUri
val projection = MediaQuery.MediaProjection val projection = MediaQuery.MediaProjection
val imageOrVideo = 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_IMAGE) or
(MediaStore.Files.FileColumns.MEDIA_TYPE eq MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO) (MediaStore.Files.FileColumns.MEDIA_TYPE eq MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO)
val albumFilter = bucketId?.let { val albumFilter = bucketId?.let {
when (it) { when (it) {
MediaStoreBuckets.MEDIA_STORE_BUCKET_FAVORITES.id -> MediaStoreBuckets.MEDIA_STORE_BUCKET_FAVORITES.id -> MediaStore.Files.FileColumns.IS_FAVORITE eq 1
MediaStore.Files.FileColumns.IS_FAVORITE eq 1
MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
MediaStore.Files.FileColumns.IS_TRASHED eq 1 MediaStore.Files.FileColumns.IS_TRASHED eq 1
} else { } else {
null null
@ -44,50 +40,47 @@ class MediaFlow(private val context: Context, private val bucketId: Int?) {
} }
} }
val selection = albumFilter?.let { imageOrVideo and it } ?: imageOrVideo val selection = albumFilter?.let { imageOrVideo and it } ?: imageOrVideo
val sortOrder = MediaStore.Files.FileColumns.DATE_ADDED + " DESC" val selectionArgs = bucketId?.takeIf {
val loader = GlimpseCursorLoader(
context,
MediaStore.Files.getContentUri("external"),
projection,
selection.build(),
bucketId?.takeIf {
MediaStoreBuckets.values().none { bucket -> it == bucket.id } MediaStoreBuckets.values().none { bucket -> it == bucket.id }
}?.let { arrayOf(it.toString()) }, }?.let { arrayOf(it.toString()) }
sortOrder, val sortOrder = MediaStore.Files.FileColumns.DATE_ADDED + " DESC"
bundleOf().apply { 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 // Exclude trashed media unless we want data for the trashed album
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
putInt( putInt(
MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.QUERY_ARG_MATCH_TRASHED, when (bucketId) {
when (bucketId) {
MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> MediaStore.MATCH_ONLY MediaStoreBuckets.MEDIA_STORE_BUCKET_TRASH.id -> MediaStore.MATCH_ONLY
else -> MediaStore.MATCH_EXCLUDE else -> MediaStore.MATCH_EXCLUDE
} }
) )
} }
}) }
val onLoadCompleteListener = OnLoadCompleteListener<Cursor> { _, data: Cursor? -> return context.contentResolver.queryFlow(
if (!isActive) return@OnLoadCompleteListener uri,
launch(Dispatchers.IO) { projection,
val media = mutableListOf<Media>().apply { queryArgs,
data?.let { null,
)
}
override fun flowData() = flowCursor().mapEachRow {
val idIndex = it.getColumnIndex(MediaStore.Files.FileColumns._ID) val idIndex = it.getColumnIndex(MediaStore.Files.FileColumns._ID)
val isFavoriteIndex = val isFavoriteIndex = it.getColumnIndex(MediaStore.Files.FileColumns.IS_FAVORITE)
it.getColumnIndex(MediaStore.Files.FileColumns.IS_FAVORITE) val isTrashedIndex = it.getColumnIndex(MediaStore.Files.FileColumns.IS_TRASHED)
val isTrashedIndex = val mediaTypeIndex = it.getColumnIndex(MediaStore.Files.FileColumns.MEDIA_TYPE)
it.getColumnIndex(MediaStore.Files.FileColumns.IS_TRASHED) val mimeTypeIndex = it.getColumnIndex(MediaStore.Files.FileColumns.MIME_TYPE)
val mediaTypeIndex = val dateAddedIndex = it.getColumnIndex(MediaStore.Files.FileColumns.DATE_ADDED)
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) val bucketIdIndex = it.getColumnIndex(MediaStore.MediaColumns.BUCKET_ID)
it.moveToFirst()
while (!it.isAfterLast) {
val id = it.getLong(idIndex) val id = it.getLong(idIndex)
val buckedId = it.getInt(bucketIdIndex) val buckedId = it.getInt(bucketIdIndex)
val isFavorite = it.getInt(isFavoriteIndex) 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 mimeType = it.getString(mimeTypeIndex)
val dateAdded = it.getLong(dateAddedIndex) val dateAdded = it.getLong(dateAddedIndex)
add(
Media.fromMediaStore( Media.fromMediaStore(
id, id,
buckedId, buckedId,
@ -106,24 +98,5 @@ class MediaFlow(private val context: Context, private val bucketId: Int?) {
mimeType, mimeType,
dateAdded, 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 package org.lineageos.glimpse.query
import android.net.Uri
import android.provider.MediaStore import android.provider.MediaStore
object MediaQuery { object MediaQuery {
val MediaStoreFileUri: Uri = MediaStore.Files.getContentUri("external")
val MediaProjection = arrayOf( val MediaProjection = arrayOf(
MediaStore.Files.FileColumns._ID, MediaStore.Files.FileColumns._ID,
MediaStore.Files.FileColumns.BUCKET_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.AlbumsFlow
import org.lineageos.glimpse.flow.MediaFlow import org.lineageos.glimpse.flow.MediaFlow
@Suppress("Unused")
class MediaRepository(private val context: Context) { class MediaRepository(private val context: Context) {
fun media(bucketId: Int? = null) = MediaFlow(context, bucketId).flow() fun media(bucketId: Int? = null) = MediaFlow(context, bucketId).flowData()
fun albums() = AlbumsFlow(context).flow() fun mediaCursor(bucketId: Int? = null) = MediaFlow(context, bucketId).flowCursor()
fun albums() = AlbumsFlow(context).flowData()
fun albumsCursor() = AlbumsFlow(context).flowCursor()
} }