adding media fetching tests

This commit is contained in:
Adam Brown 2022-11-03 10:57:05 +00:00
parent 40534bc581
commit dcf4b4c80a
6 changed files with 243 additions and 23 deletions

View File

@ -0,0 +1,23 @@
package app.dapk.st.core
import android.content.ContentResolver
import android.database.Cursor
import android.net.Uri
data class ContentResolverQuery(
val uri: Uri,
val projection: List<String>,
val selection: String,
val selectionArgs: List<String>,
val sortBy: String,
)
inline fun <T> ContentResolver.reduce(query: ContentResolverQuery, operation: (Cursor) -> T): List<T> {
val result = mutableListOf<T>()
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))
}
}
return result
}

View File

@ -13,4 +13,20 @@ class FakeContentResolver {
fun givenFile(uri: Uri) = every { instance.openInputStream(uri) }.delegateReturn()
fun givenUriResult(uri: Uri) = every { instance.query(uri, null, null, null, null) }.delegateReturn()
fun givenQueryResult(
uri: Uri,
projection: Array<String>?,
selection: String?,
selectionArgs: Array<String>?,
sortOrder: String?,
) = every {
instance.query(
uri,
projection,
selection,
selectionArgs,
sortOrder
)
}.delegateReturn()
}

View File

@ -24,4 +24,56 @@ class FakeCursor {
every { instance.getColumnIndex(columnName) } returns columnId
every { instance.getString(columnId) } returns content
}
}
interface CreateCursorScope {
fun addRow(vararg item: Pair<String, Any?>)
}
fun createCursor(creator: CreateCursorScope.() -> Unit): Cursor {
val content = mutableListOf<Map<String, Any?>>()
val scope = object : CreateCursorScope {
override fun addRow(vararg item: Pair<String, Any?>) {
content.add(item.toMap())
}
}
creator(scope)
return StubCursor(content)
}
private class StubCursor(private val content: List<Map<String, Any?>>) : Cursor by mockk() {
private val columnNames = content.map { it.keys }.flatten().distinct()
private var currentRowIndex = -1
override fun getColumnIndexOrThrow(columnName: String): Int {
return getColumnIndex(columnName).takeIf { it != -1 } ?: throw IllegalArgumentException(columnName)
}
override fun getColumnIndex(columnName: String) = columnNames.indexOf(columnName)
override fun moveToNext() = (currentRowIndex + 1 < content.size).also {
currentRowIndex += 1
}
override fun moveToFirst() = content.isNotEmpty()
override fun getCount() = content.size
override fun getString(index: Int): String? = content[currentRowIndex][columnNames[index]] as? String
override fun getInt(index: Int): Int {
return content[currentRowIndex][columnNames[index]] as? Int ?: throw IllegalArgumentException("Int can't be null")
}
override fun getLong(index: Int): Long {
return content[currentRowIndex][columnNames[index]] as? Long ?: throw IllegalArgumentException("Long can't be null")
}
override fun getColumnCount() = columnNames.size
override fun close() {
// do nothing
}
}

View File

