vc=52: R8 enabled + surface-handoff polish

R8 (minify + resource-shrink) flipped on for BOTH debug AND release
variants — we publish the debug APK to fdroid (per existing
pipeline), and the audit-flagged Log.d strip discipline required R8
to actually run on the variant we ship.

New strawApp/proguard-rules.pro covers:
  * UniFFI bindings (uniffi.strawcore.*) — reflective FFI dispatch
    from Rust side, must survive minification
  * JNA — Library subclasses reflectively loaded by name
  * kotlinx-serialization @Serializable — generated $$serializer
    companions, kept via both the package-anchored rule and the
    annotation-wildcard rule for belt + suspenders
  * Media3 session Parcelables (cross-process via Binder)
  * Compose runtime + Strawcore exception hierarchy

Surface-handoff polish on inline ↔ fullscreen transitions:
  setKeepContentOnPlayerReset(true) on both PlayerViews (inline in
  VideoDetail + fullscreen Player). When the detaching view's player
  is nulled on dispose, it holds the last rendered frame instead of
  flashing black. The receiving view's surface takeover then renders
  the next frame without the ~1-frame black gap. Round-4 audit
  HIGH-5 was the closest writeup.

Expected APK-size win from R8: ~30-40%. Need real-device
verification post-install — the keep rules are best-effort and a
missing rule manifests as runtime ClassNotFoundException or
silently-broken kotlinx-serialization decoding.
This commit is contained in:
Kayos 2026-05-26 08:43:06 -07:00
parent dc1fff00db
commit ebe1fc8464
5 changed files with 115 additions and 3 deletions

View file

@ -39,13 +39,30 @@ configure<ApplicationExtension> {
}
buildTypes {
// R8 enabled on BOTH variants — we publish the debug APK to
// fdroid (com.sulkta.straw.debug) per the existing pipeline,
// and audit-flagged Log.d strips depended on R8 actually
// running on the variant we ship. Keep rules in
// strawApp/proguard-rules.pro cover UniFFI + JNA +
// kotlinx-serialization companions.
debug {
isDebuggable = true
applicationIdSuffix = ".debug"
resValue("string", "app_name", "Straw debug")
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
release {
isMinifyEnabled = false
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}

81
strawApp/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,81 @@
# SPDX-FileCopyrightText: 2026 Sulkta-Coop
# SPDX-License-Identifier: GPL-3.0-or-later
#
# R8 keep rules for the Straw app module. The legacy `app/proguard-rules.pro`
# is for the upstream NewPipe module different namespaces, different
# rules. This file is OURS.
#
# AGP's getDefaultProguardFile("proguard-android-optimize.txt") handles
# the Android framework + AndroidX + Compose runtime defaults via
# consumer rules shipped with each library. We only need to spell out
# what those defaults can't see:
#
# * UniFFI bindings reflective FFI dispatch from generated code.
# * JNA reflects on every class extending com.sun.jna.Library
# (that's how the loadLibrary glue works).
# * Our kotlinx-serialization @Serializable types their generated
# $$serializer companions get tree-shaken without explicit keeps.
# * Media3 session metadata Parcelables.
# -- UniFFI -------------------------------------------------------------
# Generated bindings live under uniffi.strawcore.*. The Rust side calls
# them via JNI symbol name; if R8 renames the class or methods, every
# extractor call NPEs.
-keep class uniffi.strawcore.** { *; }
-keep class uniffi.** { *; }
# -- JNA ---------------------------------------------------------------
# JNA looks up Library subclasses by Class.forName + reflection at
# load time. Anything that extends Library or has @FieldOrder must
# survive.
-keep class * extends com.sun.jna.Library { *; }
-keep class com.sun.jna.** { *; }
-dontwarn com.sun.jna.**
# -- kotlinx-serialization ---------------------------------------------
# Every @Serializable type gets a synthetic Companion + $$serializer
# class. R8 will strip the $$serializer if nothing visibly calls it
# (the lookup goes through reflection on the Companion).
-keepattributes *Annotation*, InnerClasses
-dontwarn kotlinx.serialization.**
-keep,includedescriptorclasses class com.sulkta.straw.**$$serializer { *; }
-keepclassmembers class com.sulkta.straw.** {
*** Companion;
}
-keepclasseswithmembers class com.sulkta.straw.** {
kotlinx.serialization.KSerializer serializer(...);
}
# Same dance for our top-level @Serializable types defined outside
# `com.sulkta.straw.**` (Rust DTOs, etc.). Belt + suspenders.
-keepclassmembers @kotlinx.serialization.Serializable class * {
static **$Companion Companion;
public static <1>$Companion Companion;
}
-keepclasseswithmembers @kotlinx.serialization.Serializable class * {
kotlinx.serialization.KSerializer serializer(...);
}
-keep class **$$serializer { *; }
# -- Media3 / ExoPlayer ------------------------------------------------
# Most of Media3 ships consumer rules but session-related Parcelables
# are reflectively reconstructed across process boundaries (the
# MediaController talks to PlaybackService via Binder). Keep their
# field names.
-keep class androidx.media3.session.** { *; }
-keep class androidx.media3.common.MediaItem { *; }
-keep class androidx.media3.common.MediaItem$* { *; }
-keep class androidx.media3.common.MediaMetadata { *; }
# -- Strawcore exceptions / DTOs reflected by UniFFI --------------------
# StrawcoreError is a sealed Throwable hierarchy exposed via UniFFI.
# Keep all subclasses + their fields so the Kotlin pattern-match works
# after minification.
-keep class com.sulkta.straw.feature.player.** { *; }
# -- Reflection-via-Class.forName paths from Compose --------------------
# Compose's runtime does some Class.forName for its own bootstrap; the
# AGP consumer rules cover this, but documenting the dependency here
# so a future bump doesn't surprise us.
-keep class androidx.compose.runtime.** { *; }

View file

@ -799,6 +799,12 @@ private fun InlinePlayer(
PlayerView(ctx).apply {
player = controller
useController = true
// Same surface-handoff polish as the
// fullscreen PlayerView — hold the last
// frame on dispose so the inline ↔
// fullscreen transition doesn't flash
// black between detach + reattach.
setKeepContentOnPlayerReset(true)
}
},
update = { it.player = controller },

View file

@ -178,6 +178,14 @@ fun PlayerScreen(
PlayerView(ctx).apply {
player = controller
useController = true
// Keep the last frame on screen when this
// view's player is reset (fullscreen →
// inline transition). Without this, the
// detaching PlayerView flashes black for
// ~1 frame before the receiving view takes
// over the surface.
controllerHideOnTouch = true
setKeepContentOnPlayerReset(true)
}
},
update = { it.player = controller },