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

View File

@ -10,7 +10,7 @@ import app.dapk.st.core.withIoContext
class FetchMediaUseCase(
private val contentResolver: ContentResolver,
private val uriAvoidance: UriAvoidance,
private val uriAvoidance: MediaUriAvoidance,
private val dispatchers: CoroutineDispatchers
) {
@ -54,11 +54,6 @@ class FetchMediaUseCase(
private fun getHeightColumn(orientation: Int) =
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(

View File

@ -7,7 +7,6 @@ import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.JobBag
import app.dapk.st.core.ProvidableModule
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.imageGalleryReducer
@ -17,17 +16,14 @@ class ImageGalleryModule(
) : ProvidableModule {
fun imageGalleryState(roomName: String): ImageGalleryState = createStateViewModel {
val uriAvoidance = MediaUriAvoidance(
uriAppender = { uri, rowId -> ContentUris.withAppendedId(uri, rowId) },
externalContentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
)
imageGalleryReducer(
roomName = roomName,
FetchMediaFoldersUseCase(contentResolver, dispatchers),
FetchMediaUseCase(
contentResolver,
UriAvoidance(
uriAppender = { uri, rowId -> ContentUris.withAppendedId(uri, rowId) },
externalContentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
),
dispatchers,
),
FetchMediaFoldersUseCase(contentResolver, uriAvoidance, dispatchers),
FetchMediaUseCase(contentResolver, uriAvoidance, dispatchers),
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
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_ROW_ID = 20L
private const val A_MIME_TYPE = "image/png"
@ -23,9 +24,8 @@ private const val A_HEIGHT = 750
class FetchMediaUseCaseTest {
private val fakeContentResolver = FakeContentResolver()
private val appendedUri = FakeUri()
private val uriAvoidance = FetchMediaUseCase.UriAvoidance(
uriAppender = { _, _ -> appendedUri.instance },
private val uriAvoidance = MediaUriAvoidance(
uriAppender = { _, _ -> ROW_URI.instance },
externalContentUri = A_EXTERNAL_CONTENT_URI.instance,
)
@ -50,7 +50,7 @@ class FetchMediaUseCaseTest {
result shouldBeEqualTo listOf(
Media(
id = A_ROW_ID,
uri = appendedUri.instance,
uri = ROW_URI.instance,
mimeType = A_MIME_TYPE,
width = A_WIDTH,
height = A_HEIGHT,
@ -79,7 +79,7 @@ class FetchMediaUseCaseTest {
result shouldBeEqualTo listOf(
Media(
id = A_ROW_ID,
uri = appendedUri.instance,
uri = ROW_URI.instance,
mimeType = A_MIME_TYPE,
width = A_HEIGHT,
height = A_WIDTH,