Skip to content

Commit

Permalink
Merge pull request #244 from ouchadam/tech/gallery-tests
Browse files Browse the repository at this point in the history
Tech/Image Gallery Tests
  • Loading branch information
ouchadam committed Nov 3, 2022
2 parents 40534bc + b1c5481 commit 78a4cbd
Show file tree
Hide file tree
Showing 12 changed files with 471 additions and 65 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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> {
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()) {
accumulator = operation(accumulator, cursor)
}
}
return accumulator
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
52 changes: 52 additions & 0 deletions domains/android/stub/src/testFixtures/kotlin/fake/FakeCursor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
17 changes: 14 additions & 3 deletions domains/state/src/testFixtures/kotlin/test/ReducerTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ class ReducerTestScope<S, E>(
private val expectTestScope: ExpectTestScope
) : ExpectTestScope by expectTestScope, Reducer<S> {

private var invalidateCapturedState: Boolean = false
private val actionSideEffects = mutableMapOf<Action, () -> S>()
private var manualState: S? = null
private var capturedResult: S? = null

Expand All @@ -47,22 +49,31 @@ class ReducerTestScope<S, E>(
override val coroutineScope = CoroutineScope(UnconfinedTestDispatcher())
override fun dispatch(action: Action) {
actionCaptures.add(action)

if (actionSideEffects.containsKey(action)) {
setState(actionSideEffects.getValue(action).invoke(), invalidateCapturedState = true)
}
}

override fun getState() = manualState ?: reducerFactory.initialState()
}
private val reducer: Reducer<S> = reducerFactory.create(reducerScope)

override fun reduce(action: Action) = reducer.reduce(action).also {
capturedResult = it
capturedResult = if (invalidateCapturedState) manualState else it
}

fun actionSideEffect(action: Action, handler: () -> S) {
actionSideEffects[action] = handler
}

fun setState(state: S) {
fun setState(state: S, invalidateCapturedState: Boolean = false) {
manualState = state
this.invalidateCapturedState = invalidateCapturedState
}

fun setState(block: (S) -> S) {
manualState = block(reducerScope.getState())
setState(block(reducerScope.getState()))
}

fun assertInitialState(expected: S) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 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()
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"
)

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()
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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: MediaUriAvoidance,
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,
Expand All @@ -22,34 +27,33 @@ 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
}
}

private fun getWidthColumn(orientation: Int) = if (orientation == 0 || orientation == 180) MediaStore.Images.Media.WIDTH else MediaStore.Images.Media.HEIGHT

private fun getHeightColumn(orientation: Int) =
if (orientation == 0 || orientation == 180) MediaStore.Images.Media.HEIGHT else MediaStore.Images.Media.WIDTH

}

data class Media(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
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.state.ImageGalleryState
import app.dapk.st.messenger.gallery.state.imageGalleryReducer

Expand All @@ -11,10 +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, dispatchers),
FetchMediaFoldersUseCase(contentResolver, uriAvoidance, dispatchers),
FetchMediaUseCase(contentResolver, uriAvoidance, dispatchers),
JobBag(),
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package app.dapk.st.messenger.gallery

import android.net.Uri

class MediaUriAvoidance(
val uriAppender: (Uri, Long) -> Uri,
val externalContentUri: Uri,
)
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ fun imageGalleryReducer(
parent = ImageGalleryPage.Routes.folders,
state = ImageGalleryPage.Files(Lce.Loading(), action.folder)
)

dispatch(PageAction.GoTo(page))

jobBag.replace(ImageGalleryPage.Files::class, coroutineScope.launch {
Expand All @@ -58,7 +57,7 @@ fun imageGalleryReducer(
},

sideEffect(PageStateChange.ChangePage::class) { action, _ ->
jobBag.cancel(action.previous::class)
jobBag.cancel(action.previous.state::class)
},
)
}
Expand Down
Loading

0 comments on commit 78a4cbd

Please sign in to comment.