@@ -13,6 +13,7 @@ import coil3.request.CachePolicy
1313import coil3.request.ImageRequest
1414import coil3.request.allowHardware
1515import coil3.toBitmap
16+ import android.util.LruCache
1617import kotlinx.coroutines.Dispatchers
1718import kotlinx.coroutines.coroutineScope
1819import kotlinx.coroutines.async
@@ -41,9 +42,38 @@ object WidgetImageLoader {
4142 // fall back to its placeholder drawable — preferable to a frozen cell.
4243 private const val WIDGET_LOAD_TIMEOUT_MS = 2_000L
4344
45+ // Overall deadline for a whole batch preload. RemoteViewsFactory callbacks
46+ // run on the widget host's binder thread, so without a hard ceiling N slow
47+ // URLs (each up to WIDGET_LOAD_TIMEOUT_MS) can stall the launcher and ANR
48+ // it. Bounded preloads return a partial map; unresolved URLs fall back to
49+ // the placeholder just like an individual timeout.
50+ private const val WIDGET_PRELOAD_DEADLINE_MS = 2_000L
51+
52+ // Cap how many distinct posters a single preload attempts. A widget cell
53+ // count is small; loading more just widens the blocking window on the
54+ // binder thread for content the user has to scroll to see anyway.
55+ private const val WIDGET_PRELOAD_MAX_URLS = 12
56+
57+ /* *
58+ * Process-scoped decoded-poster cache keyed by image URL. RemoteViewsService
59+ * factories are recreated frequently (new binder, process restart, config
60+ * change) and previously re-fetched every poster on each re-bind even when
61+ * the URL was unchanged. This LruCache makes repeat binds a map lookup.
62+ *
63+ * Sized by bitmap byte count (~250KB per decoded+rounded poster at
64+ * [WIDGET_IMAGE_TARGET]); ~6MB comfortably holds a full widget grid and is
65+ * well inside the widget process's bitmap budget.
66+ */
67+ private val posterMemoryCache: LruCache <String , Bitmap > = object : LruCache <String , Bitmap >(6 * 1024 * 1024 ) {
68+ override fun sizeOf (key : String , value : Bitmap ): Int = value.byteCount
69+ }
70+
4471 suspend fun loadPoster (context : Context , url : String? , cornerRadiusDp : Float = 10f): Bitmap ? {
4572 if (url.isNullOrBlank()) return null
46- return withTimeoutOrNull(WIDGET_LOAD_TIMEOUT_MS ) {
73+ // Serve from the process cache first so a factory re-bind (or a second
74+ // widget on the same launcher) does not re-decode/re-fetch.
75+ posterMemoryCache.get(url)?.let { return it }
76+ val bitmap = withTimeoutOrNull(WIDGET_LOAD_TIMEOUT_MS ) {
4777 withContext(Dispatchers .IO ) {
4878 runCatching {
4979 val request = ImageRequest .Builder (context)
@@ -67,24 +97,43 @@ object WidgetImageLoader {
6797 }.getOrNull()
6898 }
6999 }
100+ if (bitmap != null ) posterMemoryCache.put(url, bitmap)
101+ return bitmap
70102 }
71103
72104 /* *
73- * Pre-fetches every poster concurrently so `RemoteViewsFactory.getViewAt`
105+ * Pre-fetches posters concurrently so `RemoteViewsFactory.getViewAt`
74106 * can read from the resulting cache instead of doing per-cell network I/O
75- * on the binder thread. A single slow URL won't blow the budget — each
76- * individual load is bounded by [WIDGET_LOAD_TIMEOUT_MS] via [loadPoster].
107+ * on the binder thread.
108+ *
109+ * Two safety rails keep this from ANR-ing the launcher when called from a
110+ * factory's `onDataSetChanged`:
111+ * 1. The batch is capped at [WIDGET_PRELOAD_MAX_URLS] distinct URLs.
112+ * 2. The whole batch is bounded by [WIDGET_PRELOAD_DEADLINE_MS]; any URL
113+ * not resolved in time simply maps to `null` (placeholder fallback).
114+ *
115+ * Already-cached posters (from a prior preload in this process) are served
116+ * instantly from [posterMemoryCache] and don't count against the deadline.
77117 */
78118 suspend fun preloadPosters (
79119 context : Context ,
80120 urls : Collection <String >,
81121 cornerRadiusDp : Float = 10f,
82- ): Map <String , Bitmap ?> = coroutineScope {
83- urls.filter { it.isNotBlank() }
84- .distinct()
85- .map { url -> async { url to loadPoster(context, url, cornerRadiusDp) } }
86- .awaitAll()
87- .toMap()
122+ ): Map <String , Bitmap ?> {
123+ val distinct = urls.filter { it.isNotBlank() }.distinct()
124+ if (distinct.isEmpty()) return emptyMap()
125+ val capped = distinct.take(WIDGET_PRELOAD_MAX_URLS )
126+ // withTimeoutOrNull returns null on timeout — treat that as "resolve
127+ // whatever landed". Because the async loads write into posterMemoryCache
128+ // as they complete, a timeout still leaves any finished entries cached
129+ // for the next bind; here we just report the ones that resolved in time.
130+ return withTimeoutOrNull(WIDGET_PRELOAD_DEADLINE_MS ) {
131+ coroutineScope {
132+ capped.map { url -> async { url to loadPoster(context, url, cornerRadiusDp) } }
133+ .awaitAll()
134+ .toMap()
135+ }
136+ } ? : emptyMap()
88137 }
89138
90139 private fun applyRoundedCorners (context : Context , bitmap : Bitmap , cornerRadiusDp : Float = 10f): Bitmap {
0 commit comments