Skip to content

Commit 2f74937

Browse files
authored
Migrate from Picasso to Coil (#4911)
* Migrate from Picasso to Coil * Update to avoid placeholder blinking
1 parent 764f043 commit 2f74937

File tree

7 files changed

+115
-46
lines changed

7 files changed

+115
-46
lines changed

app/build.gradle.kts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,8 @@ dependencies {
143143

144144
implementation(libs.jackson.module.kotlin)
145145
implementation(libs.okhttp)
146-
implementation(libs.picasso)
146+
147+
implementation(libs.bundles.coil)
147148

148149
"fullImplementation"(libs.play.services.location)
149150
"fullImplementation"(libs.play.services.home)

app/src/main/java/io/homeassistant/companion/android/HomeAssistantApplication.kt

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import android.os.Build
1313
import android.os.PowerManager
1414
import android.telephony.TelephonyManager
1515
import androidx.core.content.ContextCompat
16+
import coil3.ImageLoader
17+
import coil3.PlatformContext
18+
import coil3.SingletonImageLoader
19+
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
1620
import dagger.hilt.android.HiltAndroidApp
1721
import io.homeassistant.companion.android.common.data.keychain.KeyChainRepository
1822
import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
@@ -35,9 +39,10 @@ import kotlinx.coroutines.CoroutineScope
3539
import kotlinx.coroutines.Dispatchers
3640
import kotlinx.coroutines.Job
3741
import kotlinx.coroutines.launch
42+
import okhttp3.OkHttpClient
3843

3944
@HiltAndroidApp
40-
open class HomeAssistantApplication : Application() {
45+
open class HomeAssistantApplication : Application(), SingletonImageLoader.Factory {
4146

4247
private val ioScope: CoroutineScope = CoroutineScope(Dispatchers.IO + Job())
4348

@@ -48,6 +53,9 @@ open class HomeAssistantApplication : Application() {
4853
@Named("keyChainRepository")
4954
lateinit var keyChainRepository: KeyChainRepository
5055

56+
@Inject
57+
lateinit var okHttpClient: OkHttpClient
58+
5159
@Inject
5260
lateinit var languagesManager: LanguagesManager
5361

@@ -302,4 +310,15 @@ open class HomeAssistantApplication : Application() {
302310
ContextCompat.registerReceiver(this, templateWidget, screenIntentFilter, ContextCompat.RECEIVER_NOT_EXPORTED)
303311
}
304312
}
313+
314+
override fun newImageLoader(context: PlatformContext): ImageLoader =
315+
ImageLoader.Builder(context)
316+
.components {
317+
add(
318+
OkHttpNetworkFetcherFactory(
319+
callFactory = okHttpClient
320+
)
321+
)
322+
}
323+
.build()
305324
}

app/src/main/java/io/homeassistant/companion/android/widgets/camera/CameraWidget.kt

Lines changed: 42 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,27 @@ import android.util.Log
1414
import android.view.View
1515
import android.widget.RemoteViews
1616
import androidx.core.os.BundleCompat
17-
import com.squareup.picasso.Picasso
17+
import coil3.imageLoader
18+
import coil3.request.CachePolicy
19+
import coil3.request.ImageRequest
20+
import coil3.size.Dimension
21+
import coil3.size.Precision
22+
import coil3.size.Size
1823
import dagger.hilt.android.AndroidEntryPoint
19-
import io.homeassistant.companion.android.BuildConfig
2024
import io.homeassistant.companion.android.R
2125
import io.homeassistant.companion.android.common.data.servers.ServerManager
2226
import io.homeassistant.companion.android.database.widget.CameraWidgetDao
2327
import io.homeassistant.companion.android.database.widget.CameraWidgetEntity
2428
import io.homeassistant.companion.android.database.widget.WidgetTapAction
2529
import io.homeassistant.companion.android.util.hasActiveConnection
2630
import io.homeassistant.companion.android.webview.WebViewActivity
31+
import io.homeassistant.companion.android.widgets.common.RemoteViewsTarget
2732
import javax.inject.Inject
2833
import kotlinx.coroutines.CoroutineScope
2934
import kotlinx.coroutines.Dispatchers
3035
import kotlinx.coroutines.Job
3136
import kotlinx.coroutines.launch
37+
import okhttp3.OkHttpClient
3238

3339
@AndroidEntryPoint
3440
class CameraWidget : AppWidgetProvider() {
@@ -52,6 +58,9 @@ class CameraWidget : AppWidgetProvider() {
5258
@Inject
5359
lateinit var cameraWidgetDao: CameraWidgetDao
5460

61+
@Inject
62+
lateinit var okHttpClient: OkHttpClient
63+
5564
private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
5665

5766
override fun onUpdate(
@@ -80,7 +89,7 @@ class CameraWidget : AppWidgetProvider() {
8089
}
8190
mainScope.launch {
8291
val views = getWidgetRemoteViews(context, appWidgetId)
83-
appWidgetManager.updateAppWidget(appWidgetId, views)
92+
views?.let { appWidgetManager.updateAppWidget(appWidgetId, it) }
8493
}
8594
}
8695

@@ -108,27 +117,30 @@ class CameraWidget : AppWidgetProvider() {
108117
}
109118
}
110119

111-
private suspend fun getWidgetRemoteViews(context: Context, appWidgetId: Int): RemoteViews {
120+
private suspend fun getWidgetRemoteViews(context: Context, appWidgetId: Int): RemoteViews? {
112121
val updateCameraIntent = Intent(context, CameraWidget::class.java).apply {
113122
action = UPDATE_IMAGE
114123
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
115124
}
116125

117-
return RemoteViews(context.packageName, R.layout.widget_camera).apply {
118-
val widget = cameraWidgetDao.get(appWidgetId)
119-
if (widget != null) {
120-
var entityPictureUrl: String?
121-
try {
122-
entityPictureUrl = retrieveCameraImageUrl(widget.serverId, widget.entityId)
123-
setViewVisibility(R.id.widgetCameraError, View.GONE)
124-
} catch (e: Exception) {
125-
Log.e(TAG, "Failed to fetch entity or entity does not exist", e)
126-
setViewVisibility(R.id.widgetCameraError, View.VISIBLE)
127-
entityPictureUrl = null
128-
}
126+
val widget = cameraWidgetDao.get(appWidgetId)
127+
var widgetCameraError = false
128+
var url: String? = null
129+
if (widget != null) {
130+
try {
131+
val entityPictureUrl = retrieveCameraImageUrl(widget.serverId, widget.entityId)
129132
val baseUrl = serverManager.getServer(widget.serverId)?.connection?.getUrl().toString().removeSuffix("/")
130-
val url = "$baseUrl$entityPictureUrl"
131-
if (entityPictureUrl == null) {
133+
url = "$baseUrl$entityPictureUrl"
134+
} catch (e: Exception) {
135+
Log.e(TAG, "Failed to fetch entity or entity does not exist", e)
136+
widgetCameraError = true
137+
}
138+
}
139+
140+
val views = RemoteViews(context.packageName, R.layout.widget_camera).apply {
141+
if (widget != null) {
142+
setViewVisibility(R.id.widgetCameraError, if (widgetCameraError) View.VISIBLE else View.GONE)
143+
if (url == null) {
132144
setImageViewResource(
133145
R.id.widgetCameraImage,
134146
R.drawable.app_icon_round
@@ -152,21 +164,20 @@ class CameraWidget : AppWidgetProvider() {
152164
)
153165
Log.d(TAG, "Fetching camera image")
154166
Handler(Looper.getMainLooper()).post {
155-
val picasso = Picasso.get()
156-
if (BuildConfig.DEBUG) {
157-
picasso.isLoggingEnabled = true
158-
}
159167
try {
160-
picasso.invalidate(url)
161-
picasso.load(url).resize(getScreenWidth(), 0).onlyScaleDown().into(
162-
this,
163-
R.id.widgetCameraImage,
164-
intArrayOf(appWidgetId)
165-
)
168+
val request = ImageRequest.Builder(context)
169+
.data(url)
170+
.target(RemoteViewsTarget(context, appWidgetId, this, R.id.widgetCameraImage))
171+
.diskCachePolicy(CachePolicy.DISABLED)
172+
.memoryCachePolicy(CachePolicy.DISABLED)
173+
.networkCachePolicy(CachePolicy.READ_ONLY)
174+
.size(Size(getScreenWidth(), Dimension.Undefined))
175+
.precision(Precision.INEXACT)
176+
.build()
177+
context.imageLoader.enqueue(request)
166178
} catch (e: Exception) {
167179
Log.e(TAG, "Unable to fetch image", e)
168180
}
169-
Log.d(TAG, "Fetch and load complete")
170181
}
171182
}
172183

@@ -189,6 +200,8 @@ class CameraWidget : AppWidgetProvider() {
189200
setOnClickPendingIntent(R.id.widgetCameraPlaceholder, tapWidgetPendingIntent)
190201
}
191202
}
203+
// If there is an url, Coil will call appWidgetManager.updateAppWidget
204+
return if (url == null) views else null
192205
}
193206

194207
private suspend fun retrieveCameraImageUrl(serverId: Int, entityId: String): String? {
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package io.homeassistant.companion.android.widgets.common
2+
3+
import android.appwidget.AppWidgetManager
4+
import android.content.Context
5+
import android.widget.RemoteViews
6+
import androidx.annotation.IdRes
7+
import coil3.Image
8+
import coil3.target.Target
9+
import coil3.toBitmap
10+
11+
/**
12+
* Load images into RemoteViews with Coil
13+
* (based on https://coil-kt.github.io/coil/recipes/#remote-views)
14+
*/
15+
class RemoteViewsTarget(
16+
private val context: Context,
17+
private val appWidgetId: Int,
18+
private val remoteViews: RemoteViews,
19+
@IdRes private val imageViewResId: Int
20+
) : Target {
21+
22+
override fun onStart(placeholder: Image?) {
23+
// Skip if null to avoid blinking (there is no placeholder)
24+
placeholder?.let { setDrawable(it) }
25+
}
26+
27+
override fun onError(error: Image?) = setDrawable(error)
28+
29+
override fun onSuccess(result: Image) = setDrawable(result)
30+
31+
private fun setDrawable(image: Image?) {
32+
remoteViews.setImageViewBitmap(imageViewResId, image?.toBitmap())
33+
AppWidgetManager.getInstance(context).updateAppWidget(appWidgetId, remoteViews)
34+
}
35+
}

app/src/main/java/io/homeassistant/companion/android/widgets/mediaplayer/MediaPlayerControlsWidget.kt

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,12 @@ import android.view.View
1414
import android.widget.RemoteViews
1515
import android.widget.Toast
1616
import androidx.core.os.BundleCompat
17+
import coil3.imageLoader
18+
import coil3.request.ImageRequest
1719
import com.google.android.material.color.DynamicColors
1820
import com.mikepenz.iconics.IconicsDrawable
1921
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
20-
import com.squareup.picasso.Picasso
2122
import dagger.hilt.android.AndroidEntryPoint
22-
import io.homeassistant.companion.android.BuildConfig
2323
import io.homeassistant.companion.android.R
2424
import io.homeassistant.companion.android.common.R as commonR
2525
import io.homeassistant.companion.android.common.data.integration.Entity
@@ -28,6 +28,7 @@ import io.homeassistant.companion.android.database.widget.MediaPlayerControlsWid
2828
import io.homeassistant.companion.android.database.widget.WidgetBackgroundType
2929
import io.homeassistant.companion.android.util.hasActiveConnection
3030
import io.homeassistant.companion.android.widgets.BaseWidgetProvider
31+
import io.homeassistant.companion.android.widgets.common.RemoteViewsTarget
3132
import java.util.LinkedList
3233
import javax.inject.Inject
3334
import kotlin.collections.HashMap
@@ -273,20 +274,16 @@ class MediaPlayerControlsWidget : BaseWidgetProvider() {
273274
)
274275
Log.d(TAG, "Fetching media preview image")
275276
Handler(Looper.getMainLooper()).post {
276-
if (BuildConfig.DEBUG) {
277-
Picasso.get().isLoggingEnabled = true
278-
Picasso.get().setIndicatorsEnabled(true)
279-
}
280277
try {
281-
Picasso.get().load(url).resize(1024, 1024).into(
282-
this,
283-
R.id.widgetMediaImage,
284-
intArrayOf(appWidgetId)
285-
)
278+
val request = ImageRequest.Builder(context)
279+
.data(url)
280+
.target(RemoteViewsTarget(context, appWidgetId, this, R.id.widgetMediaImage))
281+
.size(1024)
282+
.build()
283+
context.imageLoader.enqueue(request)
286284
} catch (e: Exception) {
287285
Log.e(TAG, "Unable to load image", e)
288286
}
289-
Log.d(TAG, "Fetch and load complete")
290287
}
291288
}
292289

automotive/build.gradle.kts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,8 @@ dependencies {
172172

173173
implementation(libs.jackson.module.kotlin)
174174
implementation(libs.okhttp)
175-
implementation(libs.picasso)
175+
176+
implementation(libs.bundles.coil)
176177

177178
"fullImplementation"(libs.play.services.location)
178179
"fullImplementation"(libs.play.services.home)

gradle/libs.versions.toml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ media3 = "1.5.0"
4343
navigation-compose = "2.8.5"
4444
okhttp = "5.0.0-alpha.14"
4545
paging = "3.3.5"
46-
picasso = "2.8"
46+
coil = "3.0.4"
4747
play-services-threadnetwork = "16.2.1"
4848
play-services-home = "16.0.0"
4949
play-services-location = "21.3.0"
@@ -104,6 +104,9 @@ car-core = { module = "androidx.car.app:app", version.ref = "car-versions" }
104104
car-automotive = { module = "androidx.car.app:app-automotive", version.ref = "car-versions" }
105105
car-projected = { module = "androidx.car.app:app-projected", version.ref = "car-versions" }
106106
changeLog = { module = "com.github.AppDevNext:ChangeLog", version.ref = "changeLog" }
107+
coil-oktthp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
108+
coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" }
109+
coil-views = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }
107110
community-material-typeface = { module = "com.mikepenz:community-material-typeface", version.ref = "community-material-typeface" }
108111
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
109112
compose-animation = { module = "androidx.compose.animation:animation" }
@@ -148,7 +151,6 @@ paging-compose = { module = "androidx.paging:paging-compose", version.ref = "pag
148151
play-services-threadnetwork = { module = "com.google.android.gms:play-services-threadnetwork", version.ref = "play-services-threadnetwork" }
149152
play-services-home = { module = "com.google.android.gms:play-services-home", version.ref = "play-services-home" }
150153
play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "play-services-location" }
151-
picasso = { module = "com.squareup.picasso:picasso", version.ref = "picasso" }
152154
play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "play-services-wearable" }
153155
preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preference-ktx" }
154156
recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" }
@@ -173,6 +175,7 @@ webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" }
173175
zxing = { module = "com.journeyapps:zxing-android-embedded", version.ref = "zxing" }
174176

175177
[bundles]
178+
coil = ["coil-views", "coil-oktthp", "coil-svg"]
176179
media3 = ["media3-exoplayer", "media3-exoplayer-hls", "media3-ui"]
177180
paging = ["paging-runtime", "paging-compose"]
178181
wear-tiles = ["wear-tiles", "wear-protolayout-main", "wear-protolayout-expression", "wear-protolayout-material"]

0 commit comments

Comments
 (0)