Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tile image loaders based on Coil. #217

Merged
merged 4 commits into from
Jun 10, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/tiles.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Tiles Library

## CoroutinesTileService

Provides a CoroutinesTileService, which also acts as a LifecycleService.

```kotlin
Expand All @@ -16,6 +18,19 @@ class ExampleTileService : CoroutinesTileService() {
}
```

## Coil Image Helpers

Provides a suspending method to load an image from the network, convert to an RGB_565
bitmap, and encode as a Tiles InlineImageResource.

```kotlin
val imageResource = imageLoader.loadImageResource(applicationContext,
"https://raw.githubusercontent.com/google/horologist/main/docs/media-ui/playerscreen.png") {
// Show a local error image if missing
error(R.drawable.missingImage)
}
```

## Download

```groovy
Expand Down
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ androidx-test-rules = "androidx.test:rules:1.4.0"
androidx-wear = { module = "androidx.wear:wear", version.ref = "androidxWear" }
androidx-wear-tiles = { module = "androidx.wear.tiles:tiles", version.ref = "androidxtiles" }
androidx-wear-tiles-testing = { module = "androidx.wear.tiles:tiles-testing", version.ref = "androidxtiles" }
coil = "io.coil-kt:coil-compose:2.1.0"
yschimke marked this conversation as resolved.
Show resolved Hide resolved
com-squareup-okhttp3-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "com-squareup-okhttp3" }
compose-foundation-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" }
compose-material-iconscore = { module = "androidx.compose.material:material-icons-core", version.ref = "compose" }
Expand Down
6 changes: 6 additions & 0 deletions tiles/api/current.api
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,11 @@ package com.google.android.horologist.tiles {
method public abstract suspend Object? tileRequest(androidx.wear.tiles.RequestBuilders.TileRequest requestParams, kotlin.coroutines.Continuation<? super androidx.wear.tiles.TileBuilders.Tile> p);
}

public final class ImagesKt {
method public static suspend Object? loadImage(coil.ImageLoader, android.content.Context context, Object? data, optional kotlin.jvm.functions.Function1<? super coil.request.ImageRequest.Builder,kotlin.Unit> configurer, optional kotlin.coroutines.Continuation<? super android.graphics.Bitmap> p);
method public static suspend Object? loadImageResource(coil.ImageLoader, android.content.Context context, Object? data, optional kotlin.jvm.functions.Function1<? super coil.request.ImageRequest.Builder,kotlin.Unit> configurer, optional kotlin.coroutines.Continuation<? super androidx.wear.tiles.ResourceBuilders.ImageResource> p);
method public static androidx.wear.tiles.ResourceBuilders.ImageResource toImageResource(android.graphics.Bitmap);
}

}

3 changes: 3 additions & 0 deletions tiles/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ dependencies {
implementation libs.androidx.wear.tiles
implementation libs.androidx.lifecycle.service

implementation libs.coil

testImplementation libs.androidx.wear.tiles.testing
testImplementation libs.espresso.core
testImplementation libs.junit
Expand All @@ -110,6 +112,7 @@ dependencies {
testImplementation libs.androidx.concurrent.future.ktx

androidTestImplementation libs.androidx.lifecycle.testing
androidTestImplementation libs.kotlinx.coroutines.test
androidTestImplementation libs.truth
debugImplementation libs.compose.ui.test.manifest
androidTestImplementation libs.espresso.core
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.android.horologist.tiles

import android.content.Context
import android.graphics.BitmapFactory
import android.graphics.drawable.BitmapDrawable
import androidx.annotation.DrawableRes
import coil.ComponentRegistry
import coil.ImageLoader
import coil.decode.DataSource
import coil.disk.DiskCache
import coil.memory.MemoryCache
import coil.request.DefaultRequestOptions
import coil.request.Disposable
import coil.request.ErrorResult
import coil.request.ImageRequest
import coil.request.ImageResult
import coil.request.SuccessResult
import java.io.IOException

// https://coil-kt.github.io/coil/image_loaders/#testing
class FakeImageLoader(val imageFn: (ImageRequest) -> ImageResult) : ImageLoader {
override val defaults = DefaultRequestOptions()
override val components = ComponentRegistry()
override val memoryCache: MemoryCache? get() = null
override val diskCache: DiskCache? get() = null

override fun enqueue(request: ImageRequest): Disposable = TODO()

override suspend fun execute(request: ImageRequest): ImageResult {
return imageFn(request)
}

override fun newBuilder(): ImageLoader.Builder = throw UnsupportedOperationException()

override fun shutdown() {}

companion object {
fun loadSuccessBitmap(context: Context, request: ImageRequest, @DrawableRes id: Int): ImageResult {
val bitmap = BitmapFactory.decodeResource(context.resources, id)
val result = BitmapDrawable(context.resources, bitmap)
return SuccessResult(
drawable = result,
request = request,
dataSource = DataSource.NETWORK
)
}

fun loadErrorBitmap(context: Context, request: ImageRequest, @DrawableRes id: Int): ImageResult {
val bitmap = BitmapFactory.decodeResource(context.resources, id)
val result = BitmapDrawable(context.resources, bitmap)
return ErrorResult(
drawable = result,
request = request,
throwable = IOException("request for ")
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

@file:OptIn(ExperimentalCoroutinesApi::class, ExperimentalCoroutinesApi::class)

package com.google.android.horologist.tiles

import android.content.Context
import androidx.test.platform.app.InstrumentationRegistry
import androidx.wear.tiles.ResourceBuilders
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test

class ImagesTest {
private lateinit var context: Context

@Before
fun setup() {
context = InstrumentationRegistry.getInstrumentation().context
}

@Test
public fun loadImageResource() {
runTest {
val imageLoader = FakeImageLoader {
// https://wordpress.org/openverse/image/34896de8-afb0-494c-af63-17b73fc14124/
FakeImageLoader.loadSuccessBitmap(context, it, R.drawable.coil)
}

val imageResource = imageLoader.loadImageResource(context, R.drawable.coil)

val inlineResource = imageResource!!.inlineResource!!
assertThat(inlineResource.format).isEqualTo(ResourceBuilders.IMAGE_FORMAT_RGB_565)
assertThat(inlineResource.widthPx).isEqualTo(200)
assertThat(inlineResource.heightPx).isEqualTo(134)
}
}

@Test
public fun handlesFailures() {
runTest {
val imageLoader = FakeImageLoader {
// https://wordpress.org/openverse/image/34896de8-afb0-494c-af63-17b73fc14124/
FakeImageLoader.loadErrorBitmap(context, it, R.drawable.coil)
}

val imageResource = imageLoader.loadImageResource(context, R.drawable.coil) {
error(R.drawable.coil)
}

val inlineResource = imageResource!!.inlineResource!!
assertThat(inlineResource.format).isEqualTo(ResourceBuilders.IMAGE_FORMAT_RGB_565)
}
}
}
Binary file added tiles/src/debug/res/drawable-mdpi/coil.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
89 changes: 89 additions & 0 deletions tiles/src/main/java/com/google/android/horologist/tiles/images.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.android.horologist.tiles

import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import androidx.wear.tiles.ResourceBuilders
import androidx.wear.tiles.ResourceBuilders.ImageResource
import coil.ImageLoader
import coil.request.ImageRequest
import java.nio.ByteBuffer

/**
* Load a Bitmap from a CoilImage loader.
*
* @param context the context of the service or activity.
* @param data the image to fetch in one of the support Coil formats such as String, HttpUrl.
* @param configurer any additional configuration of the ImageRequest being built.
*/
public suspend fun ImageLoader.loadImage(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need the ones returning Bitmap to be public? Which cases do we need Bitmaps (vs. ImageResources)?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so, I originally had them separate. You may do the first part in the suspending method of the tile service, and the conversion at the last minute. Also this form might be useful for other situations.

context: Context,
data: Any?,
configurer: ImageRequest.Builder.() -> Unit = {}
): Bitmap? {
val request = ImageRequest.Builder(context)
.data(data)
.apply(configurer)
.allowRgb565(true)
.allowHardware(false)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the reason for disabling hardware?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need a Bitmap as a java object, I'll add a comment.

See https://coil-kt.github.io/coil/recipes/#palette

.build()
val response = execute(request)
return (response.drawable as? BitmapDrawable)?.bitmap
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this needs better error handling. If the response is SuccessResult, the drawable is non-null, but if it's an ErrorResult, this method will just swallow the error and return null. I'd argue an exception is a better idea in the latter case, especially since this is a couroutine.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooooffff, this is tough.

I'm not certain about this. ImageRequest has placeholder and error images. If you set an error image you should get that rather than null.

It's also more inline with Coil compose usage. I think null is the right thing here. Even logging this isn't very useful, networks a patchy, you probably have to assume requests fail routinely and decide how to handle it. Logging isn't something you would actually want generally.

I'll add a test for this.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I'm missing something, but my understanding is that we're wrapping a method that returns non-null (error/success result object) with one that returns a nullable, that's what I'm finding a bit concerning. But I could be overzealous on just trying to avoid null as much as possible.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but the result is generally the Drawable.

sealed class ImageResult {
    abstract val drawable: Drawable?
    abstract val request: ImageRequest
}

It can be null if it fails and you don't set an error image.

Copy link
Collaborator Author

@yschimke yschimke Jun 10, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't want an exception on ErrorResult, I want the drawable.

}

/**
* Load an ImageResource from a CoilImage loader.
*
* @param context the context of the service or activity.
* @param data the image to fetch in one of the support Coil formats such as String, HttpUrl.
* @param configurer any additional configuration of the ImageRequest being built.
*/
public suspend fun ImageLoader.loadImageResource(
context: Context,
data: Any?,
configurer: ImageRequest.Builder.() -> Unit = {}
): ImageResource? = loadImage(context, data, configurer)?.toImageResource()

/**
* Convert a bitmap to a ImageResource.
*
* Ensures it uses RGB_565 encoding, then generates an ImageResource
* with the correct width and height.
*/
public fun Bitmap.toImageResource(): ImageResource {
val rgb565Bitmap = if (config == Bitmap.Config.RGB_565) {
this
} else {
copy(Bitmap.Config.RGB_565, false)
}

val byteBuffer = ByteBuffer.allocate(rgb565Bitmap.byteCount)
rgb565Bitmap.copyPixelsToBuffer(byteBuffer)
val bytes: ByteArray = byteBuffer.array()

return ImageResource.Builder().setInlineResource(
ResourceBuilders.InlineImageResource.Builder()
.setData(bytes)
.setWidthPx(rgb565Bitmap.width)
.setHeightPx(rgb565Bitmap.height)
.setFormat(ResourceBuilders.IMAGE_FORMAT_RGB_565)
.build()
)
.build()
}