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 givenFile(uri: Uri) = every { instance.openInputStream(uri) }.delegateReturn()
fun givenUriResult(uri: Uri) = every { instance.query(uri, null, null, null, null) }.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.getColumnIndex(columnName) } returns columnId
every { instance.getString(columnId) } returns content 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 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 import android.provider.MediaStore
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 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._ID,
MediaStore.Images.Media.MIME_TYPE, MediaStore.Images.Media.MIME_TYPE,
MediaStore.Images.Media.DATE_MODIFIED, 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 ?" 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> { suspend fun getMediaInBucket(bucketId: String): List<Media> {
return dispatchers.withIoContext { return dispatchers.withIoContext {
val media = mutableListOf<Media>() val query = ContentResolverQuery(
val selectionArgs = arrayOf(bucketId, "%image/svg%") uri = uriAvoidance.externalContentUri,
val sortBy = MediaStore.Images.Media.DATE_MODIFIED + " DESC" projection = projection,
val contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI selection = selection,
contentResolver.query(contentUri, projection, selection, selectionArgs, sortBy).use { cursor -> selectionArgs = listOf(bucketId, "%image/svg%"),
while (cursor != null && cursor.moveToNext()) { sortBy = MediaStore.Images.Media.DATE_MODIFIED + " DESC",
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)) contentResolver.reduce(query) { cursor ->
val date = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_MODIFIED)) val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID))
val orientation = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.ORIENTATION)) val uri = uriAvoidance.uriAppender(query.uri, rowId)
val width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation))) val mimetype = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE))
val height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation))) val date = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_MODIFIED))
val size = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)) val orientation = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.ORIENTATION))
media.add(Media(rowId, uri, mimetype, width, height, size, date)) 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) = 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

@ -1,7 +1,13 @@
package app.dapk.st.messenger.gallery package app.dapk.st.messenger.gallery
import android.content.ContentResolver 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.ImageGalleryState
import app.dapk.st.messenger.gallery.state.imageGalleryReducer import app.dapk.st.messenger.gallery.state.imageGalleryReducer
@ -14,7 +20,14 @@ class ImageGalleryModule(
imageGalleryReducer( imageGalleryReducer(
roomName = roomName, roomName = roomName,
FetchMediaFoldersUseCase(contentResolver, dispatchers), 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(), 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",
)