adding test for fetching media folders

This commit is contained in:
Adam Brown 2022-11-03 11:28:27 +00:00
parent dcf4b4c80a
commit 42761c0899
7 changed files with 141 additions and 42 deletions

View File

@ -13,11 +13,18 @@ data class ContentResolverQuery(
) )
inline fun <T> ContentResolver.reduce(query: ContentResolverQuery, operation: (Cursor) -> T): List<T> { inline fun <T> ContentResolver.reduce(query: ContentResolverQuery, operation: (Cursor) -> T): List<T> {
val result = mutableListOf<T>() return this.reduce(query, mutableListOf<T>()) { acc, cursor ->
acc.add(operation(cursor))
acc
}
}
inline fun <T> ContentResolver.reduce(query: ContentResolverQuery, initial: T, operation: (T, Cursor) -> T): T {
var accumulator: T = initial
this.query(query.uri, query.projection.toTypedArray(), query.selection, query.selectionArgs.toTypedArray(), query.sortBy).use { cursor -> this.query(query.uri, query.projection.toTypedArray(), query.selection, query.selectionArgs.toTypedArray(), query.sortBy).use { cursor ->
while (cursor != null && cursor.moveToNext()) { while (cursor != null && cursor.moveToNext()) {
result.add(operation(cursor)) accumulator = operation(accumulator, cursor)
} }
} }
return result return accumulator
} }

View File

@ -1,37 +1,39 @@
package app.dapk.st.messenger.gallery package app.dapk.st.messenger.gallery
import android.content.ContentResolver import android.content.ContentResolver
import android.content.ContentUris
import android.net.Uri import android.net.Uri
import android.provider.MediaStore.Images import android.provider.MediaStore.Images
import app.dapk.st.core.ContentResolverQuery
import app.dapk.st.core.CoroutineDispatchers import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.reduce
import app.dapk.st.core.withIoContext import app.dapk.st.core.withIoContext
class FetchMediaFoldersUseCase( class FetchMediaFoldersUseCase(
private val contentResolver: ContentResolver, private val contentResolver: ContentResolver,
private val uriAvoidance: MediaUriAvoidance,
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
) { ) {
suspend fun fetchFolders(): List<Folder> { suspend fun fetchFolders(): List<Folder> {
return dispatchers.withIoContext { return dispatchers.withIoContext {
val projection = arrayOf(Images.Media._ID, Images.Media.BUCKET_ID, Images.Media.BUCKET_DISPLAY_NAME, Images.Media.DATE_MODIFIED) val query = ContentResolverQuery(
val selection = "${isNotPending()} AND ${Images.Media.BUCKET_ID} AND ${Images.Media.MIME_TYPE} NOT LIKE ?" uriAvoidance.externalContentUri,
val sortBy = "${Images.Media.BUCKET_DISPLAY_NAME} COLLATE NOCASE ASC, ${Images.Media.DATE_MODIFIED} DESC" listOf(Images.Media._ID, Images.Media.BUCKET_ID, Images.Media.BUCKET_DISPLAY_NAME, Images.Media.DATE_MODIFIED),
"${isNotPending()} AND ${Images.Media.BUCKET_ID} AND ${Images.Media.MIME_TYPE} NOT LIKE ?",
listOf("%image/svg%"),
"${Images.Media.BUCKET_DISPLAY_NAME} COLLATE NOCASE ASC, ${Images.Media.DATE_MODIFIED} DESC"
)
val folders = mutableMapOf<String, Folder>() contentResolver.reduce(query, mutableMapOf<String, Folder>()) { acc, cursor ->
val contentUri = Images.Media.EXTERNAL_CONTENT_URI val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media._ID))
contentResolver.query(contentUri, projection, selection, arrayOf("%image/svg%"), sortBy).use { cursor -> val thumbnail = uriAvoidance.uriAppender(query.uri, rowId)
while (cursor != null && cursor.moveToNext()) { val bucketId = cursor.getString(cursor.getColumnIndexOrThrow(Images.Media.BUCKET_ID))
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(projection[0])) val title = cursor.getString(cursor.getColumnIndexOrThrow(Images.Media.BUCKET_DISPLAY_NAME)) ?: ""
val thumbnail = ContentUris.withAppendedId(contentUri, rowId) val timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.DATE_MODIFIED))
val bucketId = cursor.getString(cursor.getColumnIndexOrThrow(projection[1])) val folder = acc.getOrPut(bucketId) { Folder(bucketId, title, thumbnail) }
val title = cursor.getString(cursor.getColumnIndexOrThrow(projection[2])) ?: ""
val timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(projection[3]))
val folder = folders.getOrPut(bucketId) { Folder(bucketId, title, thumbnail) }
folder.incrementItemCount() folder.incrementItemCount()
} acc
} }.values.toList()
folders.values.toList()
} }
} }

