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..0d1161b --- /dev/null +++ b/domains/android/core/src/main/kotlin/app/dapk/st/core/ContentResolverExtensions.kt @@ -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, + val selection: String, + val selectionArgs: List, + val sortBy: String, +) + +inline fun ContentResolver.reduce(query: ContentResolverQuery, operation: (Cursor) -> T): List { + val result = mutableListOf() + 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 +} 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/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..0b0414d 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: 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 { - 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 } } @@ -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( 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..7016eb7 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,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(), ) } 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..3f70485 --- /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 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", +)