Files
pluriwave/test/servicios/servicio_audio_controles_notificacion_test.dart
T
FreeTLab f2f706b342 fix(auto): restore the equalizer toggle and list the user's own presets
Two Android Auto regressions reported from the car.

1. The on/off equalizer action disappeared from the playback screen.

That was self-inflicted: commit cacd3ec removed it on the theory that a
custom action in `controls` aborts `AudioService.setState` and kills the
media notification. Reading the plugin source refutes it. setState
(AudioService.java:513-520) SPLITS the list -- a control carrying a
customAction goes to `customActions` (PlaybackStateCompat, i.e. the car),
everything else becomes a NotificationCompat.Action in `nativeActions`
(the phone notification). The two never mix. And the throw the theory
depended on cannot happen here: ic_auto_eq_on/ic_auto_eq_off both exist
under res/drawable, and the labels are non-empty in all 13 locales.

The notification outage was already fixed by abc6b47 (transient idle on
a source change, which setState turns into a full stop() at :557).

The action is back, with both state-aware icons. The real invariant --
a custom action's icon must resolve and its label must be non-empty --
is now a test that reads res/drawable and fails on a missing file,
instead of a comment claiming custom actions are forbidden outright.

2. The Ecualizador folder never listed the user's saved presets.

itemsEcualizadorAuto iterated PresetEcualizador.presets, so only the six
factory presets appeared -- the user's own were unreachable from the
car, the surface where a preset picker matters most. They now arrive
through a registered read function (same seam as stations and local
music, re-read per browse so a preset saved on the phone shows up
without an app restart).

presetsEcualizadorAuto is the single source of truth for the ordered
universe, used to BUILD the items and to RESOLVE a tap, so the folder
cannot show an item that resolution then refuses -- which is what the
factory-only default in seleccionarPresetEqPorMediaId would have caused.
A custom preset whose name collides with a factory one is dropped: the
media id is the raw name, so it could only ever resolve to the factory
entry, and an item that applies a preset other than the one it names is
worse than an absent one.

Tests: 1108 -> 1120.
2026-08-03 21:32:09 +02:00

192 lines
7.5 KiB
Dart