View File

@ -10,7 +10,7 @@ import app.dapk.st.core.withIoContext
class FetchMediaUseCase( class FetchMediaUseCase(
private val contentResolver: ContentResolver, private val contentResolver: ContentResolver,
private val uriAvoidance: UriAvoidance, private val uriAvoidance: MediaUriAvoidance,
private val dispatchers: CoroutineDispatchers private val dispatchers: CoroutineDispatchers
) { ) {
@ -54,11 +54,6 @@ class FetchMediaUseCase(
private fun getHeightColumn(orientation: Int) = private fun getHeightColumn(orientation: Int) =
if (orientation == 0 || orientation == 180) MediaStore.Images.Media.HEIGHT else MediaStore.Images.Media.WIDTH if (orientation == 0 || orientation == 180) MediaStore.Images.Media.HEIGHT else MediaStore.Images.Media.WIDTH
class UriAvoidance(
val uriAppender: (Uri, Long) -> Uri,
val externalContentUri: Uri,
)
} }
data class Media( data class Media(

View File

@ -7,7 +7,6 @@ import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.JobBag import app.dapk.st.core.JobBag
import app.dapk.st.core.ProvidableModule import app.dapk.st.core.ProvidableModule
import app.dapk.st.core.createStateViewModel import app.dapk.st.core.createStateViewModel
import app.dapk.st.messenger.gallery.FetchMediaUseCase.UriAvoidance
import app.dapk.st.messenger.gallery.state.ImageGalleryState import app.dapk.st.messenger.gallery.state.ImageGalleryState
import app.dapk.st.messenger.gallery.state.imageGalleryReducer import app.dapk.st.messenger.gallery.state.imageGalleryReducer
@ -17,17 +16,14 @@ class ImageGalleryModule(
) : ProvidableModule { ) : ProvidableModule {
fun imageGalleryState(roomName: String): ImageGalleryState = createStateViewModel { fun imageGalleryState(roomName: String): ImageGalleryState = createStateViewModel {
imageGalleryReducer( val uriAvoidance = MediaUriAvoidance(
roomName = roomName,
FetchMediaFoldersUseCase(contentResolver, dispatchers),
FetchMediaUseCase(
contentResolver,
UriAvoidance(
uriAppender = { uri, rowId -> ContentUris.withAppendedId(uri, rowId) }, uriAppender = { uri, rowId -> ContentUris.withAppendedId(uri, rowId) },
externalContentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI externalContentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
), )
dispatchers, imageGalleryReducer(
), roomName = roomName,
FetchMediaFoldersUseCase(contentResolver, uriAvoidance, dispatchers),
FetchMediaUseCase(contentResolver, uriAvoidance, dispatchers),
JobBag(), JobBag(),
) )
} }

View File

@ -0,0 +1,8 @@
package app.dapk.st.messenger.gallery
import android.net.Uri
class MediaUriAvoidance(
val uriAppender: (Uri, Long) -> Uri,
val externalContentUri: Uri,
)

View File

@ -0,0 +1,91 @@
package app.dapk.st.messenger.gallery
import android.net.Uri
import android.provider.MediaStore
import fake.CreateCursorScope
import fake.FakeContentResolver
import fake.FakeUri
import fake.createCursor
import fixture.CoroutineDispatchersFixture.aCoroutineDispatchers
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
private val A_EXTERNAL_CONTENT_URI = FakeUri()
private const val A_BUCKET_ID = "a-bucket-id"
private const val A_SECOND_BUCKET_ID = "another-bucket"
private const val A_DISPLAY_NAME = "a-bucket-name"
private const val A_DATE_MODIFIED = 5000L
class FetchMediaFoldersUseCaseTest {
private val fakeContentResolver = FakeContentResolver()
private val fakeUriAppender = FakeUriAppender()
private val uriAvoidance = MediaUriAvoidance(
uriAppender = fakeUriAppender,
externalContentUri = A_EXTERNAL_CONTENT_URI.instance,
)
private val useCase = FetchMediaFoldersUseCase(fakeContentResolver.instance, uriAvoidance, aCoroutineDispatchers())
@Test
fun `given cursor content, when get folder, then reads unique folders`() = runTest {
fakeContentResolver.givenFolderQuery().returns(createCursor {
addFolderRow(rowId = 1, A_BUCKET_ID)
addFolderRow(rowId = 2, A_BUCKET_ID)
addFolderRow(rowId = 3, A_SECOND_BUCKET_ID)
})
val result = useCase.fetchFolders()
result shouldBeEqualTo listOf(
Folder(
bucketId = A_BUCKET_ID,
title = A_DISPLAY_NAME,
thumbnail = fakeUriAppender.get(rowId = 1),
),
Folder(
bucketId = A_SECOND_BUCKET_ID,
title = A_DISPLAY_NAME,
thumbnail = fakeUriAppender.get(rowId = 3),
),
)
result[0].itemCount shouldBeEqualTo 2
result[1].itemCount shouldBeEqualTo 1
}
private fun CreateCursorScope.addFolderRow(rowId: Long, bucketId: String) {
addRow(
MediaStore.Images.Media._ID to rowId,
MediaStore.Images.Media.BUCKET_ID to bucketId,
MediaStore.Images.Media.BUCKET_DISPLAY_NAME to A_DISPLAY_NAME,
MediaStore.Images.Media.DATE_MODIFIED to A_DATE_MODIFIED,
)
}
}
private fun FakeContentResolver.givenFolderQuery() = this.givenQueryResult(
A_EXTERNAL_CONTENT_URI.instance,
arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.BUCKET_ID,
MediaStore.Images.Media.BUCKET_DISPLAY_NAME,
MediaStore.Images.Media.DATE_MODIFIED
),
"${isNotPending()} AND ${MediaStore.Images.Media.BUCKET_ID} AND ${MediaStore.Images.Media.MIME_TYPE} NOT LIKE ?",
arrayOf("%image/svg%"),
"${MediaStore.Images.Media.BUCKET_DISPLAY_NAME} COLLATE NOCASE ASC, ${MediaStore.Images.Media.DATE_MODIFIED} DESC",
)
class FakeUriAppender : (Uri, Long) -> Uri {
private val uris = mutableMapOf<Long, FakeUri>()
override fun invoke(uri: Uri, rowId: Long): Uri {
val fakeUri = FakeUri()
uris[rowId] = fakeUri
return fakeUri.instance
}
fun get(rowId: Long) = uris[rowId]!!.instance
}

View File

@ -10,6 +10,7 @@ import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test import org.junit.Test
private val A_EXTERNAL_CONTENT_URI = FakeUri() private val A_EXTERNAL_CONTENT_URI = FakeUri()
private val ROW_URI = FakeUri()
private const val A_BUCKET_ID = "a-bucket-id" private const val A_BUCKET_ID = "a-bucket-id"
private const val A_ROW_ID = 20L private const val A_ROW_ID = 20L
private const val A_MIME_TYPE = "image/png" private const val A_MIME_TYPE = "image/png"
@ -23,9 +24,8 @@ private const val A_HEIGHT = 750
class FetchMediaUseCaseTest { class FetchMediaUseCaseTest {
private val fakeContentResolver = FakeContentResolver() private val fakeContentResolver = FakeContentResolver()
private val appendedUri = FakeUri() private val uriAvoidance = MediaUriAvoidance(
private val uriAvoidance = FetchMediaUseCase.UriAvoidance( uriAppender = { _, _ -> ROW_URI.instance },
uriAppender = { _, _ -> appendedUri.instance },
externalContentUri = A_EXTERNAL_CONTENT_URI.instance, externalContentUri = A_EXTERNAL_CONTENT_URI.instance,
) )
@ -50,7 +50,7 @@ class FetchMediaUseCaseTest {
result shouldBeEqualTo listOf( result shouldBeEqualTo listOf(
Media( Media(
id = A_ROW_ID, id = A_ROW_ID,
uri = appendedUri.instance, uri = ROW_URI.instance,
mimeType = A_MIME_TYPE, mimeType = A_MIME_TYPE,
width = A_WIDTH, width = A_WIDTH,
height = A_HEIGHT, height = A_HEIGHT,
@ -79,7 +79,7 @@ class FetchMediaUseCaseTest {
result shouldBeEqualTo listOf( result shouldBeEqualTo listOf(
Media( Media(
id = A_ROW_ID, id = A_ROW_ID,
uri = appendedUri.instance, uri = ROW_URI.instance,
mimeType = A_MIME_TYPE, mimeType = A_MIME_TYPE,
width = A_HEIGHT, width = A_HEIGHT,
height = A_WIDTH, height = A_WIDTH,