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: commitcacd3ecremoved 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 byabc6b47(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.
This commit is contained in:
@@ -1,93 +1,191 @@
|
||||
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';
|
||||
|
||||
/// Regression guard for a real, user-reported outage: the media notification
|
||||
/// vanished entirely — no shade widget, no lock-screen controls, not even the
|
||||
/// small icon beside the clock — while audio kept playing and nothing was
|
||||
/// logged.
|
||||
/// Guards the ONE rule that makes a `MediaControl.custom` safe to put in the
|
||||
/// handler's transport `controls`.
|
||||
///
|
||||
/// Cause: an equalizer `MediaControl.custom(...)` had been appended to the
|
||||
/// handler's transport `controls`. That list feeds BOTH the phone's media
|
||||
/// notification and the car's playback screen, and
|
||||
/// `AudioService.setState` (AudioService.java:513-520) walks every control
|
||||
/// through `createCustomAction` BEFORE reaching
|
||||
/// `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) —
|
||||
/// the only place the notification is ever posted. `createCustomAction`
|
||||
/// resolves the icon by NAME via `getResources().getIdentifier(...)`
|
||||
/// (:415-420), which returns 0 on a miss, and hands it to
|
||||
/// `PlaybackStateCompat.CustomAction.Builder`, which throws on a 0 icon or an
|
||||
/// empty label. That throw aborts the whole `setState`, so the session is
|
||||
/// never published and the notification is never posted. ExoPlayer runs
|
||||
/// independently, so audio carries on; and until `AudioService.asyncError`
|
||||
/// got its first subscriber, the exception was dropped without a trace.
|
||||
/// so the session is never published and the notification is never posted,
|
||||
/// while ExoPlayer keeps playing regardless.
|
||||
///
|
||||
/// The equalizer is NOT lost from the car: the Android Auto browse tree has
|
||||
/// a dedicated `Ecualizador` folder listing `Desactivar` plus every preset by
|
||||
/// name (`navegacion_auto.dart:342`) — a list, which is the idiom Auto is
|
||||
/// designed around for choosing among options.
|
||||
///
|
||||
/// `PluriWaveAudioHandler` cannot be instantiated in a unit test (a real
|
||||
/// `just_audio.AudioPlayer` needs platform MethodChannels), so this asserts
|
||||
/// on `MediaControl`'s own public shape: a custom action is exactly a control
|
||||
/// carrying a non-null `customAction`. Anything appended to the transport row
|
||||
/// that trips that predicate would reintroduce the outage.
|
||||
/// 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() {
|
||||
group('transport controls must never carry a custom action', () {
|
||||
/// Mirrors `_controlesTransporte`'s construction exactly. Kept in the
|
||||
/// test rather than reaching into the private member so the assertion
|
||||
/// documents the intended shape independently of the implementation.
|
||||
/// 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(
|
||||
'colaActiva=$colaActiva playing=$playing yields only native actions',
|
||||
() {
|
||||
final controles = transporte(
|
||||
colaActiva: colaActiva,
|
||||
playing: playing,
|
||||
);
|
||||
test('compact index still points at play/pause '
|
||||
'(colaActiva=$colaActiva playing=$playing)', () {
|
||||
final controles = transporte(
|
||||
colaActiva: colaActiva,
|
||||
playing: playing,
|
||||
eqDisponible: true,
|
||||
);
|
||||
|
||||
expect(
|
||||
controles.where((c) => c.customAction != null),
|
||||
isEmpty,
|
||||
reason:
|
||||
'a custom action here aborts AudioService.setState before '
|
||||
'enterPlayingState(), so no notification is ever posted',
|
||||
);
|
||||
// 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,
|
||||
);
|
||||
|
||||
// androidCompactActionIndices is `[colaActiva ? 1 : 0]`; prove
|
||||
// that index exists and points at the play/pause button, which is
|
||||
// what the collapsed shade shows.
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the equalizer control builder still exists for the car, unused by '
|
||||
'the notification', () {
|
||||
// Deliberately still present and tested: removing it would be the wrong
|
||||
// lesson. The rule is "not in `controls`", not "never build one".
|
||||
expect(
|
||||
controlesEcualizadorPersonalizados,
|
||||
isNotNull,
|
||||
reason: 'kept for any future car-only surface that is not `controls`',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -120,4 +120,135 @@ void main() {
|
||||
expect(desactivar.title, contains(l10n.autoEqDisableOption));
|
||||
});
|
||||
});
|
||||
|
||||
/// On-device feedback: the folder only ever listed the six FACTORY
|
||||
/// presets, so a user's own saved presets were unreachable from the car —
|
||||
/// the surface where a preset picker matters most, since you cannot pull
|
||||
/// out the phone and drag five band sliders while driving.
|
||||
group('presets personalizados del usuario en la carpeta', () {
|
||||
final mio = PresetEcualizador(
|
||||
nombre: 'Mi coche',
|
||||
bandas: [4.0, 2.0, 0.0, 1.0, 3.0],
|
||||
);
|
||||
final otro = PresetEcualizador(
|
||||
nombre: 'Podcast noche',
|
||||
bandas: [-3.0, 0.0, 3.0, 2.0, -1.0],
|
||||
);
|
||||
|
||||
test('se listan tras los 6 de fábrica, en orden de guardado', () {
|
||||
final items = itemsEcualizadorAuto(
|
||||
activo: true,
|
||||
presetActual: PresetEcualizador.flat,
|
||||
l10n: l10n,
|
||||
presetsPersonalizados: [mio, otro],
|
||||
);
|
||||
|
||||
expect(items, hasLength(9));
|
||||
expect(items[7].title, contains('Mi coche'));
|
||||
expect(items[8].title, contains('Podcast noche'));
|
||||
});
|
||||
|
||||
test('su id sigue el mismo formato eq_preset:<nombre> y resuelve al '
|
||||
'preset correcto con la lista combinada', () {
|
||||
final constructor = ConstructorArbolAuto();
|
||||
final universo = presetsEcualizadorAuto(personalizados: [mio]);
|
||||
final items = itemsEcualizadorAuto(
|
||||
activo: true,
|
||||
presetActual: PresetEcualizador.flat,
|
||||
l10n: l10n,
|
||||
presetsPersonalizados: [mio],
|
||||
);
|
||||
|
||||
final item = items.last;
|
||||
expect(item.id, constructor.idPresetEq('Mi coche'));
|
||||
expect(item.playable, isTrue);
|
||||
expect(constructor.resolverPresetEq(item.id, presets: universo), mio);
|
||||
});
|
||||
|
||||
test('sin la lista combinada NO resuelve: es exactamente el fallo que se '
|
||||
'reportó, un item visible que al pulsarlo no hacía nada', () {
|
||||
final constructor = ConstructorArbolAuto();
|
||||
|
||||
// Default `presets` == the factory six only. This is what the tap
|
||||
// dispatch used to do, and why a listed custom preset was a no-op.
|
||||
expect(
|
||||
constructor.resolverPresetEq(constructor.idPresetEq('Mi coche')),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('pulsar un preset personalizado lo aplica de verdad', () async {
|
||||
final aplicados = <PresetEcualizador>[];
|
||||
var encendido = false;
|
||||
|
||||
await seleccionarPresetEqPorMediaId(
|
||||
ConstructorArbolAuto().idPresetEq('Mi coche'),
|
||||
activo: false,
|
||||
aplicarPreset: (p) async => aplicados.add(p),
|
||||
activarEcualizador: (v) async => encendido = v,
|
||||
presets: presetsEcualizadorAuto(personalizados: [mio]),
|
||||
);
|
||||
|
||||
expect(aplicados, [mio]);
|
||||
// Tapping any preset while the EQ is off must also switch it on,
|
||||
// exactly as it already does for a factory preset.
|
||||
expect(encendido, isTrue);
|
||||
});
|
||||
|
||||
test('el preset personalizado activo se marca con ✓, y solo él', () {
|
||||
final items = itemsEcualizadorAuto(
|
||||
activo: true,
|
||||
presetActual: mio,
|
||||
l10n: l10n,
|
||||
presetsPersonalizados: [mio, otro],
|
||||
);
|
||||
|
||||
final marcados = items.where((i) => i.title.contains('✓')).toList();
|
||||
expect(marcados, hasLength(1));
|
||||
expect(marcados.single.id, ConstructorArbolAuto().idPresetEq('Mi coche'));
|
||||
});
|
||||
|
||||
test('un personalizado que repite el nombre de uno de fábrica se DESCARTA '
|
||||
'— su id solo podría resolver al de fábrica, así que mostrarlo '
|
||||
'aplicaría un preset distinto del que anuncia', () {
|
||||
final impostor = PresetEcualizador(
|
||||
nombre: 'Rock',
|
||||
bandas: [9.0, 9.0, 9.0, 9.0, 9.0],
|
||||
);
|
||||
|
||||
final universo = presetsEcualizadorAuto(personalizados: [impostor, mio]);
|
||||
|
||||
expect(universo.where((p) => p.nombre == 'Rock'), hasLength(1));
|
||||
expect(
|
||||
universo.firstWhere((p) => p.nombre == 'Rock'),
|
||||
PresetEcualizador.rock,
|
||||
);
|
||||
expect(universo, contains(mio));
|
||||
expect(
|
||||
itemsEcualizadorAuto(
|
||||
activo: true,
|
||||
presetActual: PresetEcualizador.flat,
|
||||
l10n: l10n,
|
||||
presetsPersonalizados: [impostor, mio],
|
||||
),
|
||||
hasLength(8),
|
||||
);
|
||||
});
|
||||
|
||||
test('sin presets guardados la carpeta queda exactamente como antes', () {
|
||||
expect(
|
||||
itemsEcualizadorAuto(
|
||||
activo: true,
|
||||
presetActual: PresetEcualizador.flat,
|
||||
l10n: l10n,
|
||||
).map((i) => i.id),
|
||||
itemsEcualizadorAuto(
|
||||
activo: true,
|
||||
presetActual: PresetEcualizador.flat,
|
||||
l10n: l10n,
|
||||
presetsPersonalizados: const [],
|
||||
).map((i) => i.id),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user