From 02a8a155c337d69fc85efde66fc418952c90f4e2 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 26 Oct 2026 18:20:24 +0100 Subject: [PATCH] Move Glance keep rules to floret-kit, check R8 keeps reflective constructors in CI (#75) --- .forgejo/workflows/ci.yaml | 9 +++++++ CHANGELOG.md | 4 +-- app/proguard-rules.pro | 14 ++-------- floret-kit | 2 +- scripts/check_r8_keeps.py | 55 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 15 deletions(-) create mode 100755 scripts/check_r8_keeps.py diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml index 6e82c5f..aa25172 100644 --- a/.forgejo/workflows/ci.yaml +++ b/.forgejo/workflows/ci.yaml @@ -195,6 +195,15 @@ jobs: if: steps.scope.outputs.code == 'true' run: ./gradlew assembleDebug + # Debug builds aren't minified, so a keep rule that drops a reflectively + # created class's constructor only breaks release builds (#1, #75). The + # releaseTest twin is R8-shrunk with the release rules and debug-signed. + - name: R8 keeps reflective constructors + if: steps.scope.outputs.code == 'true' + run: | + ./gradlew assembleFullReleaseTest + python3 scripts/check_r8_keeps.py + - name: Trivy filesystem scan if: steps.scope.outputs.code == 'true' run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e769b1..06c8aae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,8 +55,8 @@ All notable changes to this project are documented here. The format follows whose dates put a word after the year (#70). - Widgets no longer turn into the Today widget or show "Can't load content" after a task is ticked off (#92). -- Ticking off a task in the Tasks and Up next widgets works again; in release - builds the tap did nothing (#75). +- Ticking off a task in the Tasks and Up next widgets now works in release + builds; until now the tap did nothing (#75). - The Tasks widget leaves the same gap under its header as its preview (#86). - The Starts and Due labels in the task form no longer get cut off at large font sizes (#76). diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index a37cfe3..daf294f 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -7,8 +7,8 @@ # nothing calls it directly — Room then throws InstantiationException, reported # as "Failed to create an instance of ...". This first bit us through a # transitive Room (Glance -> WorkManager -> WorkDatabase, built at startup: -# issue #1); Glance is gone and Room is now our own task store, so the rule -# matters more, not less — TasksDatabase is built on the first store read. +# issue #1); Room is now also our own task store, so the rule matters more, +# not less — TasksDatabase is built on the first store read. -keep class * extends androidx.room.RoomDatabase { (); } # Compose Compiler may keep its own; defaults are fine @@ -20,13 +20,3 @@ -dontwarn javax.naming.** -dontwarn lombok.Generated -dontwarn org.slf4j.impl.StaticLoggerBinder - -# Glance maps each widget to its receivers by the provider's class name. R8's -# class merging folded the GlanceAppWidget subclasses into one class, so they -# all shared a name and redrew each other's slots (#92). --keep class * extends androidx.glance.appwidget.GlanceAppWidget { (); } - -# Glance instantiates ActionCallbacks reflectively through a no-arg constructor. -# Its consumer rule keeps the class but not that constructor, so R8 pruned it -# and every widget check tap failed with NoSuchMethodException (#75). --keep class * implements androidx.glance.appwidget.action.ActionCallback { (); } diff --git a/floret-kit b/floret-kit index e2b1430..ec00050 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit e2b1430ffd7bc1a8d6b7eda8b4da0facee41f69f +Subproject commit ec0005028d4291f3e555c2529ddb1e29064de2fe diff --git a/scripts/check_r8_keeps.py b/scripts/check_r8_keeps.py new file mode 100755 index 0000000..7463f3e --- /dev/null +++ b/scripts/check_r8_keeps.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Fail when R8 keeps one of our classes by name but strips its no-arg constructor. + +A keep rule that names a class but no members keeps the class and lets R8 drop +the constructor nothing calls directly. Anything that then instantiates it by +reflection (Room, Glance's ActionCallback, WorkManager) fails at runtime only, +in release builds only (#1, #75). R8 reports both halves: seeds.txt lists what +keep rules pinned, usage.txt what it removed. + + scripts/check_r8_keeps.py [mapping dir] default: fullReleaseTest's +""" +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DEFAULT_DIR = ROOT / "app/build/outputs/mapping/fullReleaseTest" +PACKAGES = ("de.jeanlucmakiola.",) + + +def kept_classes(seeds: Path) -> set[str]: + return { + line.strip() + for line in seeds.read_text().splitlines() + if line.startswith(PACKAGES) and ":" not in line + } + + +def classes_without_default_ctor(usage: Path) -> set[str]: + stripped, current = set(), None + for line in usage.read_text().splitlines(): + if not line.startswith(" "): + current = line.rstrip(":") if line.endswith(":") else None + elif current and line.strip().endswith(" ()"): + stripped.add(current) + return stripped + + +def main() -> int: + mapping = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_DIR + seeds, usage = mapping / "seeds.txt", mapping / "usage.txt" + if not seeds.exists() or not usage.exists(): + print(f"ERROR: no seeds.txt/usage.txt in {mapping}; build a minified variant first.", file=sys.stderr) + return 1 + broken = sorted(kept_classes(seeds) & classes_without_default_ctor(usage)) + for name in broken: + print(f"ERROR: {name} is kept by a rule but R8 removed its no-arg constructor.", file=sys.stderr) + if broken: + print("Keep the constructor too: -keep class ... { (); }", file=sys.stderr) + return 1 + print(f"OK: every kept class under {', '.join(PACKAGES)} still has its no-arg constructor.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())