Audit of the same failure family as the shrunk drawables: references by
NAME that nothing validates at compile time.
The whole onboarding and release-notes feature had never shipped. Reading
the installed APK: ZERO entries under assets/content/, while
assets/icons/alarmas/* was present. pubspec declared `assets/content/`, and
Flutter does not recurse -- naming a directory includes the files sitting
directly in it, never its subdirectories. Every content file lives in one
(onboarding/, updates/<locale>/), so none of them were packaged.
On the device that surfaced on every single launch:
Unable to load asset: "assets/content/onboarding/en.md"
with the file plainly present on disk. That is why it never looked like a
packaging problem. The tell was already in the pubspec: assets/icons/alarmas/
is listed explicitly, so the rule was known once and not applied here.
All 14 content directories are now declared: onboarding/ plus updates/ for
each of the 13 locales.
The guard is a test that loads every file under assets/content/ through
rootBundle, because that is the only thing that proves an asset is declared
and will ship. A test asserting File.existsSync would have stayed green
through all of this -- the files were never missing, only unpackaged. Run
against the unfixed pubspec it fails 26 of 27; with the fix it passes.
Tests: 1165 -> 1192.
Root cause found, and it is not the stale build cache I claimed earlier.
flutter clean was good hygiene and changed nothing here, because nothing
was cached: the resources were being deliberately removed.
Flutter's own Gradle plugin enables shrinking on every release build --
FlutterPlugin.kt, `releaseBuildType.isMinifyEnabled = true` and
`isShrinkResources = true` -- no matter what app/build.gradle.kts says. The
shrinker keeps what it can see referenced, and it cannot see
`MediaControl.custom(androidIcon: 'drawable/ic_auto_eq_on')`: that is a
string inside Dart, resolved at runtime through getIdentifier. So both
equalizer icons were stripped from every release APK ever built.
The evidence that pins it, from the APK pulled off the device:
ic_stat_pluriwave present <- referenced as R.drawable from Kotlin,
4 call sites in the alarm notifications
ic_auto_eq_on absent <- named only in a Dart string
ic_auto_eq_off absent <- named only in a Dart string
Same folder, same file shape, same commit range. The only difference is
whether a real R.drawable reference exists, which is exactly what the
shrinker looks for.
The consequence was never a blank button. getResourceId returns 0 for an
unresolvable name, PlaybackStateCompat.CustomAction.Builder throws on a 0
icon, and that throw aborts AudioService.setState before the media session
is activated -- so Android Auto held a frozen, inactive session. Dead
playback screen, play that never became pause, the app losing its pane to
any app with a live session, audio playing "as if it were not the app".
One shrunk file, four symptoms, since 31 July (2540556).
Two protections, because they fail differently:
- res/raw/keep.xml with tools:keep is the official mechanism for
dynamically resolved resources and is what actually binds the shrinker;
- RecursosResueltosPorNombre.kt gives them genuine R.drawable references,
the same thing that kept ic_stat_pluriwave alive all along.
station_art_* are kept too. They are reached the same way, through
android.resource:// URIs built in Dart, and survived only by luck.
Tests: 1165, unchanged -- this is a build-configuration fix, and no Dart
test can see it. The CI resource guard is what verifies it now.
The previous guard reported "Drawables en el APK: (ninguno)" for a 105MB
release APK. Zero drawables is impossible -- AndroidX alone contributes
dozens -- so the check was wrong, not the build. Release APKs shorten and
rename resource file paths, so `res/drawable/...` simply is not how they
are stored there. The 45MB base.apk taken off the device kept readable
paths because it came from an AAB through bundletool; the CI builds a fat
APK through a different pipeline. Same app, different layout.
Resource NAMES survive in resources.arsc regardless of path shortening, so
that is what gets inspected now.
And the guard checks itself before judging. It looks for a sentinel
resource known to be present (station_art_nova); if the sentinel is not
found, the inspection method is unreliable and the step says so instead of
declaring anything absent. This guard has already lied once, reporting
ic_stat_pluriwave missing when it was verified present, and that lie was
about to send us hunting a build problem that did not exist. A check with
no way to detect its own failure has no business failing a build.
Verified before pushing, all three extracted verbatim from the parsed YAML
and run against real inputs:
1. real 45MB base.apk -> sentinel found, ic_stat_pluriwave OK,
ic_auto_eq_on/off missing, exit 1
2. APK absent -> reports the path and lists what is there,
exit 1, no resource accusations
3. zip without arsc -> "inspection impossible", exit 1 (checked
without a pipe, so the code is the script's)
Scenario 3 is the one the old guard got wrong: it turned an inspection
failure into three false "FALTA" lines.
The previous commit made build.yml unparseable and no job ran at all:
yaml: line 183: could not find expected ':'
A `run: |` block is a YAML literal scalar, so every line has to keep the
block's indentation. The python3 fallback I added used a heredoc whose
body sat at column 0, which terminates the scalar -- YAML then tried to
read `import zipfile, sys` as a mapping and gave up. Worse than a broken
check: a broken pipeline.
The fallback is gone rather than re-indented. unzip is present on this
runner, a second code path existed only to guard against a case that was
never observed, and its only contribution was an escaping hazard inside
YAML inside shell.
Verified before pushing this time, which is the actual lesson:
- build.yml now parses (yaml.safe_load), 6 + 15 steps;
- the guard's `run` script was extracted from the parsed YAML and executed
verbatim against the real 45MB base.apk pulled off the device. It prints
the drawable inventory, reports ic_stat_pluriwave OK and ic_auto_eq_on /
ic_auto_eq_off missing, and exits 1 -- matching an independent zipfile
inspection of the same file.
Two commits in a row shipped a CI change that had never been run. Both
were caught by the user rather than by me.
The guard added in the previous commit reported all three drawables as
missing on its first run, including ic_stat_pluriwave -- which is
verifiably present: it was read out of the base.apk pulled off the device
byte by byte. The step also finished in 0s, so it never opened the file at
all. Either the APK is not at the assumed path on this runner or unzip is
unavailable, and the failing pipeline silently produced an empty listing
that every grep then "failed" against.
A guard that lies is worse than no guard: it sends you hunting ghosts,
which is exactly the failure mode this whole episode has been about.
It now verifies its own preconditions before judging anything:
- the APK must exist, and if it does not the step prints where the APKs
actually are (find over build/app/outputs) instead of guessing;
- it needs unzip or python3, and says so plainly if neither is there;
- an empty listing is treated as "inspection unreliable", not as
"everything is missing";
- it dumps the real res/drawable inventory before the verdict, so a
future failure is readable without another round trip.
Matching is now exact (grep -qx) rather than substring.
The logic was run locally against the real 45MB base.apk taken off the
device: ic_stat_pluriwave OK, ic_auto_eq_on and ic_auto_eq_off missing --
which is precisely what an independent zipfile inspection of the same APK
reported yesterday. The check agrees with reality before shipping.
History review requested by the owner: when and why did the Android Auto
UI stop working.
ANSWER: 31 July, commit 2540556, "give the equalizer actions distinct,
state-aware icons".
9eff760 (31-07) androidIcon: 'drawable/ic_stat_pluriwave' -> in the APK
2540556 (31-07) androidIcon: 'drawable/ic_auto_eq_on' -> NEVER in it
That commit swapped a drawable that shipped for two that the stale CI
resource cache never included. From that moment getResourceId returned 0,
PlaybackStateCompat.CustomAction.Builder threw, and the throw aborted
AudioService.setState before the session was published -- so every Android
Auto symptom chased since is one line of that commit. The bitter part is
that 2540556 was itself a fix for a report about two identical icons.
Three changes.
1. CI guard. The build now unzips the release APK and fails if a drawable
resolved by NAME at runtime is missing. Resolution by name cannot fail at
compile time -- it fails in the car, silently, with id 0. This class of
bug shipped undetected for a week; it cannot ship again.
2. Skipping stations now walks the favourites GROUP first, as requested:
group -> all favourites -> my stations -> catalogue. Two deliberate
exclusions, both tested: `sinAsignarId` is the ABSENCE of a group, not a
group, so those walk all favourites; and a one-member group falls through
too, or both buttons would be dead ends. The group is read from the
FAVOURITE record, never from the playing station -- that one is rebuilt by
emisoraDesdeMediaItem, which carries no group id and would always report
"unfiled".
3. Diagnostics on the station skip. It was reported as doing nothing for
radio, and every early return in that method is silent: an empty list and
a single-entry list look identical from outside. The log now names which
one fired, so the next capture answers it instead of another hypothesis.
Tests: 1161 -> 1165.
The equalizer drawables were never in the shipped binary. Verified by
pulling base.apk off the device and reading it:
res/drawable/ic_stat_pluriwave.xml PRESENT (added 02-07)
res/drawable/ic_auto_eq_on.xml ABSENT (added 31-07, 2540556)
res/drawable/ic_auto_eq_off.xml ABSENT
Neither as a zip entry nor as a name in resources.arsc. Both files are in
git with content and on disk; the older sibling in the same folder is in
the APK. The difference is when they were added.
This runner is self-hosted and the workflow never cleaned, so build/
survives between runs and Gradle's incremental resource merge went stale:
resources present when the cache was built kept working, resources added
afterwards silently never made it in.
The cost was weeks of wrong diagnosis. getResourceId returned 0 for that
icon, PlaybackStateCompat.CustomAction.Builder throws on a 0 icon, and
that throw aborts AudioService.setState BEFORE mediaSession.setActive --
so Android Auto held a frozen, inactive session. On the device that
surfaced as a dead playback screen, a play button that never became
pause, PluriWave losing its pane to whichever app did have an active
session, and audio that played "as if it were not the app". One cause,
four symptoms.
Dart changes always shipped because Dart is recompiled every build, which
is exactly why this hid for so long: every fix appeared to land and
nothing behaved differently.
flutter clean costs build time. It buys the guarantee that what is in git
is what is in the binary, which this project just spent weeks not having.
Reported, with this on screen:
No se pudo iniciar la grabación: Invalid argument(s): Unsupported scheme
'content' in URI content://com.android.externalstorage.documents/tree/
primary%3AMusic/document/primary%3AMusic%2F...%2FNew Limit - Smile.mp3
The URI in that message is a local MP3, not a station.
PluriWaveAudioHandler._cambiarFuente sets `emisoraActual` for EVERY source
it plays, so a local track surfaces as an Emisora whose `url` is the SAF
content:// document URI it was opened from. EstadoGrabacion.iniciar only
checked for null, handed that straight to the recorder, and the HTTP
client failed with a message no user can act on.
"It used to work" is exactly right: before local music playback existed,
whatever was playing was always a real station, so the case could not
arise. The recorder never changed.
iniciar() now also requires a real network stream (esEmisoraGrabable) and
falls back to the existing "select a station first" message, which is the
correct guidance here -- recording a local file makes no sense anyway,
it is already on the device. No new l10n key, so no 13-locale churn for a
message that already says the right thing.
Tests: 1158 -> 1161.
Every diagnostic line in the audio path used `dart:developer`'s `log()`.
That function writes to the VM service, which a RELEASE build does not
have — so in the only build that ever runs in a car, all eleven of them
went nowhere. `debugPrint`/`print` do reach logcat in release; `log()`
does not.
That includes the two channels built specifically to end the guessing:
- `registrarErrorAudioService`, which subscribes to
`AudioService.asyncError` so the plugin's swallowed platform exceptions
stop vanishing (b0271fa). It moved them from a dropped PublishSubject
to a dropped log call.
- `_trazarEstadoPublicado`, the published-state trace added in 7054a4c to
settle why the car's play button never becomes pause.
So "no evidence" was never a quiet app. It was an app writing its
evidence somewhere release builds discard. Several rounds of hypotheses
were argued without data that the app was already producing.
All eleven now use `debugPrint` with a `[PluriWave][Tag]` prefix, so one
filter catches the audio path and the existing alarm lines together:
adb logcat | grep PluriWave
No behaviour changes. Tests: 1158, unchanged.
Four car reports, two root causes.
1. Local music vanished from the Android Auto menu. Self-inflicted, by
c1afe72 yesterday.
That commit moved registrarFuenteNavegacion above every await to keep a
headless engine from dying before it ran -- but left
registrarFuenteMusicaLocal below `await SharedPreferences.getInstance()`.
The root menu decides whether to offer "Música Local" with
`fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada()`, so
the car could now get a root response in the window between the two
registrations, find a null source, and be told there is no local music.
Android Auto caches the browse root, so it stayed missing for the whole
session. Before the reorder both registrations sat together after the
await and the window did not exist.
FuenteMusicaLocalAutoImpl never needed prefs to be CONSTRUCTED -- it
resolves them lazily per call, the same convention ServicioAlarmas uses
-- so it now registers beside the station source, above every await, and
the window is gone rather than narrowed.
2. PluriWave disappeared from the Auto pane mid-drive, the playback
screen sat frozen, and the equalizer was lost on every navigation
prompt. One cause for all three.
androidWillPauseWhenDucked: true made audio_session translate Android's
AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK into a full PAUSE. In a car that
fires constantly: every navigation instruction, every speed-camera
warning, every voice assistant. And a pause publishes playing:false,
which AudioService.setState turns into exitPlayingState() and, with
androidStopForegroundOnPause: true, into stopForeground(...). The
plugin's own doc for that flag says what follows: "while in this lower
priority state, the operating system will also be able to kill your
service at any time to reclaim resources". A killed service is a media
session that vanishes from the car pane -- and another media app takes
the slot.
Now the app ducks instead of pausing, so playing stays true and session,
notification and pane all survive an interruption; and the service stays
foreground even on a real pause, so a genuine one is not a death
sentence either. androidNotificationOngoing goes to false because the
plugin asserts it implies stopForegroundOnPause, and nothing is lost: a
foreground service already forces the notification to be ongoing.
A real, non-duckable focus loss (a phone call) still pauses and still
auto-resumes -- asserted, so the duck change cannot silently turn a call
into a station playing over it.
3. Previous/next on the car playback screen, for stations too.
skipToPrevious/skipToNext are now advertised unconditionally, since
Android Auto only draws those buttons when the app declares support.
They are no longer inert without a local queue: they walk the narrowest
list the current station belongs to -- favourites, then my stations,
then the catalogue -- wrapping at both ends, because a button that goes
dead at the end of a list reads as broken on a screen with no visible
list position. Matching is by uuid so a refreshed snapshot still
resolves, and a station in no list leaves playback untouched.
The equalizer toggle still fits alongside them: prev/next take their two
reserved slots and the equalizer claims the remaining custom-action room
because construirControlesTransporte places it before MediaControl.stop.
The phone notification is deliberately untouched: `controls` still gates
skip on an active queue, so nativeActions and
androidCompactActionIndices are byte-identical. Only systemActions
changed, and only the car reads those.
Tests: 1146 -> 1158.
Reported: with Android Auto connected, the car screen sometimes came up
completely BLACK, and opening the app on the phone then showed a
completely WHITE screen until the app was force-killed and reopened.
Never without Android Auto.
The user guessed portrait-only plus a landscape phone made the app "go a
bit crazy". Right file and right trigger, different mechanism -- a broken
layout renders overflow stripes or a red error box, never white. White
means nothing was ever built, so runApp had not run.
Verified in the plugin source: AudioServiceActivity.provideFlutterEngine
returns AudioServicePlugin.getFlutterEngine(context), which CREATES the
engine and executes the Dart entrypoint the first time it is asked. When
the car binds the MediaBrowserService before the app is opened, that
first ask is the service -- so main() runs HEADLESS, with no Activity.
SystemChrome.setPreferredOrientations travels the flutter/platform
channel, whose handler (PlatformPlugin) is installed by the Activity.
Headless there is nobody to answer it, so the call throws
MissingPluginException or never settles. It was the FIRST await in
main(), which made it fatal twice over: registrarFuenteNavegacion sits
below it and never ran, leaving getChildren with no source (black car
screen), and runApp was never reached. Opening the app then reused that
same cached, already-dead engine -- white screen. Only a force-kill,
which disposes the cached engine, recovered it. That is exactly the
workaround that was reported, and it is what makes the diagnosis fit
every detail rather than most of them.
Three changes, smallest first:
- The Android Auto browse registration moves above every await. It
depends on nothing, and anything before it is a place to get stuck.
- The orientation call is no longer awaited. It is a display preference,
never a prerequisite for runApp, and _OrientacionResponsiveApp already
re-applies it in didChangeDependencies -- the only moment it can take
effect anyway.
- aplicarPoliticaOrientacion swallows everything and logs, so the
headless failure can never propagate again.
The policy itself is unchanged and now pure and tested
(orientacionesPara): phones portrait, >=600dp everything.
Tests: 1141 -> 1146.
Reported: on the Android Auto playback screen the play/pause button stays
on PLAY while audio is audibly playing, and "it used to work, in the
latest versions it doesn't".
Previous rounds looked for a regression in this repo's audio commits and
found none: every playbackState.add site publishes playing:true with a
ready processingState, and AudioService.getPlaybackState maps that to
STATE_PLAYING. That search was aimed at the wrong thing.
The Android for Cars guide ("Enable playback control") is explicit:
"Android Auto and AAOS display playback controls based on the actions
that are enabled in the PlaybackStateCompat object. By default, your app
must support the following actions: ACTION_PLAY, ACTION_PAUSE,
ACTION_STOP, ACTION_PLAY_FROM_MEDIA_ID, ACTION_PLAY_FROM_SEARCH."
systemActions has carried only `seek` + `stop` since e9d1f67, the first
commit of the project -- git log -S confirms it was never once edited. So
the required actions have never been advertised, and no audio commit can
explain a change in behaviour. Android Auto ships as its own app and
updates itself, which is how a working screen breaks with a clean repo
history. That fits the report better than any commit here does.
The phone notification was never affected: it builds its play/pause
button from `controls`, not from these bits, which is exactly why the
symptom is car-only.
Skip actions stay conditional on an active queue on purpose -- the same
guide notes Auto reserves the prev/next slots for them and gives the
space to custom actions when the app does not support them, and that is
the space the equalizer toggle needs.
ACTION_PLAY_FROM_SEARCH is now implemented rather than merely claimed:
advertising it unimplemented would have the car's assistant accept "play
Radio X" and silently do nothing. emisoraParaBusqueda ranks exact name,
then prefix, then substring, then country, accent- and case-insensitive
because voice transcription rarely gets diacritics right; favourites are
searched first so they win a name tie, and a miss plays nothing rather
than something arbitrary.
Still a hypothesis for the play/pause symptom, not a confirmed fix -- it
is documentation-backed and cheap, but only a head unit can confirm it.
Tests: 1132 -> 1141.
Continuation of 7054a4c: the native anchor guard alone did not fix the
reported ~1444-minute snooze, because Dart runs AFTERWARDS on the
pre-notice path and had no guard at all.
1. Snooze from the pre-notice notification, root cause.
app.dart dispatches AFTER the receiver's postponeNext already ran and
after startActivity, and EstadoAlarmas.posponerProximaDesdePreaviso took
whatever occurrence it was handed on faith, then persisted and
rescheduled from it -- the last snooze path in the codebase with no
occurrence guard. The occurrence itself is not trustworthy either:
app.dart falls back to alarma.proximaEjecucion when the native event
carries none, and that field can already point at tomorrow.
_ocurrenciaSonando is generalized into _ocurrenciaValida with a caller-
supplied forward allowance and an externally-proposed occurrence that
still has to survive the same check. The pre-notice path gets a
ventanaPreaviso (30 min, matching AlarmScheduler.PRE_NOTICE_MILLIS) --
unlike the ringing-screen guard, this occurrence legitimately has not
happened yet, which is exactly why the existing helper could not just be
reused here.
Also heals state already poisoned by the missing guard: a snoozeHasta
parked past a 3-hour ceiling (posponerEjecucion clamps to 120 minutes,
so anything beyond that is corruption, not a long real snooze) is
dropped on recalculation. Without it, an alarm poisoned on a build
before this fix keeps reporting tomorrow after updating, and the user
reasonably concludes nothing changed.
2. Android Auto: no progress bar or time labels on a local track.
updatePosition was never set anywhere in the handler, so it sat at its
Duration.zero default while copyWith refreshed updateTime to now on every
push -- the car was told "position 0, as of right now" on every event, a
bar pinned at the start regardless of what was actually playing. Now set
from _player.position on both the player-state and buffered-position
listeners (the latter ticks ~2/s, which is what keeps the car's bar
smooth between player-state events). Also stream the MediaItem's
duration once the source reports it -- Auto draws no bar at all without
one, and radio streams correctly keep reporting none (live audio has no
length).
3. Android Auto: drop the Ecualizador browsable folder.
Owner decision after driving with it: a browsable six-preset list is
more interaction than a driver wants, and on/off from all three player
views (already fixed in 7054a4c to win the custom-action slot) is the
only equalizer control that belongs in the car. Preset selection stays on
the phone. This lands back on the redesign mockup's original rule ("sin
carpeta de ecualizador"), now for a road-tested reason. getChildren keeps
answering the folder's id transitionally, since a head unit can have the
old tree cached for a session or two.
The two "raiz always includes/ends with Ecualizador" tests are replaced,
not regressed -- same move the codebase already made once in the other
direction for the same folder.
Tests: 1127 -> 1132.