@ -1,15 +1,20 @@
package app.dapk.st.messenger.gallery
import android.content.ContentResolver
import android.content.ContentUris
import android.net.Uri
import android.provider.MediaStore
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 FetchMediaUseCase(private val contentResolver: ContentResolver, private val dispatchers: CoroutineDispatchers) {
class FetchMediaUseCase(
private val contentResolver: ContentResolver,
private val uriAvoidance: UriAvoidance,
private val dispatchers: CoroutineDispatchers
) {
private val projection = arrayOf(
private val projection = listOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.MIME_TYPE,
MediaStore.Images.Media.DATE_MODIFIED,
@ -22,26 +27,26 @@ class FetchMediaUseCase(private val contentResolver: ContentResolver, private va
private val selection = MediaStore.Images.Media.BUCKET_ID + " = ? AND " + isNotPending() + " AND " + MediaStore.Images.Media.MIME_TYPE + " NOT LIKE ?"
suspend fun getMediaInBucket(bucketId: String): List<Media> {
return dispatchers.withIoContext {
val media = mutableListOf<Media>()
val selectionArgs = arrayOf(bucketId, "%image/svg%")
val sortBy = MediaStore.Images.Media.DATE_MODIFIED + " DESC"
val contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
contentResolver.query(contentUri, projection, selection, selectionArgs, sortBy).use { cursor ->
while (cursor != null && cursor.moveToNext()) {
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(projection[0]))
val uri = ContentUris.withAppendedId(contentUri, rowId)
val mimetype = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE))
val date = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_MODIFIED))
val orientation = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.ORIENTATION))
val width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation)))
val height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation)))
val size = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE))
media.add(Media(rowId, uri, mimetype, width, height, size, date))
}
val query = ContentResolverQuery(
uri = uriAvoidance.externalContentUri,
projection = projection,
selection = selection,
selectionArgs = listOf(bucketId, "%image/svg%"),
sortBy = MediaStore.Images.Media.DATE_MODIFIED + " DESC",
)
contentResolver.reduce(query) { cursor ->
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID))
val uri = uriAvoidance.uriAppender(query.uri, rowId)
val mimetype = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE))
val date = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_MODIFIED))
val orientation = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.ORIENTATION))
val width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation)))
val height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation)))
val size = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE))
Media(rowId, uri, mimetype, width, height, size, date)
}
media
}
}
@ -50,6 +55,10 @@ class FetchMediaUseCase(private val contentResolver: ContentResolver, private va
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

@ -1,7 +1,13 @@
package app.dapk.st.messenger.gallery
import android.content.ContentResolver
import app.dapk.st.core.*
import android.content.ContentUris
import android.provider.MediaStore
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
@ -14,7 +20,14 @@ class ImageGalleryModule(
imageGalleryReducer(
roomName = roomName,
FetchMediaFoldersUseCase(contentResolver, dispatchers),
FetchMediaUseCase(contentResolver, dispatchers),
FetchMediaUseCase(
contentResolver,
UriAvoidance(
uriAppender = { uri, rowId -> ContentUris.withAppendedId(uri, rowId) },
externalContentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
),
dispatchers,
),
JobBag(),
)
}

View File

@ -0,0 +1,107 @@
package app.dapk.st.messenger.gallery
import android.provider.MediaStore
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_ROW_ID = 20L
private const val A_MIME_TYPE = "image/png"
private const val A_DATE_MODIFIED = 5000L
private const val A_SIZE = 1000L
private const val A_NORMAL_ORIENTATION = 0
private const val AN_INVERTED_ORIENTATION = 90
private const val A_WIDTH = 250
private const val A_HEIGHT = 750
class FetchMediaUseCaseTest {
private val fakeContentResolver = FakeContentResolver()
private val appendedUri = FakeUri()
private val uriAvoidance = FetchMediaUseCase.UriAvoidance(
uriAppender = { _, _ -> appendedUri.instance },
externalContentUri = A_EXTERNAL_CONTENT_URI.instance,
)
private val useCase = FetchMediaUseCase(fakeContentResolver.instance, uriAvoidance, aCoroutineDispatchers())
@Test
fun `given cursor content, when get media for bucket, then reads media`() = runTest {
fakeContentResolver.givenMediaQuery().returns(createCursor {
addRow(
MediaStore.Images.Media._ID to A_ROW_ID,
MediaStore.Images.Media.MIME_TYPE to A_MIME_TYPE,
MediaStore.Images.Media.DATE_MODIFIED to A_DATE_MODIFIED,
MediaStore.Images.Media.ORIENTATION to A_NORMAL_ORIENTATION,
MediaStore.Images.Media.WIDTH to A_WIDTH,
MediaStore.Images.Media.HEIGHT to A_HEIGHT,
MediaStore.Images.Media.SIZE to A_SIZE,
)
})
val result = useCase.getMediaInBucket(A_BUCKET_ID)
result shouldBeEqualTo listOf(
Media(
id = A_ROW_ID,
uri = appendedUri.instance,
mimeType = A_MIME_TYPE,
width = A_WIDTH,
height = A_HEIGHT,
size = A_SIZE,
dateModifiedEpochMillis = A_DATE_MODIFIED
)
)
}
@Test
fun `given cursor content with 90 degree orientation, when get media for bucket, then reads media with inverted width and height`() = runTest {
fakeContentResolver.givenMediaQuery().returns(createCursor {
addRow(
MediaStore.Images.Media._ID to A_ROW_ID,
MediaStore.Images.Media.MIME_TYPE to A_MIME_TYPE,
MediaStore.Images.Media.DATE_MODIFIED to A_DATE_MODIFIED,
MediaStore.Images.Media.ORIENTATION to AN_INVERTED_ORIENTATION,
MediaStore.Images.Media.WIDTH to A_WIDTH,
MediaStore.Images.Media.HEIGHT to A_HEIGHT,
MediaStore.Images.Media.SIZE to A_SIZE,
)
})
val result = useCase.getMediaInBucket(A_BUCKET_ID)
result shouldBeEqualTo listOf(
Media(
id = A_ROW_ID,
uri = appendedUri.instance,
mimeType = A_MIME_TYPE,
width = A_HEIGHT,
height = A_WIDTH,
size = A_SIZE,
dateModifiedEpochMillis = A_DATE_MODIFIED
)
)
}
}
private fun FakeContentResolver.givenMediaQuery() = this.givenQueryResult(
A_EXTERNAL_CONTENT_URI.instance,
arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.MIME_TYPE,
MediaStore.Images.Media.DATE_MODIFIED,
MediaStore.Images.Media.ORIENTATION,
MediaStore.Images.Media.WIDTH,
MediaStore.Images.Media.HEIGHT,
MediaStore.Images.Media.SIZE
),
MediaStore.Images.Media.BUCKET_ID + " = ? AND " + isNotPending() + " AND " + MediaStore.Images.Media.MIME_TYPE + " NOT LIKE ?",
arrayOf(A_BUCKET_ID, "%image/svg%"),
MediaStore.Images.Media.DATE_MODIFIED + " DESC",
)