From a0a559648e3264b13bbb0580ebd9ed825b5085b8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 17 Aug 2026 19:30:49 +0200 Subject: [PATCH 1/2] Shrink the month widget's RemoteViews so it stops killing the widget host (#214) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grid rendered ~740 views and SizeMode.Exact serialised a full copy per host size, so one update was a 450-540 KB oneway binder push. Three of those overran the launcher's 1 MB async buffer, and AppWidgetServiceImpl dropped the whole host on the TransactionTooLargeException — which is why the arrows went dead after a couple of taps and only came back when the launcher rebound. One composition sized from the host's own options, day-open taps moved to a single strip per column, day numbers as bare text, and no views for lanes or a "+N" row a week doesn't fill. 742 views -> 192. --- .../calendula/widget/month/MonthWidget.kt | 289 ++++++++++++------ 1 file changed, 199 insertions(+), 90 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt index f4b5bd1..94bfbab 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.calendula.widget.month import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration +import android.appwidget.AppWidgetManager import android.content.Context import android.content.res.Configuration import androidx.compose.runtime.Composable @@ -19,6 +20,7 @@ import androidx.glance.action.ActionParameters import androidx.glance.action.actionParametersOf import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.LocalAppWidgetOptions import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.actionRunCallback @@ -33,6 +35,7 @@ import androidx.glance.layout.Box import androidx.glance.layout.Column import androidx.glance.layout.Row import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxHeight import androidx.glance.layout.fillMaxSize import androidx.glance.layout.fillMaxWidth import androidx.glance.layout.height @@ -92,16 +95,31 @@ private fun yearMonthOf(index: Int): YearMonth = * event bars and titled single-day pills (the in-app lane layout via * [layoutMonthWeeks]), and prev/next/today navigation. * - * Columns are sized explicitly from [LocalSize] (hence [SizeMode.Exact]) so a - * multi-day span renders as a single Box spanning its columns — connected, no - * inter-cell seam, with rounded end caps. The displayed month lives in Glance - * state and is read reactively in the composition ([currentState]) so the arrows - * move it via plain recomposition, not a (here-unreliable) widget session reload. + * Columns are sized explicitly from [gridWidth] so a multi-day span renders as a + * single Box spanning its columns — connected, no inter-cell seam, with rounded + * end caps. The displayed month lives in Glance state and is read reactively in + * the composition ([currentState]) so the arrows move it via plain recomposition, + * not a (here-unreliable) widget session reload. + * + * Everything here is written to keep the published `RemoteViews` small. A widget + * update reaches the launcher as a *oneway* binder call, and the host's async + * buffer is 1 MB for all of it; overrun throws `TransactionTooLargeException` and + * the system drops the whole host, freezing every widget on the home screen until + * the launcher rebinds. A 6x7 grid is close enough to that ceiling to have hit it + * (#214), so the grid emits no view it does not need. */ class MonthWidget : GlanceAppWidget() { override val stateDefinition = PreferencesGlanceStateDefinition - override val sizeMode = SizeMode.Exact + + /** + * [SizeMode.Exact] would serialise the whole grid once per size the host + * reports — two full copies of ~700 views on any launcher that offers a + * portrait and a landscape size, which is what pushed the payload over the + * binder ceiling (#214). One composition, sized from [gridWidth], which the + * host re-runs on resize and rotation via `onAppWidgetOptionsChanged`. + */ + override val sizeMode = SizeMode.Single override suspend fun provideGlance(context: Context, id: GlanceId) { val source = context.loadMonthWidgetSource() @@ -170,7 +188,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone) val ym = yearMonthOf(index) // Column width from the live widget size, minus our H padding. - val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7 + val colW = (gridWidth() - GRID_HPADDING * 2) / 7 val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone) MonthHeader(label = monthLabel(ym, source.today.year)) @@ -192,6 +210,28 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo } } +/** + * The widget's current width. Under [SizeMode.Single] `LocalSize` is the provider's + * declared minimum rather than the placed size, so read the host's own numbers — + * min width in portrait, max width in landscape, the same pair Glance itself picks + * from — and keep `LocalSize` only as a fallback for a host that reports neither. + */ +@Composable +private fun gridWidth(): Dp { + val options = LocalAppWidgetOptions.current + val landscape = LocalContext.current.resources.configuration.orientation == + Configuration.ORIENTATION_LANDSCAPE + val width = options.getInt( + if (landscape) { + AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH + } else { + AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH + }, + 0, + ) + return if (width > 0) width.dp else LocalSize.current.width +} + @Composable private fun MonthHeader(label: String) { val context = LocalContext.current @@ -296,29 +336,75 @@ private fun WeekRow( colW: Dp, modifier: GlanceModifier, ) { - Column(modifier = modifier.fillMaxWidth()) { - // Day numbers. - Row(modifier = GlanceModifier.fillMaxWidth()) { + val context = LocalContext.current + val hidden = week.hiddenPerDay() + Box(modifier = modifier.fillMaxWidth()) { + // Day-open targets, one full-height strip per column, sitting *under* the + // content: a tap anywhere in a day still opens it, as in the app, without + // every cell in the column carrying a click target of its own. The content + // above is not clickable, so touches fall through to this layer; only an + // event bar opts out to open its own detail. + Row(modifier = GlanceModifier.fillMaxSize()) { week.days.forEach { date -> - DayNumber( - date = date, - isToday = date == today, - inMonth = date.month == currentMonth, - colW = colW, - ) + Box( + modifier = GlanceModifier + .width(colW) + .fillMaxHeight() + .clickable(openDayAction(context, date)), + ) {} } } - Spacer(GlanceModifier.height(2.dp)) - // One lane row per event row. A multi-day span is a single Box spanning - // its columns (colW * n) so it's connected with no seam and rounded ends. - repeat(MAX_LANES) { lane -> - LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW) - Spacer(GlanceModifier.height(1.dp)) + Column(modifier = GlanceModifier.fillMaxWidth()) { + // Day numbers. Fixed height so every week's number row lines up whether + // or not it holds today's larger pill. + Row( + modifier = GlanceModifier.fillMaxWidth().height(DAY_NUMBER_HEIGHT), + verticalAlignment = Alignment.CenterVertically, + ) { + week.days.forEach { date -> + DayNumber( + date = date, + isToday = date == today, + inMonth = date.month == currentMonth, + colW = colW, + ) + } + } + Spacer(GlanceModifier.height(2.dp)) + // One lane row per event row. A multi-day span is a single Box spanning + // its columns (colW * n) so it's connected with no seam and rounded ends. + // Lanes past the last one this week fills emit nothing at all. + repeat(week.usedLanes()) { lane -> + LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW) + Spacer(GlanceModifier.height(1.dp)) + } + if (hidden.any { it > 0 }) OverflowRow(hidden = hidden, colW = colW) } - OverflowRow(week = week, colW = colW) } } +/** + * How many lanes this week actually fills, counting from the top. The grid draws + * that many rows instead of a fixed [MAX_LANES]; an empty lane costs three views + * per day and most weeks leave the last two empty. + */ +private fun MonthWeek.usedLanes(): Int { + for (lane in MAX_LANES - 1 downTo 0) { + val filled = spans.any { it.lane == lane } || + days.indices.any { col -> timedEventAt(this, lane, col, days[col]) != null } + if (filled) return lane + 1 + } + return 0 +} + +/** Events per day that no lane had room for — the "+N" counts, 0 where all fit. */ +private fun MonthWeek.hiddenPerDay(): List = days.mapIndexed { col, date -> + val shownSpans = spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES } + val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0) + val timedShown = minOf(freeSlots, timedByDay[date].orEmpty().size) + ((countByDay[date] ?: 0) - shownSpans - timedShown).coerceAtLeast(0) +} + /** * Open [date]'s day view rooted in the month view (so back returns to the month * grid) — the same target the in-app month grid uses when a day cell is tapped. @@ -328,64 +414,93 @@ private fun WeekRow( private fun openDayAction(context: Context, date: LocalDate) = actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)) +/** + * A day's number. Every day but today is a bare centred [Text] carrying the column + * width itself — a wrapping Box costs three views, and there are 42 of these. + * Today keeps its filled circle, which does need the Box. + */ @Composable private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) { - val context = LocalContext.current - Box( - modifier = GlanceModifier - .width(colW) - .height(DAY_NUMBER_HEIGHT) - .clickable(openDayAction(context, date)), - contentAlignment = Alignment.Center, - ) { + val style = TextStyle( + color = when { + isToday -> GlanceTheme.colors.onPrimary + inMonth -> GlanceTheme.colors.onSurface + else -> GlanceTheme.colors.onSurfaceVariant + }, + fontSize = 11.sp, + fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, + textAlign = TextAlign.Center, + ) + if (!isToday) { + Text(text = date.day.toString(), style = style, modifier = GlanceModifier.width(colW)) + return + } + Box(modifier = GlanceModifier.width(colW), contentAlignment = Alignment.Center) { Box( modifier = GlanceModifier .size(DAY_NUMBER_HEIGHT) - .then(if (isToday) GlanceModifier.cornerRadius(DAY_NUMBER_HEIGHT / 2).background(GlanceTheme.colors.primary) else GlanceModifier), + .cornerRadius(DAY_NUMBER_HEIGHT / 2) + .background(GlanceTheme.colors.primary), contentAlignment = Alignment.Center, ) { - Text( - text = date.day.toString(), - style = TextStyle( - color = when { - isToday -> GlanceTheme.colors.onPrimary - inMonth -> GlanceTheme.colors.onSurface - else -> GlanceTheme.colors.onSurfaceVariant - }, - fontSize = 11.sp, - fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, - ), - ) + Text(text = date.day.toString(), style = style) } } } +/** A bar occupying [cols] columns, or — with a null event — that many blank ones. */ +private data class LaneCell(val event: EventInstance?, val cols: Int) + +/** + * A lane split into bars and the gaps between them, with consecutive blank columns + * merged into one gap: the week's day-open taps come from the strip underneath, so + * a gap needs no per-column view and a mostly-empty lane collapses to a spacer. + */ +private fun MonthWeek.laneCells(lane: Int): List { + val cells = mutableListOf() + var gap = 0 + var col = 0 + fun closeGap() { + if (gap > 0) cells += LaneCell(null, gap) + gap = 0 + } + while (col < 7) { + val span = spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol } + val timed = if (span == null) timedEventAt(this, lane, col, days[col]) else null + when { + span != null -> { + closeGap() + cells += LaneCell(span.event, span.endCol - col + 1) + col = span.endCol + 1 + } + timed != null -> { + closeGap() + cells += LaneCell(timed, 1) + col += 1 + } + else -> { + gap += 1 + col += 1 + } + } + } + closeGap() + return cells +} + @Composable private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) { - val context = LocalContext.current Row(modifier = GlanceModifier.fillMaxWidth()) { - var col = 0 - while (col < 7) { - val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol } - if (span != null) { - val cols = span.endCol - col + 1 - SpanBar(event = span.event, dark = dark, soften = soften, width = colW * cols) - col = span.endCol + 1 + week.laneCells(lane).forEach { cell -> + if (cell.event == null) { + Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT)) } else { - val timed = timedEventAt(week, lane, col, week.days[col]) - if (timed != null) { - SpanBar(event = timed, dark = dark, soften = soften, width = colW) - } else { - // Empty lane cell: a tap opens that day, so blank space in a - // day column is a day-open target just like the number is. - Box( - GlanceModifier - .width(colW) - .height(LANE_HEIGHT) - .clickable(openDayAction(context, week.days[col])), - ) {} - } - col += 1 + SpanBar( + event = cell.event, + dark = dark, + soften = soften, + width = colW * cell.cols, + ) } } } @@ -435,34 +550,28 @@ private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width: } } +/** The "+N" row, drawn only for weeks that hide something, gaps merged as in a lane. */ @Composable -private fun OverflowRow(week: MonthWeek, colW: Dp) { - val context = LocalContext.current +private fun OverflowRow(hidden: List, colW: Dp) { Row(modifier = GlanceModifier.fillMaxWidth()) { - week.days.forEachIndexed { col, date -> - val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES } - val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0) - val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size) - val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown - // The overflow row is part of the day column too: tapping it (whether - // it shows "+N" or is blank) opens that day, same as the app. - Box( - modifier = GlanceModifier - .width(colW) - .height(LANE_HEIGHT) - .clickable(openDayAction(context, date)), - contentAlignment = Alignment.CenterStart, - ) { - if (hidden > 0) { - Text( - text = "+$hidden", - maxLines = 1, - style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp), - modifier = GlanceModifier.padding(start = 3.dp), - ) - } + var gap = 0 + hidden.forEach { count -> + if (count == 0) { + gap += 1 + return@forEach } + if (gap > 0) { + Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT)) + gap = 0 + } + Text( + text = "+$count", + maxLines = 1, + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp), + modifier = GlanceModifier.width(colW).padding(start = 3.dp), + ) } + if (gap > 0) Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT)) } } From 7431a7390e0b65ab9e9966892d75ea4b839bd748 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 17 Aug 2026 19:56:20 +0200 Subject: [PATCH 2/2] Keep SizeMode.Exact: Single and Responsive never reflow on resize (#214) GlanceAppWidget.resize returns early for both, so the grid was never recomposed for a new size and a resized widget clipped instead of reflowing. Exact costs one serialised copy per host size, which the view cuts have made affordable again. Drops the options-derived width that went with Single; LocalSize is exact here. --- .../calendula/widget/month/MonthWidget.kt | 54 ++++++------------- 1 file changed, 16 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt index 94bfbab..6778bda 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt @@ -1,7 +1,6 @@ package de.jeanlucmakiola.calendula.widget.month import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration -import android.appwidget.AppWidgetManager import android.content.Context import android.content.res.Configuration import androidx.compose.runtime.Composable @@ -20,7 +19,6 @@ import androidx.glance.action.ActionParameters import androidx.glance.action.actionParametersOf import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget -import androidx.glance.appwidget.LocalAppWidgetOptions import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.actionRunCallback @@ -95,31 +93,33 @@ private fun yearMonthOf(index: Int): YearMonth = * event bars and titled single-day pills (the in-app lane layout via * [layoutMonthWeeks]), and prev/next/today navigation. * - * Columns are sized explicitly from [gridWidth] so a multi-day span renders as a - * single Box spanning its columns — connected, no inter-cell seam, with rounded - * end caps. The displayed month lives in Glance state and is read reactively in - * the composition ([currentState]) so the arrows move it via plain recomposition, - * not a (here-unreliable) widget session reload. + * Columns are sized explicitly from [LocalSize] (hence [SizeMode.Exact]) so a + * multi-day span renders as a single Box spanning its columns — connected, no + * inter-cell seam, with rounded end caps. The displayed month lives in Glance + * state and is read reactively in the composition ([currentState]) so the arrows + * move it via plain recomposition, not a (here-unreliable) widget session reload. * * Everything here is written to keep the published `RemoteViews` small. A widget * update reaches the launcher as a *oneway* binder call, and the host's async * buffer is 1 MB for all of it; overrun throws `TransactionTooLargeException` and * the system drops the whole host, freezing every widget on the home screen until - * the launcher rebinds. A 6x7 grid is close enough to that ceiling to have hit it - * (#214), so the grid emits no view it does not need. + * the launcher rebinds. [SizeMode.Exact] serialises the grid once per size the + * host reports, so the ceiling arrives at half the view count you would expect: a + * grid that emitted a view per cell blew past it after three taps (#214). Every + * row here emits only what it fills. */ class MonthWidget : GlanceAppWidget() { override val stateDefinition = PreferencesGlanceStateDefinition /** - * [SizeMode.Exact] would serialise the whole grid once per size the host - * reports — two full copies of ~700 views on any launcher that offers a - * portrait and a landscape size, which is what pushed the payload over the - * binder ceiling (#214). One composition, sized from [gridWidth], which the - * host re-runs on resize and rotation via `onAppWidgetOptionsChanged`. + * Exact rather than [SizeMode.Single] or [SizeMode.Responsive], both of which + * return early from `GlanceAppWidget.resize` — under those the grid is never + * recomposed for a new size and a resized widget clips instead of reflowing. + * Exact costs one serialised copy per host size, which is affordable now that + * a rendering is ~190 views rather than ~740. */ - override val sizeMode = SizeMode.Single + override val sizeMode = SizeMode.Exact override suspend fun provideGlance(context: Context, id: GlanceId) { val source = context.loadMonthWidgetSource() @@ -188,7 +188,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone) val ym = yearMonthOf(index) // Column width from the live widget size, minus our H padding. - val colW = (gridWidth() - GRID_HPADDING * 2) / 7 + val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7 val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone) MonthHeader(label = monthLabel(ym, source.today.year)) @@ -210,28 +210,6 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo } } -/** - * The widget's current width. Under [SizeMode.Single] `LocalSize` is the provider's - * declared minimum rather than the placed size, so read the host's own numbers — - * min width in portrait, max width in landscape, the same pair Glance itself picks - * from — and keep `LocalSize` only as a fallback for a host that reports neither. - */ -@Composable -private fun gridWidth(): Dp { - val options = LocalAppWidgetOptions.current - val landscape = LocalContext.current.resources.configuration.orientation == - Configuration.ORIENTATION_LANDSCAPE - val width = options.getInt( - if (landscape) { - AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH - } else { - AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH - }, - 0, - ) - return if (width > 0) width.dp else LocalSize.current.width -} - @Composable private fun MonthHeader(label: String) { val context = LocalContext.current