import 'dart:io';
import 'dart:ui' show Locale;
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
/// Guards the ONE rule that makes a `MediaControl.custom` safe to put in the
/// handler's transport `controls`.
///
/// `AudioService.setState` (AudioService.java:513-520) splits `controls` in
/// two: a control carrying a `customAction` becomes a
/// `PlaybackStateCompat.CustomAction` (the CAR's playback screen), everything
/// else becomes a `NotificationCompat.Action` (the PHONE's media
/// notification). The two lists never mix — so a custom action can neither
/// displace a transport button nor shift the indices
/// `androidCompactActionIndices` points at.
///
/// What it CAN do is take the whole media session down. `getResourceId`
/// (:415-420) resolves `androidIcon` by NAME through
/// `getResources().getIdentifier(...)` and returns 0 when it misses, and
/// `PlaybackStateCompat.CustomAction.Builder` throws on a 0 icon or an empty
/// label. That throw happens at :515, BEFORE
/// `mediaSession.setPlaybackState` (:552) and `enterPlayingState()` (:559) —
/// so the session is never published and the notification is never posted,
/// while ExoPlayer keeps playing regardless.
///
/// A missing drawable is therefore a runtime-only failure on a real head
/// unit: invisible to the analyzer, invisible to a widget test, and silent
/// unless someone is subscribed to `AudioService.asyncError`. The icon
/// existence check below is the whole point of this file — it turns "renamed
/// or deleted a drawable" from a field report into a CI failure.
void main() {
/// Resolves an `androidIcon` string (`'drawable/ic_foo'`) the same way
/// `getResourceId` does: type directory, then resource name. Any file
/// extension counts — a vector `.xml` and a raster `.png` are equally valid
/// to `getIdentifier`.
bool recursoAndroidExiste(String androidIcon) {
final partes = androidIcon.split('/');
if (partes.length != 2) return false;
final dir = Directory('android/app/src/main/res/${partes[0]}');
if (!dir.existsSync()) return false;
return dir.listSync().whereType<File>().any((f) {
final nombre = f.uri.pathSegments.last;
final base =
nombre.contains('.')
? nombre.substring(0, nombre.indexOf('.'))
: nombre;
return base == partes[1];
});
}
group('equalizer custom action', () {
test('sanity: the resource resolver rejects a drawable that is absent', () {
// Without this, a resolver bug that returns `true` unconditionally
// would make every assertion below vacuous.
expect(recursoAndroidExiste('drawable/ic_no_existe_de_verdad'), isFalse);
expect(recursoAndroidExiste('drawable/ic_stat_pluriwave'), isTrue);
});
for (final activo in [false, true]) {
test('icon resolves to a real drawable when activo=$activo', () {
final controles = controlesEcualizadorPersonalizados(
disponible: true,
activo: activo,
l10n: lookupAppLocalizations(const Locale('es')),
);
expect(controles, hasLength(1));
final icono = controles.single.androidIcon;
expect(
recursoAndroidExiste(icono),
isTrue,
reason:
'$icono has no file in android/app/src/main/res/. '
'getResourceId would return 0 and CustomAction.Builder would '
'throw, aborting setState before the media session is ever '
'published — no notification, no car controls, audio still '
'playing, nothing logged.',
);
});
}
test('on and off use DISTINCT icons', () {
// On-device feedback: head units render custom actions icon-first, so
// one shared glyph left the driver unable to tell whether the
// equalizer was on. Two identical icons is the bug, not the fix.
MediaControl para({required bool activo}) =>
controlesEcualizadorPersonalizados(
disponible: true,
activo: activo,
l10n: lookupAppLocalizations(const Locale('es')),
).single;
expect(
para(activo: true).androidIcon,
isNot(para(activo: false).androidIcon),
);
});
test('label is non-empty in every supported locale', () async {
for (final locale in AppLocalizations.supportedLocales) {
final l10n = await AppLocalizations.delegate.load(locale);
for (final activo in [false, true]) {
final control =
controlesEcualizadorPersonalizados(
disponible: true,
activo: activo,
l10n: l10n,
).single;
expect(
control.label.trim(),
isNotEmpty,
reason:
'an empty label makes CustomAction.Builder throw for '
'${locale.languageCode} (activo=$activo), which kills the '
'media session for every user in that language',
);
}
}
});
test('no action at all when the device has no equalizer', () {
expect(
controlesEcualizadorPersonalizados(
disponible: false,
activo: true,
l10n: lookupAppLocalizations(const Locale('es')),
),
isEmpty,
reason:
'a device without the native effect gets no EQ action, '
'never a broken one',
);
});
});
group('transport row keeps its shape', () {
/// Mirrors `_controlesTransporte`'s construction. Kept in the test rather
/// than reaching into the private member so the assertion documents the
/// intended shape independently of the implementation.
List<MediaControl> transporte({
required bool colaActiva,
required bool playing,
required bool eqDisponible,
}) => [
if (colaActiva) MediaControl.skipToPrevious,
if (playing) MediaControl.pause else MediaControl.play,
MediaControl.stop,
if (colaActiva) MediaControl.skipToNext,
...controlesEcualizadorPersonalizados(
disponible: eqDisponible,
activo: true,
l10n: lookupAppLocalizations(const Locale('es')),
),
];
for (final colaActiva in [false, true]) {
for (final playing in [false, true]) {
test('compact index still points at play/pause '
'(colaActiva=$colaActiva playing=$playing)', () {
final controles = transporte(
colaActiva: colaActiva,
playing: playing,
eqDisponible: true,
);
// androidCompactActionIndices is `[colaActiva ? 1 : 0]`. The EQ
// action is APPENDED, so the native transport buttons keep
// indices 0-3 and the collapsed shade still shows play/pause.
final indiceCompacto = colaActiva ? 1 : 0;
expect(controles.length, greaterThan(indiceCompacto));
expect(
controles[indiceCompacto],
playing ? MediaControl.pause : MediaControl.play,
);
// The custom action must never sit among the transport buttons:
// `nativeActions` and `customActions` are built by walking this
// list in order, so an interleaved custom action would renumber
// the notification's own actions.
final indiceCustom = controles.indexWhere(
(c) => c.customAction != null,
);
expect(indiceCustom, controles.length - 1);
});
}
}
});
}