diff options
| author | 2023-03-31 10:47:07 +0000 | |
|---|---|---|
| committer | 2023-03-31 10:47:07 +0000 | |
| commit | 0c5af730932c93e92d8db70f6a93b420db05164f (patch) | |
| tree | 56a227d34f1a799f98953189219e704ff61b8cec | |
| parent | 58e26a22b875b4c23a4469a60abdbabb4d9346ee (diff) | |
| parent | 7b600d1540ab730162d611aea6f3666027c14daa (diff) | |
Merge "Implement common ImageLoader in SystemUI" into udc-dev
| -rw-r--r-- | packages/SystemUI/src/com/android/systemui/graphics/ImageLoader.kt | 493 | ||||
| -rw-r--r-- | packages/SystemUI/tests/res/drawable-nodpi/romainguy_rockaway.jpg | bin | 0 -> 414841 bytes | |||
| -rw-r--r-- | packages/SystemUI/tests/src/com/android/systemui/graphics/ImageLoaderTest.kt | 346 |
3 files changed, 839 insertions, 0 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/graphics/ImageLoader.kt b/packages/SystemUI/src/com/android/systemui/graphics/ImageLoader.kt new file mode 100644 index 000000000000..801b1652e487 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/graphics/ImageLoader.kt @@ -0,0 +1,493 @@ +package com.android.systemui.graphics + +import android.annotation.AnyThread +import android.annotation.DrawableRes +import android.annotation.Px +import android.annotation.SuppressLint +import android.annotation.WorkerThread +import android.content.Context +import android.content.pm.PackageManager +import android.content.res.Resources +import android.content.res.Resources.NotFoundException +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.graphics.ImageDecoder.DecodeException +import android.graphics.drawable.AdaptiveIconDrawable +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.Icon +import android.util.Log +import android.util.Size +import androidx.core.content.res.ResourcesCompat +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Background +import java.io.IOException +import javax.inject.Inject +import kotlin.math.min +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext + +/** + * Helper class to load images for SystemUI. It allows for memory efficient image loading with size + * restriction and attempts to use hardware bitmaps when sensible. + */ +@SysUISingleton +class ImageLoader +@Inject +constructor( + private val defaultContext: Context, + @Background private val backgroundDispatcher: CoroutineDispatcher +) { + + /** Source of the image data. */ + sealed interface Source + + /** + * Load image from a Resource ID. If the resource is part of another package or if it requires + * tinting, pass in a correct [Context]. + */ + data class Res(@DrawableRes val resId: Int, val context: Context?) : Source { + constructor(@DrawableRes resId: Int) : this(resId, null) + } + + /** Load image from a Uri. */ + data class Uri(val uri: android.net.Uri) : Source { + constructor(uri: String) : this(android.net.Uri.parse(uri)) + } + + /** Load image from a [File]. */ + data class File(val file: java.io.File) : Source { + constructor(path: String) : this(java.io.File(path)) + } + + /** Load image from an [InputStream]. */ + data class InputStream(val inputStream: java.io.InputStream, val context: Context?) : Source { + constructor(inputStream: java.io.InputStream) : this(inputStream, null) + } + + /** + * Loads passed [Source] on a background thread and returns the [Bitmap]. + * + * Maximum height and width can be passed as optional parameters - the image decoder will make + * sure to keep the decoded drawable size within those passed constraints while keeping aspect + * ratio. + * + * @param maxWidth Maximum width of the returned drawable (if able). 0 means no restriction. Set + * to [DEFAULT_MAX_SAFE_BITMAP_SIZE_PX] by default. + * @param maxHeight Maximum height of the returned drawable (if able). 0 means no restriction. + * Set to [DEFAULT_MAX_SAFE_BITMAP_SIZE_PX] by default. + * @param allocator Allocator to use for the loaded drawable - one of [ImageDecoder] allocator + * ints. Use [ImageDecoder.ALLOCATOR_SOFTWARE] to force software bitmap. + * @return loaded [Bitmap] or `null` if loading failed. + */ + @AnyThread + suspend fun loadBitmap( + source: Source, + @Px maxWidth: Int = DEFAULT_MAX_SAFE_BITMAP_SIZE_PX, + @Px maxHeight: Int = DEFAULT_MAX_SAFE_BITMAP_SIZE_PX, + allocator: Int = ImageDecoder.ALLOCATOR_DEFAULT + ): Bitmap? = + withContext(backgroundDispatcher) { loadBitmapSync(source, maxWidth, maxHeight, allocator) } + + /** + * Loads passed [Source] synchronously and returns the [Bitmap]. + * + * Maximum height and width can be passed as optional parameters - the image decoder will make + * sure to keep the decoded drawable size within those passed constraints while keeping aspect + * ratio. + * + * @param maxWidth Maximum width of the returned drawable (if able). 0 means no restriction. Set + * to [DEFAULT_MAX_SAFE_BITMAP_SIZE_PX] by default. + * @param maxHeight Maximum height of the returned drawable (if able). 0 means no restriction. + * Set to [DEFAULT_MAX_SAFE_BITMAP_SIZE_PX] by default. + * @param allocator Allocator to use for the loaded drawable - one of [ImageDecoder] allocator + * ints. Use [ImageDecoder.ALLOCATOR_SOFTWARE] to force software bitmap. + * @return loaded [Bitmap] or `null` if loading failed. + */ + @WorkerThread + fun loadBitmapSync( + source: Source, + @Px maxWidth: Int = DEFAULT_MAX_SAFE_BITMAP_SIZE_PX, + @Px maxHeight: Int = DEFAULT_MAX_SAFE_BITMAP_SIZE_PX, + allocator: Int = ImageDecoder.ALLOCATOR_DEFAULT + ): Bitmap? { + return try { + loadBitmapSync( + toImageDecoderSource(source, defaultContext), + maxWidth, + maxHeight, + allocator + ) + } catch (e: NotFoundException) { + Log.w(TAG, "Couldn't load resource $source", e) + null + } + } + + /** + * Loads passed [ImageDecoder.Source] synchronously and returns the drawable. + * + * Maximum height and width can be passed as optional parameters - the image decoder will make + * sure to keep the decoded drawable size within those passed constraints (while keeping aspect + * ratio). + * + * @param maxWidth Maximum width of the returned drawable (if able). 0 means no restriction. Set + * to [DEFAULT_MAX_SAFE_BITMAP_SIZE_PX] by default. + * @param maxHeight Maximum height of the returned drawable (if able). 0 means no restriction. + * Set to [DEFAULT_MAX_SAFE_BITMAP_SIZE_PX] by default. + * @param allocator Allocator to use for the loaded drawable - one of [ImageDecoder] allocator + * ints. Use [ImageDecoder.ALLOCATOR_SOFTWARE] to force software bitmap. + * @return loaded [Bitmap] or `null` if loading failed. + */ + @WorkerThread + fun loadBitmapSync( + source: ImageDecoder.Source, + @Px maxWidth: Int = DEFAULT_MAX_SAFE_BITMAP_SIZE_PX, + @Px maxHeight: Int = DEFAULT_MAX_SAFE_BITMAP_SIZE_PX, + allocator: Int = ImageDecoder.ALLOCATOR_DEFAULT + ): Bitmap? { + return try { + ImageDecoder.decodeBitmap(source) { decoder, info, _ -> + configureDecoderForMaximumSize(decoder, info.size, maxWidth, maxHeight) + decoder.allocator = allocator + } + } catch (e: IOException) { + Log.w(TAG, "Failed to load source $source", e) + return null + } catch (e: DecodeException) { + Log.w(TAG, "Failed to decode source $source", e) + return null + } + } + + /** + * Loads passed [Source] on a background thread and returns the [Drawable]. + * + * Maximum height and width can be passed as optional parameters - the image decoder will make + * sure to keep the decoded drawable size within those passed constraints (while keeping aspect + * ratio). + * + * @param maxWidth Maximum width of the returned drawable (if able). 0 means no restriction. Set + * to [DEFAULT_MAX_SAFE_BITMAP_SIZE_PX] by default. + * @param maxHeight Maximum height of the returned drawable (if able). 0 means no restriction. + * Set to [DEFAULT_MAX_SAFE_BITMAP_SIZE_PX] by default. + * @param allocator Allocator to use for the loaded drawable - one of [ImageDecoder] allocator + * ints. Use [ImageDecoder.ALLOCATOR_SOFTWARE] to force software bitmap. + * @return loaded [Drawable] or `null` if loading failed. + */ + @AnyThread + suspend fun loadDrawable( + source: Source, + @Px maxWidth: Int = DEFAULT_MAX_SAFE_BITMAP_SIZE_PX, + @Px maxHeight: Int = DEFAULT_MAX_SAFE_BITMAP_SIZE_PX, + allocator: Int = ImageDecoder.ALLOCATOR_DEFAULT + ): Drawable? = + withContext(backgroundDispatcher) { + loadDrawableSync(source, maxWidth, maxHeight, allocator) + } + + /** + * Loads passed [Icon] on a background thread and returns the drawable. + * + * Maximum height and width can be passed as optional parameters - the image decoder will make + * sure to keep the decoded drawable size within those passed constraints (while keeping aspect + * ratio). + * + * @param context Alternate context to use for resource loading (for e.g. cross-process use) + * @param maxWidth Maximum width of the returned drawable (if able). 0 means no restriction. Set + * to [DEFAULT_MAX_SAFE_BITMAP_SIZE_PX] by default. + * @param maxHeight Maximum height of the returned drawable (if able). 0 means no restriction. + * Set to [DEFAULT_MAX_SAFE_BITMAP_SIZE_PX] by default. + * @param allocator Allocator to use for the loaded drawable - one of [ImageDecoder] allocator + * ints. Use [ImageDecoder.ALLOCATOR_SOFTWARE] to force software bitmap. + * @return loaded [Drawable] or `null` if loading failed. + */ + @AnyThread + suspend fun loadDrawable( + icon: Icon, + context: Context = defaultContext, + @Px maxWidth: Int = DEFAULT_MAX_SAFE_BITMAP_SIZE_PX, + @Px maxHeight: Int = DEFAULT_MAX_SAFE_BITMAP_SIZE_PX, + allocator: Int = ImageDecoder.ALLOCATOR_DEFAULT + ): Drawable? = + withContext(backgroundDispatcher) { + loadDrawableSync(icon, context, maxWidth, maxHeight, allocator) + } + + /** + * Loads passed [Source] synchronously and returns the drawable. + * + * Maximum height and width can be passed as optional parameters - the image decoder will make + * sure to keep the decoded drawable size within those passed constraints (while keeping aspect + * ratio). + * + * @param maxWidth Maximum width of the returned drawable (if able). 0 means no restriction. Set + * to [DEFAULT_MAX_SAFE_BITMAP_SIZE_PX] by default. + * @param maxHeight Maximum height of the returned drawable (if able). 0 means no restriction. + * Set to [DEFAULT_MAX_SAFE_BITMAP_SIZE_PX] by default. + * @param allocator Allocator to use for the loaded drawable - one of [ImageDecoder] allocator + * ints. Use [ImageDecoder.ALLOCATOR_SOFTWARE] to force software bitmap. + * @return loaded [Drawable] or `null` if loading failed. + */ + @WorkerThread + @SuppressLint("UseCompatLoadingForDrawables") + fun loadDrawableSync( + source: Source, + @Px maxWidth: Int = DEFAULT_MAX_SAFE_BITMAP_SIZE_PX, + @Px maxHeight: Int = DEFAULT_MAX_SAFE_BITMAP_SIZE_PX, + allocator: Int = ImageDecoder.ALLOCATOR_DEFAULT + ): Drawable? { + return try { + loadDrawableSync( + toImageDecoderSource(source, defaultContext), + maxWidth, + maxHeight, + allocator + ) + ?: + // If we have a resource, retry fallback using the "normal" Resource loading system. + // This will come into effect in cases like trying to load AnimatedVectorDrawable. + if (source is Res) { + val context = source.context ?: defaultContext + ResourcesCompat.getDrawable(context.resources, source.resId, context.theme) + } else { + null + } + } catch (e: NotFoundException) { + Log.w(TAG, "Couldn't load resource $source", e) + null + } + } + + /** + * Loads passed [ImageDecoder.Source] synchronously and returns the drawable. + * + * Maximum height and width can be passed as optional parameters - the image decoder will make + * sure to keep the decoded drawable size within those passed constraints (while keeping aspect + * ratio). + * + * @param maxWidth Maximum width of the returned drawable (if able). 0 means no restriction. Set + * to [DEFAULT_MAX_SAFE_BITMAP_SIZE_PX] by default. + * @param maxHeight Maximum height of the returned drawable (if able). 0 means no restriction. + * Set to [DEFAULT_MAX_SAFE_BITMAP_SIZE_PX] by default. + * @param allocator Allocator to use for the loaded drawable - one of [ImageDecoder] allocator + * ints. Use [ImageDecoder.ALLOCATOR_SOFTWARE] to force software bitmap. + * @return loaded [Drawable] or `null` if loading failed. + */ + @WorkerThread + fun loadDrawableSync( + source: ImageDecoder.Source, + @Px maxWidth: Int = DEFAULT_MAX_SAFE_BITMAP_SIZE_PX, + @Px maxHeight: Int = DEFAULT_MAX_SAFE_BITMAP_SIZE_PX, + allocator: Int = ImageDecoder.ALLOCATOR_DEFAULT + ): Drawable? { + return try { + ImageDecoder.decodeDrawable(source) { decoder, info, _ -> + configureDecoderForMaximumSize(decoder, info.size, maxWidth, maxHeight) + decoder.allocator = allocator + } + } catch (e: IOException) { + Log.w(TAG, "Failed to load source $source", e) + return null + } catch (e: DecodeException) { + Log.w(TAG, "Failed to decode source $source", e) + return null + } + } + + /** Loads icon drawable while attempting to size restrict the drawable. */ + @WorkerThread + fun loadDrawableSync( + icon: Icon, + context: Context = defaultContext, + @Px maxWidth: Int = DEFAULT_MAX_SAFE_BITMAP_SIZE_PX, + @Px maxHeight: Int = DEFAULT_MAX_SAFE_BITMAP_SIZE_PX, + allocator: Int = ImageDecoder.ALLOCATOR_DEFAULT + ): Drawable? { + return when (icon.type) { + Icon.TYPE_URI, + Icon.TYPE_URI_ADAPTIVE_BITMAP -> { + val source = ImageDecoder.createSource(context.contentResolver, icon.uri) + loadDrawableSync(source, maxWidth, maxHeight, allocator) + } + Icon.TYPE_RESOURCE -> { + val resources = resolveResourcesForIcon(context, icon) + resources?.let { + loadDrawableSync( + ImageDecoder.createSource(it, icon.resId), + maxWidth, + maxHeight, + allocator + ) + } + // Fallback to non-ImageDecoder load if the attempt failed (e.g. the resource + // is a Vector drawable which ImageDecoder doesn't support.) + ?: icon.loadDrawable(context) + } + Icon.TYPE_BITMAP -> { + BitmapDrawable(context.resources, icon.bitmap) + } + Icon.TYPE_ADAPTIVE_BITMAP -> { + AdaptiveIconDrawable(null, BitmapDrawable(context.resources, icon.bitmap)) + } + Icon.TYPE_DATA -> { + loadDrawableSync( + ImageDecoder.createSource(icon.dataBytes, icon.dataOffset, icon.dataLength), + maxWidth, + maxHeight, + allocator + ) + } + else -> { + // We don't recognize this icon, just fallback. + icon.loadDrawable(context) + } + }?.let { drawable -> + // Icons carry tint which we need to propagate down to a Drawable. + tintDrawable(icon, drawable) + drawable + } + } + + companion object { + const val TAG = "ImageLoader" + + // 4096 is a reasonable default - most devices will support 4096x4096 texture size for + // Canvas rendering and by default we SystemUI has no need to render larger bitmaps. + // This prevents exceptions and crashes if the code accidentally loads larger Bitmap + // and then attempts to render it on Canvas. + // It can always be overridden by the parameters. + const val DEFAULT_MAX_SAFE_BITMAP_SIZE_PX = 4096 + + /** + * This constant signals that ImageLoader shouldn't attempt to resize the passed bitmap in a + * given dimension. + * + * Set both maxWidth and maxHeight to [DO_NOT_RESIZE] if you wish to prevent resizing. + */ + const val DO_NOT_RESIZE = 0 + + /** Maps [Source] to [ImageDecoder.Source]. */ + private fun toImageDecoderSource(source: Source, defaultContext: Context) = + when (source) { + is Res -> { + val context = source.context ?: defaultContext + ImageDecoder.createSource(context.resources, source.resId) + } + is File -> ImageDecoder.createSource(source.file) + is Uri -> ImageDecoder.createSource(defaultContext.contentResolver, source.uri) + is InputStream -> { + val context = source.context ?: defaultContext + ImageDecoder.createSource(context.resources, source.inputStream) + } + } + + /** + * This sets target size on the image decoder to conform to the maxWidth / maxHeight + * parameters. The parameters are chosen to keep the existing drawable aspect ratio. + */ + @AnyThread + private fun configureDecoderForMaximumSize( + decoder: ImageDecoder, + imgSize: Size, + @Px maxWidth: Int, + @Px maxHeight: Int + ) { + if (maxWidth == DO_NOT_RESIZE && maxHeight == DO_NOT_RESIZE) { + return + } + + if (imgSize.width <= maxWidth && imgSize.height <= maxHeight) { + return + } + + // Determine the scale factor for each dimension so it fits within the set constraint + val wScale = + if (maxWidth <= 0) { + 1.0f + } else { + maxWidth.toFloat() / imgSize.width.toFloat() + } + + val hScale = + if (maxHeight <= 0) { + 1.0f + } else { + maxHeight.toFloat() / imgSize.height.toFloat() + } + + // Scale down to the dimension that demands larger scaling (smaller scale factor). + // Use the same scale for both dimensions to keep the aspect ratio. + val scale = min(wScale, hScale) + if (scale < 1.0f) { + val targetWidth = (imgSize.width * scale).toInt() + val targetHeight = (imgSize.height * scale).toInt() + if (Log.isLoggable(TAG, Log.DEBUG)) { + Log.d(TAG, "Configured image size to $targetWidth x $targetHeight") + } + + decoder.setTargetSize(targetWidth, targetHeight) + } + } + + /** + * Attempts to retrieve [Resources] class required to load the passed icon. Icons can + * originate from other processes so we need to make sure we load them from the right + * package source. + * + * @return [Resources] to load the icon drawble or null if icon doesn't carry a resource or + * the resource package couldn't be resolved. + */ + @WorkerThread + private fun resolveResourcesForIcon(context: Context, icon: Icon): Resources? { + if (icon.type != Icon.TYPE_RESOURCE) { + return null + } + + val resources = icon.resources + if (resources != null) { + return resources + } + + val resPackage = icon.resPackage + if ( + resPackage == null || resPackage.isEmpty() || context.packageName.equals(resPackage) + ) { + return context.resources + } + + if ("android" == resPackage) { + return Resources.getSystem() + } + + val pm = context.packageManager + try { + val ai = + pm.getApplicationInfo( + resPackage, + PackageManager.MATCH_UNINSTALLED_PACKAGES or + PackageManager.GET_SHARED_LIBRARY_FILES + ) + if (ai != null) { + return pm.getResourcesForApplication(ai) + } else { + Log.w(TAG, "Failed to resolve application info for $resPackage") + } + } catch (e: PackageManager.NameNotFoundException) { + Log.w(TAG, "Failed to resolve resource package", e) + return null + } + return null + } + + /** Applies tinting from [Icon] to the passed [Drawable]. */ + @AnyThread + private fun tintDrawable(icon: Icon, drawable: Drawable) { + if (icon.hasTint()) { + drawable.mutate() + drawable.setTintList(icon.tintList) + drawable.setTintBlendMode(icon.tintBlendMode) + } + } + } +} diff --git a/packages/SystemUI/tests/res/drawable-nodpi/romainguy_rockaway.jpg b/packages/SystemUI/tests/res/drawable-nodpi/romainguy_rockaway.jpg Binary files differnew file mode 100644 index 000000000000..68473ba6c962 --- /dev/null +++ b/packages/SystemUI/tests/res/drawable-nodpi/romainguy_rockaway.jpg diff --git a/packages/SystemUI/tests/src/com/android/systemui/graphics/ImageLoaderTest.kt b/packages/SystemUI/tests/src/com/android/systemui/graphics/ImageLoaderTest.kt new file mode 100644 index 000000000000..ccd631ec37d0 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/graphics/ImageLoaderTest.kt @@ -0,0 +1,346 @@ +package com.android.systemui.graphics + +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.ImageDecoder +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.Icon +import android.graphics.drawable.VectorDrawable +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.R +import com.android.systemui.SysuiTestCase +import com.google.common.truth.Truth.assertThat +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@kotlinx.coroutines.ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class ImageLoaderTest : SysuiTestCase() { + + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) + private val imageLoader = ImageLoader(context, testDispatcher) + + private lateinit var imgFile: File + + @Before + fun setUp() { + val context = context.createPackageContext("com.android.systemui.tests", 0) + val bitmap = + BitmapFactory.decodeResource( + context.resources, + com.android.systemui.tests.R.drawable.romainguy_rockaway + ) + + imgFile = File.createTempFile("image", ".png", context.cacheDir) + imgFile.deleteOnExit() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, FileOutputStream(imgFile)) + } + + @After + fun tearDown() { + imgFile.delete() + } + + @Test + fun invalidResource_drawable_returnsNull() = + testScope.runTest { assertThat(imageLoader.loadDrawable(ImageLoader.Res(-1))).isNull() } + + @Test + fun invalidResource_bitmap_returnsNull() = + testScope.runTest { assertThat(imageLoader.loadBitmap(ImageLoader.Res(-1))).isNull() } + + @Test + fun invalidUri_returnsNull() = + testScope.runTest { + assertThat(imageLoader.loadBitmap(ImageLoader.Uri("this.is/bogus"))).isNull() + } + + @Test + fun invalidFile_returnsNull() = + testScope.runTest { + assertThat(imageLoader.loadBitmap(ImageLoader.File("this is broken!"))).isNull() + } + + @Test + fun invalidIcon_returnsNull() = + testScope.runTest { + assertThat(imageLoader.loadDrawable(Icon.createWithFilePath("this is broken"))).isNull() + } + + @Test + fun invalidIS_returnsNull() = + testScope.runTest { + assertThat( + imageLoader.loadDrawable( + ImageLoader.InputStream(ByteArrayInputStream(ByteArray(0))) + ) + ) + .isNull() + } + + @Test + fun validBitmapResource_loadDrawable_returnsBitmapDrawable() = + testScope.runTest { + val context = context.createPackageContext("com.android.systemui.tests", 0) + val bitmap = + BitmapFactory.decodeResource( + context.resources, + com.android.systemui.tests.R.drawable.romainguy_rockaway + ) + assertThat(bitmap).isNotNull() + val loadedDrawable = + imageLoader.loadDrawable( + ImageLoader.Res( + com.android.systemui.tests.R.drawable.romainguy_rockaway, + context + ) + ) + assertBitmapEqualToDrawable(loadedDrawable, bitmap) + } + + @Test + fun validBitmapResource_loadBitmap_returnsBitmapDrawable() = + testScope.runTest { + val bitmap = + BitmapFactory.decodeResource( + context.resources, + R.drawable.dessert_zombiegingerbread + ) + val loadedBitmap = + imageLoader.loadBitmap(ImageLoader.Res(R.drawable.dessert_zombiegingerbread)) + assertBitmapEqualToBitmap(loadedBitmap, bitmap) + } + + @Test + fun validBitmapUri_returnsBitmapDrawable() = + testScope.runTest { + val bitmap = + BitmapFactory.decodeResource( + context.resources, + R.drawable.dessert_zombiegingerbread + ) + + val uri = + "android.resource://${context.packageName}/${R.drawable.dessert_zombiegingerbread}" + val loadedBitmap = imageLoader.loadBitmap(ImageLoader.Uri(uri)) + assertBitmapEqualToBitmap(loadedBitmap, bitmap) + } + + @Test + fun validBitmapFile_returnsBitmapDrawable() = + testScope.runTest { + val bitmap = BitmapFactory.decodeFile(imgFile.absolutePath) + val loadedBitmap = imageLoader.loadBitmap(ImageLoader.File(imgFile)) + assertBitmapEqualToBitmap(loadedBitmap, bitmap) + } + + @Test + fun validInputStream_returnsBitmapDrawable() = + testScope.runTest { + val bitmap = BitmapFactory.decodeFile(imgFile.absolutePath) + val loadedBitmap = + imageLoader.loadBitmap(ImageLoader.InputStream(FileInputStream(imgFile))) + assertBitmapEqualToBitmap(loadedBitmap, bitmap) + } + + @Test + fun validBitmapIcon_returnsBitmapDrawable() = + testScope.runTest { + val bitmap = + BitmapFactory.decodeResource( + context.resources, + R.drawable.dessert_zombiegingerbread + ) + val loadedDrawable = imageLoader.loadDrawable(Icon.createWithBitmap(bitmap)) + assertBitmapEqualToDrawable(loadedDrawable, bitmap) + } + + @Test + fun validUriIcon_returnsBitmapDrawable() = + testScope.runTest { + val bitmap = + BitmapFactory.decodeResource( + context.resources, + R.drawable.dessert_zombiegingerbread + ) + val uri = + "android.resource://${context.packageName}/${R.drawable.dessert_zombiegingerbread}" + val loadedDrawable = imageLoader.loadDrawable(Icon.createWithContentUri(Uri.parse(uri))) + assertBitmapEqualToDrawable(loadedDrawable, bitmap) + } + + @Test + fun validDataIcon_returnsBitmapDrawable() = + testScope.runTest { + val bitmap = + BitmapFactory.decodeResource( + context.resources, + R.drawable.dessert_zombiegingerbread + ) + val bos = + ByteArrayOutputStream( + bitmap.byteCount * 2 + ) // Compressed bitmap should be smaller than its source. + bitmap.compress(Bitmap.CompressFormat.PNG, 100, bos) + + val array = bos.toByteArray() + val loadedDrawable = imageLoader.loadDrawable(Icon.createWithData(array, 0, array.size)) + assertBitmapEqualToDrawable(loadedDrawable, bitmap) + } + + @Test + fun validSystemResourceIcon_returnsBitmapDrawable() = + testScope.runTest { + val bitmap = + Resources.getSystem().getDrawable(android.R.drawable.ic_dialog_alert, context.theme) + val loadedDrawable = + imageLoader.loadDrawable( + Icon.createWithResource("android", android.R.drawable.ic_dialog_alert) + ) + assertBitmapEqualToDrawable(loadedDrawable, (bitmap as BitmapDrawable).bitmap) + } + + @Test + fun invalidDifferentPackageResourceIcon_returnsNull() = + testScope.runTest { + val loadedDrawable = + imageLoader.loadDrawable( + Icon.createWithResource( + "noooope.wrong.package", + R.drawable.dessert_zombiegingerbread + ) + ) + assertThat(loadedDrawable).isNull() + } + + @Test + fun validBitmapResource_widthMoreRestricted_downsizesKeepingAspectRatio() = + testScope.runTest { + val loadedDrawable = + imageLoader.loadDrawable(ImageLoader.File(imgFile), maxWidth = 160, maxHeight = 160) + val loadedBitmap = assertBitmapInDrawable(loadedDrawable) + assertThat(loadedBitmap.width).isEqualTo(160) + assertThat(loadedBitmap.height).isEqualTo(106) + } + + @Test + fun validBitmapResource_heightMoreRestricted_downsizesKeepingAspectRatio() = + testScope.runTest { + val loadedDrawable = + imageLoader.loadDrawable(ImageLoader.File(imgFile), maxWidth = 160, maxHeight = 50) + val loadedBitmap = assertBitmapInDrawable(loadedDrawable) + assertThat(loadedBitmap.width).isEqualTo(74) + assertThat(loadedBitmap.height).isEqualTo(50) + } + + @Test + fun validBitmapResource_onlyWidthRestricted_downsizesKeepingAspectRatio() = + testScope.runTest { + val loadedDrawable = + imageLoader.loadDrawable( + ImageLoader.File(imgFile), + maxWidth = 160, + maxHeight = ImageLoader.DO_NOT_RESIZE + ) + val loadedBitmap = assertBitmapInDrawable(loadedDrawable) + assertThat(loadedBitmap.width).isEqualTo(160) + assertThat(loadedBitmap.height).isEqualTo(106) + } + + @Test + fun validBitmapResource_onlyHeightRestricted_downsizesKeepingAspectRatio() = + testScope.runTest { + val loadedDrawable = + imageLoader.loadDrawable( + ImageLoader.Res(R.drawable.bubble_thumbnail), + maxWidth = ImageLoader.DO_NOT_RESIZE, + maxHeight = 120 + ) + val loadedBitmap = assertBitmapInDrawable(loadedDrawable) + assertThat(loadedBitmap.width).isEqualTo(123) + assertThat(loadedBitmap.height).isEqualTo(120) + } + + @Test + fun validVectorDrawable_loadDrawable_successfullyLoaded() = + testScope.runTest { + val loadedDrawable = imageLoader.loadDrawable(ImageLoader.Res(R.drawable.ic_settings)) + assertThat(loadedDrawable).isNotNull() + assertThat(loadedDrawable).isInstanceOf(VectorDrawable::class.java) + } + + @Test + fun validVectorDrawable_loadBitmap_returnsNull() = + testScope.runTest { + val loadedBitmap = imageLoader.loadBitmap(ImageLoader.Res(R.drawable.ic_settings)) + assertThat(loadedBitmap).isNull() + } + + @Test + fun validVectorDrawableIcon_loadDrawable_successfullyLoaded() = + testScope.runTest { + val loadedDrawable = + imageLoader.loadDrawable(Icon.createWithResource(context, R.drawable.ic_settings)) + assertThat(loadedDrawable).isNotNull() + assertThat(loadedDrawable).isInstanceOf(VectorDrawable::class.java) + } + + @Test + fun hardwareAllocator_returnsHardwareBitmap() = + testScope.runTest { + val loadedDrawable = + imageLoader.loadDrawable( + ImageLoader.File(imgFile), + allocator = ImageDecoder.ALLOCATOR_HARDWARE + ) + assertThat(loadedDrawable).isNotNull() + assertThat((loadedDrawable as BitmapDrawable).bitmap.config) + .isEqualTo(Bitmap.Config.HARDWARE) + } + + @Test + fun softwareAllocator_returnsSoftwareBitmap() = + testScope.runTest { + val loadedDrawable = + imageLoader.loadDrawable( + ImageLoader.File(imgFile), + allocator = ImageDecoder.ALLOCATOR_SOFTWARE + ) + assertThat(loadedDrawable).isNotNull() + assertThat((loadedDrawable as BitmapDrawable).bitmap.config) + .isNotEqualTo(Bitmap.Config.HARDWARE) + } + + private fun assertBitmapInDrawable(drawable: Drawable?): Bitmap { + assertThat(drawable).isNotNull() + assertThat(drawable).isInstanceOf(BitmapDrawable::class.java) + return (drawable as BitmapDrawable).bitmap + } + + private fun assertBitmapEqualToDrawable(actual: Drawable?, expected: Bitmap) { + val actualBitmap = assertBitmapInDrawable(actual) + assertBitmapEqualToBitmap(actualBitmap, expected) + } + + private fun assertBitmapEqualToBitmap(actual: Bitmap?, expected: Bitmap) { + assertThat(actual).isNotNull() + assertThat(actual?.width).isEqualTo(expected.width) + assertThat(actual?.height).isEqualTo(expected.height) + } +} |