Skip to content

Commit f3e1cfa

Browse files
authored
Make sure the camera bitmap is not going above the memory limit (#6658)
1 parent 95ef09a commit f3e1cfa

File tree

1 file changed

+55
-7
lines changed
  • app/src/main/kotlin/io/homeassistant/companion/android/widgets/camera

1 file changed

+55
-7
lines changed

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

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import coil3.imageLoader
1414
import coil3.request.CachePolicy
1515
import coil3.request.ImageRequest
1616
import coil3.request.allowHardware
17-
import coil3.size.Dimension
1817
import coil3.size.Precision
1918
import coil3.size.Size
2019
import coil3.toBitmap
@@ -32,6 +31,7 @@ import io.homeassistant.companion.android.widgets.ACTION_APPWIDGET_CREATED
3231
import io.homeassistant.companion.android.widgets.BaseWidgetProvider.Companion.UPDATE_WIDGETS
3332
import io.homeassistant.companion.android.widgets.EXTRA_WIDGET_ENTITY
3433
import javax.inject.Inject
34+
import kotlin.math.sqrt
3535
import kotlinx.coroutines.CancellationException
3636
import kotlinx.coroutines.CoroutineScope
3737
import kotlinx.coroutines.Dispatchers
@@ -82,8 +82,7 @@ class CameraWidget : AppWidgetProvider() {
8282
Timber.d("Skipping widget update since network connection is not active")
8383
return
8484
}
85-
val views = getWidgetRemoteViews(context, appWidgetId)
86-
views?.let { appWidgetManager.updateAppWidget(appWidgetId, it) }
85+
appWidgetManager.updateAppWidget(appWidgetId, getWidgetRemoteViews(context, appWidgetId))
8786
}
8887

8988
private suspend fun updateAllWidgets(context: Context) {
@@ -108,7 +107,7 @@ class CameraWidget : AppWidgetProvider() {
108107
}
109108
}
110109

111-
private suspend fun getWidgetRemoteViews(context: Context, appWidgetId: Int): RemoteViews? {
110+
private suspend fun getWidgetRemoteViews(context: Context, appWidgetId: Int): RemoteViews {
112111
val updateCameraIntent = Intent(context, CameraWidget::class.java).apply {
113112
action = UPDATE_IMAGE
114113
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
@@ -174,7 +173,7 @@ class CameraWidget : AppWidgetProvider() {
174173
.diskCachePolicy(CachePolicy.DISABLED)
175174
.memoryCachePolicy(CachePolicy.DISABLED)
176175
.networkCachePolicy(CachePolicy.READ_ONLY)
177-
.size(Size(getScreenWidth(), Dimension.Undefined))
176+
.size(getWidgetBitmapSize(AppWidgetManager.getInstance(context), appWidgetId))
178177
.precision(Precision.INEXACT)
179178
.build(),
180179
).image?.toBitmap()?.let {
@@ -270,7 +269,56 @@ class CameraWidget : AppWidgetProvider() {
270269
// Enter relevant functionality for when the last widget is disabled
271270
}
272271

273-
private fun getScreenWidth(): Int {
274-
return Resources.getSystem().displayMetrics.widthPixels
272+
/**
273+
* Returns a [Size] based on the widget's allocated dimensions, capped to stay within the
274+
* RemoteViews bitmap memory limit. Falls back to the full screen width and height when the
275+
* widget manager does not report a size.
276+
*
277+
* The system computes the limit as `6 * screenWidth * screenHeight`
278+
* (1.5× screen area × 4 bytes/pixel) in `AppWidgetServiceImpl.computeMaximumWidgetBitmapMemory`.
279+
* https://cs.android.com/android/platform/superproject/+/8b289e3d7cf7fd9242870758a894c6e9b4c3e655:frameworks/base/services/appwidget/java/com/android/server/appwidget/AppWidgetServiceImpl.java;l=513
280+
* There is no public API for this, so we replicate the formula and keep the camera bitmap
281+
* under roughly 90% of that limit to leave headroom for other bitmap work in the same
282+
* RemoteViews.
283+
*/
284+
private fun getWidgetBitmapSize(appWidgetManager: AppWidgetManager, appWidgetId: Int): Size {
285+
val res = Resources.getSystem()
286+
val screenWidth = res.displayMetrics.widthPixels
287+
val screenHeight = res.displayMetrics.heightPixels
288+
// System limit = 1.5 × screen area × 4 bytes/pixel (ARGB_8888).
289+
// Bytes-per-pixel cancels out when converting to pixels: 1.5 * w * h * 4 * 0.9 / 4 = 1.35 * w * h.
290+
// The 0.9 factor keeps a 10% margin for RemoteViews overhead.
291+
val maxPixels = (1.35 * screenWidth * screenHeight).toInt()
292+
293+
val options = appWidgetManager.getAppWidgetOptions(appWidgetId)
294+
val widthDp = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, 0)
295+
val heightDp = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 0)
296+
297+
val widthPx: Int
298+
val heightPx: Int
299+
if (widthDp <= 0) {
300+
widthPx = screenWidth
301+
heightPx = screenHeight
302+
} else {
303+
val density = res.displayMetrics.density
304+
widthPx = (widthDp * density).toInt()
305+
heightPx = if (heightDp > 0) (heightDp * density).toInt() else 0
306+
}
307+
308+
if (heightPx <= 0) {
309+
// Height unknown, derive a max height from the pixel budget so the
310+
// bitmap stays within limits even if the source image is unusually tall.
311+
val cappedWidth = minOf(widthPx, maxPixels / maxOf(screenHeight, 1)).coerceAtLeast(1)
312+
val maxHeight = (maxPixels / cappedWidth).coerceAtLeast(1)
313+
return Size(cappedWidth, maxHeight)
314+
}
315+
316+
// Scale down proportionally if the bitmap would exceed the safe pixel budget
317+
return if (widthPx.toLong() * heightPx > maxPixels) {
318+
val scale = sqrt(maxPixels.toDouble() / (widthPx.toLong() * heightPx)).toFloat()
319+
Size((widthPx * scale).toInt().coerceAtLeast(1), (heightPx * scale).toInt().coerceAtLeast(1))
320+
} else {
321+
Size(widthPx.coerceAtLeast(1), heightPx.coerceAtLeast(1))
322+
}
275323
}
276324
}

0 commit comments

Comments
 (0)