diff --git a/domains/android/core/src/main/kotlin/app/dapk/st/core/ContentResolverExtensions.kt b/domains/android/core/src/main/kotlin/app/dapk/st/core/ContentResolverExtensions.kt new file mode 100644 index 0000000..ea6d723 --- /dev/null +++ b/domains/android/core/src/main/kotlin/app/dapk/st/core/ContentResolverExtensions.kt @@ -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, + val selection: String, + val selectionArgs: List, + val sortBy: String, +) + +inline fun ContentResolver.reduce(query: ContentResolverQuery, operation: (Cursor) -> T): List { + return this.reduce(query, mutableListOf()) { acc, cursor -> + acc.add(operation(cursor)) + acc + } +} + +inline fun 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 +} diff --git a/domains/android/stub/src/testFixtures/kotlin/fake/FakeContentResolver.kt b/domains/android/stub/src/testFixtures/kotlin/fake/FakeContentResolver.kt index d353c3f..a62de52 100644 --- a/domains/android/stub/src/testFixtures/kotlin/fake/FakeContentResolver.kt +++ b/domains/android/stub/src/testFixtures/kotlin/fake/FakeContentResolver.kt @@ -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?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ) = every { + instance.query( + uri, + projection, + selection, + selectionArgs, + sortOrder + ) + }.delegateReturn() } diff --git a/domains/android/stub/src/testFixtures/kotlin/fake/FakeCursor.kt b/domains/android/stub/src/testFixtures/kotlin/fake/FakeCursor.kt index 30f2de0..098689c 100644 --- a/domains/android/stub/src/testFixtures/kotlin/fake/FakeCursor.kt +++ b/domains/android/stub/src/testFixtures/kotlin/fake/FakeCursor.kt @@ -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) +} + +fun createCursor(creator: CreateCursorScope.() -> Unit): Cursor { + val content = mutableListOf>() + val scope = object : CreateCursorScope { + override fun addRow(vararg item: Pair) { + content.add(item.toMap()) + } + } + creator(scope) + return StubCursor(content) +} + +private class StubCursor(private val content: List>) : 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 + } } \ No newline at end of file diff --git a/domains/state/src/testFixtures/kotlin/test/ReducerTest.kt b/domains/state/src/testFixtures/kotlin/test/ReducerTest.kt index ebdd7b7..b50846b 100644 --- a/domains/state/src/testFixtures/kotlin/test/ReducerTest.kt +++ b/domains/state/src/testFixtures/kotlin/test/ReducerTest.kt @@ -39,6 +39,8 @@ class ReducerTestScope( private val expectTestScope: ExpectTestScope ) : ExpectTestScope by expectTestScope, Reducer { + private var invalidateCapturedState: Boolean = false + private val actionSideEffects = mutableMapOf S>() private var manualState: S? = null private var capturedResult: S? = null @@ -47,6 +49,10 @@ class ReducerTestScope( 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() @@ -54,15 +60,20 @@ class ReducerTestScope( private val reducer: Reducer = reducerFactory.create(reducerScope) override fun reduce(action: Action) = reducer.reduce(action).also { - capturedResult = it + capturedResult = if (invalidateCapturedState) manualState else it } - fun setState(state: S) { + fun actionSideEffect(action: Action, handler: () -> S) { + actionSideEffects[action] = handler + } + + 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) { diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/FetchMediaFoldersUseCase.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/FetchMediaFoldersUseCase.kt index 1f17102..1c2b241 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/FetchMediaFoldersUseCase.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/FetchMediaFoldersUseCase.kt @@ -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 { 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() - 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()) { 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() } } diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/FetchMediaUseCase.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/FetchMediaUseCase.kt index 7ea73f7..dc81d3c 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/FetchMediaUseCase.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/FetchMediaUseCase.kt @@ -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, @@ -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 { - return dispatchers.withIoContext { - val media = mutableListOf() - 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 } } @@ -49,7 +54,6 @@ 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 - } data class Media( diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryModule.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryModule.kt index b74d9fa..11ef676 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryModule.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/ImageGalleryModule.kt @@ -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 @@ -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(), ) } diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/MediaUriAvoidance.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/MediaUriAvoidance.kt new file mode 100644 index 0000000..4949083 --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/MediaUriAvoidance.kt @@ -0,0 +1,8 @@ +package app.dapk.st.messenger.gallery + +import android.net.Uri + +class MediaUriAvoidance( + val uriAppender: (Uri, Long) -> Uri, + val externalContentUri: Uri, +) \ No newline at end of file diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/state/ImageGalleryReducer.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/state/ImageGalleryReducer.kt index fd37f1b..db75035 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/state/ImageGalleryReducer.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/gallery/state/ImageGalleryReducer.kt @@ -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 { @@ -58,7 +57,7 @@ fun imageGalleryReducer( }, sideEffect(PageStateChange.ChangePage::class) { action, _ -> - jobBag.cancel(action.previous::class) + jobBag.cancel(action.previous.state::class) }, ) } diff --git a/features/messenger/src/test/kotlin/app/dapk/st/messenger/gallery/FetchMediaFoldersUseCaseTest.kt b/features/messenger/src/test/kotlin/app/dapk/st/messenger/gallery/FetchMediaFoldersUseCaseTest.kt new file mode 100644 index 0000000..1331bcb --- /dev/null +++ b/features/messenger/src/test/kotlin/app/dapk/st/messenger/gallery/FetchMediaFoldersUseCaseTest.kt @@ -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() + + override fun invoke(uri: Uri, rowId: Long): Uri { + val fakeUri = FakeUri() + uris[rowId] = fakeUri + return fakeUri.instance + } + + fun get(rowId: Long) = uris[rowId]!!.instance +} \ No newline at end of file diff --git a/features/messenger/src/test/kotlin/app/dapk/st/messenger/gallery/FetchMediaUseCaseTest.kt b/features/messenger/src/test/kotlin/app/dapk/st/messenger/gallery/FetchMediaUseCaseTest.kt new file mode 100644 index 0000000..799a5b4 --- /dev/null +++ b/features/messenger/src/test/kotlin/app/dapk/st/messenger/gallery/FetchMediaUseCaseTest.kt @@ -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 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" +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 uriAvoidance = MediaUriAvoidance( + uriAppender = { _, _ -> ROW_URI.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 = ROW_URI.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 = ROW_URI.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", +) diff --git a/features/messenger/src/test/kotlin/app/dapk/st/messenger/gallery/state/ImageGalleryReducerTest.kt b/features/messenger/src/test/kotlin/app/dapk/st/messenger/gallery/state/ImageGalleryReducerTest.kt index f5fc127..bb684b1 100644 --- a/features/messenger/src/test/kotlin/app/dapk/st/messenger/gallery/state/ImageGalleryReducerTest.kt +++ b/features/messenger/src/test/kotlin/app/dapk/st/messenger/gallery/state/ImageGalleryReducerTest.kt @@ -1,54 +1,131 @@ package app.dapk.st.messenger.gallery.state +import android.net.Uri import app.dapk.st.core.Lce +import app.dapk.st.core.page.PageAction +import app.dapk.st.core.page.PageContainer +import app.dapk.st.core.page.PageStateChange import app.dapk.st.design.components.SpiderPage import app.dapk.st.messenger.gallery.FetchMediaFoldersUseCase import app.dapk.st.messenger.gallery.FetchMediaUseCase -import app.dapk.st.core.page.PageContainer +import app.dapk.st.messenger.gallery.Folder +import app.dapk.st.messenger.gallery.Media import app.dapk.state.Combined2 import fake.FakeJobBag +import fake.FakeUri +import io.mockk.coEvery import io.mockk.mockk import org.junit.Test +import test.assertOnlyDispatches +import test.delegateReturn +import test.expect import test.testReducer private const val A_ROOM_NAME = "a room name" +private val A_FOLDER = Folder( + bucketId = "a-bucket-id", + title = "a title", + thumbnail = FakeUri().instance, +) +private val A_MEDIA_RESULT = listOf(aMedia()) +private val A_FOLDERS_RESULT = listOf(aFolder()) +private val AN_INITIAL_FILES_PAGE = SpiderPage( + route = ImageGalleryPage.Routes.files, + label = "Send to $A_ROOM_NAME", + parent = ImageGalleryPage.Routes.folders, + state = ImageGalleryPage.Files(Lce.Loading(), A_FOLDER) +) + +private val AN_INITIAL_FOLDERS_PAGE = SpiderPage( + route = ImageGalleryPage.Routes.folders, + label = "Send to $A_ROOM_NAME", + parent = null, + state = ImageGalleryPage.Folders(Lce.Loading()) +) class ImageGalleryReducerTest { private val fakeJobBag = FakeJobBag() + private val fakeFetchMediaFoldersUseCase = FakeFetchMediaFoldersUseCase() + private val fakeFetchMediaUseCase = FakeFetchMediaUseCase() private val runReducerTest = testReducer { _: (Unit) -> Unit -> imageGalleryReducer( A_ROOM_NAME, - FakeFetchMediaFoldersUseCase().instance, - FakeFetchMediaUseCase().instance, + fakeFetchMediaFoldersUseCase.instance, + fakeFetchMediaUseCase.instance, fakeJobBag.instance, ) } @Test fun `initial state is folders page`() = runReducerTest { - assertInitialState( - Combined2( - state1 = PageContainer( - SpiderPage( - route = ImageGalleryPage.Routes.folders, - label = "Send to $A_ROOM_NAME", - parent = null, - state = ImageGalleryPage.Folders(Lce.Loading()) - ) - ), - state2 = Unit - ) + assertInitialState(pageState(AN_INITIAL_FOLDERS_PAGE)) + } + + @Test + fun `when Visible, then updates Folders content`() = runReducerTest { + fakeJobBag.instance.expect { it.replace(ImageGalleryPage.Folders::class, any()) } + fakeFetchMediaFoldersUseCase.givenFolders().returns(A_FOLDERS_RESULT) + + reduce(ImageGalleryActions.Visible) + + assertOnlyDispatches( + PageStateChange.UpdatePage(AN_INITIAL_FOLDERS_PAGE.state.copy(content = Lce.Content(A_FOLDERS_RESULT))) ) } + @Test + fun `when SelectFolder, then goes to Folder page and fetches content`() = runReducerTest { + fakeJobBag.instance.expect { it.replace(ImageGalleryPage.Files::class, any()) } + fakeFetchMediaUseCase.givenMedia(A_FOLDER.bucketId).returns(A_MEDIA_RESULT) + val goToFolderPage = PageAction.GoTo(AN_INITIAL_FILES_PAGE) + actionSideEffect(goToFolderPage) { pageState(goToFolderPage.page) } + + reduce(ImageGalleryActions.SelectFolder(A_FOLDER)) + + assertOnlyDispatches( + goToFolderPage, + PageStateChange.UpdatePage(goToFolderPage.page.state.copy(content = Lce.Content(A_MEDIA_RESULT))) + ) + } + + @Test + fun `when ChangePage, then cancels previous page jobs`() = runReducerTest { + fakeJobBag.instance.expect { it.cancel(ImageGalleryPage.Folders::class) } + + reduce(PageStateChange.ChangePage(previous = AN_INITIAL_FOLDERS_PAGE, newPage = AN_INITIAL_FILES_PAGE)) + + assertOnlyStateChange(pageState(AN_INITIAL_FILES_PAGE)) + } } +private fun

pageState(page: SpiderPage) = Combined2(PageContainer(page), Unit) + class FakeFetchMediaFoldersUseCase { val instance = mockk() + + fun givenFolders() = coEvery { instance.fetchFolders() }.delegateReturn() } class FakeFetchMediaUseCase { val instance = mockk() -} \ No newline at end of file + + fun givenMedia(bucketId: String) = coEvery { instance.getMediaInBucket(bucketId) }.delegateReturn() +} + +fun aMedia( + id: Long = 1L, + uri: Uri = FakeUri().instance, + mimeType: String = "image/png", + width: Int = 100, + height: Int = 250, + size: Long = 1000L, + dateModifiedEpochMillis: Long = 5000L, +) = Media(id, uri, mimeType, width, height, size, dateModifiedEpochMillis) + +fun aFolder( + bucketId: String = "a-bucket-id", + title: String = "a title", + thumbnail: Uri = FakeUri().instance, +) = Folder(bucketId, title, thumbnail) \ No newline at end of file