Compare commits
3
Commits
9581ec97d7
...
34d04bbc1e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34d04bbc1e | ||
|
|
e962b8ac58 | ||
|
|
f2f706b342 |
@@ -11,6 +11,7 @@ import 'servicios/musica_local_auto.dart';
|
|||||||
import 'servicios/navegacion_auto.dart';
|
import 'servicios/navegacion_auto.dart';
|
||||||
import 'servicios/servicio_audio.dart';
|
import 'servicios/servicio_audio.dart';
|
||||||
import 'servicios/servicio_audio_session.dart';
|
import 'servicios/servicio_audio_session.dart';
|
||||||
|
import 'servicios/servicio_presets_personalizados.dart';
|
||||||
import 'tema/pluriwave_tokens.dart';
|
import 'tema/pluriwave_tokens.dart';
|
||||||
|
|
||||||
const _anchoMinimoLandscape = 600.0;
|
const _anchoMinimoLandscape = 600.0;
|
||||||
@@ -55,6 +56,14 @@ Future<void> main() async {
|
|||||||
// permanently hidden (`hayCarpetaConfigurada()` has no fuente to ask).
|
// permanently hidden (`hayCarpetaConfigurada()` has no fuente to ask).
|
||||||
registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl(prefs: prefs));
|
registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl(prefs: prefs));
|
||||||
|
|
||||||
|
// User-saved EQ presets for the car's Ecualizador folder, same
|
||||||
|
// injectable-prefs DI convention and same pre-init placement as the two
|
||||||
|
// registrations above (neither depends on the AudioHandler). Passed as a
|
||||||
|
// read function, not the service, so the folder re-reads on every browse:
|
||||||
|
// a preset saved on the phone appears in the car without an app restart.
|
||||||
|
final presetsPersonalizados = ServicioPresetsPersonalizados(prefs: prefs);
|
||||||
|
registrarFuentePresetsPersonalizados(presetsPersonalizados.listar);
|
||||||
|
|
||||||
// Silent-error channel (fix/notificacion-media): `AudioService.asyncError`
|
// Silent-error channel (fix/notificacion-media): `AudioService.asyncError`
|
||||||
// had ZERO subscribers app-wide, and a `PublishSubject` with no listeners
|
// had ZERO subscribers app-wide, and a `PublishSubject` with no listeners
|
||||||
// drops what it is given — so every exception `audio_service` catches
|
// drops what it is given — so every exception `audio_service` catches
|
||||||
|
|||||||
@@ -954,11 +954,18 @@ Future<void> reproducirPorMediaId(
|
|||||||
/// A stale/unresolvable id, or any id that doesn't match
|
/// A stale/unresolvable id, or any id that doesn't match
|
||||||
/// [ConstructorArbolAuto.esPresetEqMediaId], is a no-op: neither callback
|
/// [ConstructorArbolAuto.esPresetEqMediaId], is a no-op: neither callback
|
||||||
/// runs and no exception propagates.
|
/// runs and no exception propagates.
|
||||||
|
///
|
||||||
|
/// [presets] is the universe the id is resolved against, and it MUST be the
|
||||||
|
/// same list the folder was rendered from (`presetsEcualizadorAuto` in
|
||||||
|
/// `servicio_audio.dart` — factory presets plus the user's saved ones).
|
||||||
|
/// Defaulting to the factory six alone is what made a tapped custom preset a
|
||||||
|
/// silent no-op: the item was listed, but nothing here could resolve it.
|
||||||
Future<void> seleccionarPresetEqPorMediaId(
|
Future<void> seleccionarPresetEqPorMediaId(
|
||||||
String id, {
|
String id, {
|
||||||
required bool activo,
|
required bool activo,
|
||||||
required Future<void> Function(PresetEcualizador) aplicarPreset,
|
required Future<void> Function(PresetEcualizador) aplicarPreset,
|
||||||
required Future<void> Function(bool) activarEcualizador,
|
required Future<void> Function(bool) activarEcualizador,
|
||||||
|
List<PresetEcualizador>? presets,
|
||||||
}) async {
|
}) async {
|
||||||
final constructor = ConstructorArbolAuto();
|
final constructor = ConstructorArbolAuto();
|
||||||
if (!constructor.esPresetEqMediaId(id)) return;
|
if (!constructor.esPresetEqMediaId(id)) return;
|
||||||
@@ -968,7 +975,7 @@ Future<void> seleccionarPresetEqPorMediaId(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final preset = constructor.resolverPresetEq(id);
|
final preset = constructor.resolverPresetEq(id, presets: presets);
|
||||||
if (preset == null) return;
|
if (preset == null) return;
|
||||||
|
|
||||||
await aplicarPreset(preset);
|
await aplicarPreset(preset);
|
||||||
|
|||||||
@@ -60,6 +60,70 @@ void registrarFuenteMusicaLocal(FuenteMusicaLocalAuto fuente) {
|
|||||||
_fuenteMusicaLocalGlobal = fuente;
|
_fuenteMusicaLocalGlobal = fuente;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// User-saved EQ presets browse source — registered from main.dart, mirrors
|
||||||
|
/// the two registrations above.
|
||||||
|
///
|
||||||
|
/// On-device feedback: the car's `Ecualizador` folder only ever listed the
|
||||||
|
/// six FACTORY presets, so a driver who had carefully saved their own could
|
||||||
|
/// not reach it from the car at all — the one place a preset picker is most
|
||||||
|
/// useful. The handler owns no persistence, so the list arrives through this
|
||||||
|
/// seam exactly like stations and local music do.
|
||||||
|
///
|
||||||
|
/// A function rather than the service object: the folder needs a fresh read
|
||||||
|
/// on every browse (a preset saved on the phone must appear in the car
|
||||||
|
/// without an app restart), and this keeps `servicio_audio.dart` from
|
||||||
|
/// importing the persistence layer. `null` until registered (headless cold
|
||||||
|
/// bind) — consumers fall back to factory presets only, never throw.
|
||||||
|
Future<List<PresetEcualizador>> Function()? _fuentePresetsPersonalizadosGlobal;
|
||||||
|
|
||||||
|
void registrarFuentePresetsPersonalizados(
|
||||||
|
Future<List<PresetEcualizador>> Function() fuente,
|
||||||
|
) {
|
||||||
|
_fuentePresetsPersonalizadosGlobal = fuente;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the registered custom-preset source, tolerating both "never
|
||||||
|
/// registered" and "the read blew up" as the same empty result: a
|
||||||
|
/// diagnostics-grade failure must degrade the folder to its factory presets,
|
||||||
|
/// never make browsing fail.
|
||||||
|
Future<List<PresetEcualizador>> _leerPresetsPersonalizados() async {
|
||||||
|
final fuente = _fuentePresetsPersonalizadosGlobal;
|
||||||
|
if (fuente == null) return const [];
|
||||||
|
try {
|
||||||
|
return await fuente();
|
||||||
|
} catch (_) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full ordered preset universe the car's `Ecualizador` folder offers:
|
||||||
|
/// the six factory presets first, then [personalizados] in save order.
|
||||||
|
///
|
||||||
|
/// A custom preset whose `nombre` matches a factory preset is DROPPED, not
|
||||||
|
/// appended. Identity here is the raw name — [ConstructorArbolAuto.idPresetEq]
|
||||||
|
/// builds `eq_preset:<nombre>` from it and
|
||||||
|
/// [ConstructorArbolAuto.resolverPresetEq] resolves by first name match — so
|
||||||
|
/// two entries sharing a name would produce one media id that can only ever
|
||||||
|
/// reach the first of them. Rendering an item that silently applies a
|
||||||
|
/// different preset than the one whose name it shows is worse than not
|
||||||
|
/// rendering it, and the factory entry is the one the id is guaranteed to
|
||||||
|
/// resolve to.
|
||||||
|
///
|
||||||
|
/// Single source of truth on purpose: [itemsEcualizadorAuto] builds the items
|
||||||
|
/// from this list and the tap dispatch resolves against this same list, so
|
||||||
|
/// the folder can never show an item that resolution then refuses.
|
||||||
|
List<PresetEcualizador> presetsEcualizadorAuto({
|
||||||
|
required List<PresetEcualizador> personalizados,
|
||||||
|
List<PresetEcualizador>? deFabrica,
|
||||||
|
}) {
|
||||||
|
final fabrica = deFabrica ?? PresetEcualizador.presets;
|
||||||
|
final nombresFabrica = fabrica.map((p) => p.nombre).toSet();
|
||||||
|
return [
|
||||||
|
...fabrica,
|
||||||
|
...personalizados.where((p) => !nombresFabrica.contains(p.nombre)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/// Teardown hook for whatever `main.dart` wired around the handler and must
|
/// Teardown hook for whatever `main.dart` wired around the handler and must
|
||||||
/// be undone when the handler itself dies — today only the
|
/// be undone when the handler itself dies — today only the
|
||||||
/// `AudioService.asyncError` subscription (`observarErroresAudio`). Registered
|
/// `AudioService.asyncError` subscription (`observarErroresAudio`). Registered
|
||||||
@@ -313,10 +377,17 @@ String _marcarActivoEq(String titulo, {required bool activo}) =>
|
|||||||
/// since [presetActual] always originates from [PresetEcualizador.presets]
|
/// since [presetActual] always originates from [PresetEcualizador.presets]
|
||||||
/// or a "Personalizado" tweak that would just leave every item unmarked
|
/// or a "Personalizado" tweak that would just leave every item unmarked
|
||||||
/// rather than mis-marking one).
|
/// rather than mis-marking one).
|
||||||
|
///
|
||||||
|
/// [presetsPersonalizados] are the user's own saved presets, appended after
|
||||||
|
/// the factory six by [presetsEcualizadorAuto] (which also settles name
|
||||||
|
/// collisions). They render through the same [nombrePresetVisible] call as
|
||||||
|
/// everything else: that helper passes an unrecognized name through
|
||||||
|
/// verbatim, which is exactly right for a name the user typed themselves.
|
||||||
List<MediaItem> itemsEcualizadorAuto({
|
List<MediaItem> itemsEcualizadorAuto({
|
||||||
required bool activo,
|
required bool activo,
|
||||||
required PresetEcualizador presetActual,
|
required PresetEcualizador presetActual,
|
||||||
required AppLocalizations l10n,
|
required AppLocalizations l10n,
|
||||||
|
List<PresetEcualizador> presetsPersonalizados = const [],
|
||||||
}) {
|
}) {
|
||||||
final constructor = ConstructorArbolAuto();
|
final constructor = ConstructorArbolAuto();
|
||||||
final items = <MediaItem>[
|
final items = <MediaItem>[
|
||||||
@@ -327,7 +398,9 @@ List<MediaItem> itemsEcualizadorAuto({
|
|||||||
extras: _contentStyleListaEq,
|
extras: _contentStyleListaEq,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
for (final preset in PresetEcualizador.presets) {
|
for (final preset in presetsEcualizadorAuto(
|
||||||
|
personalizados: presetsPersonalizados,
|
||||||
|
)) {
|
||||||
items.add(
|
items.add(
|
||||||
MediaItem(
|
MediaItem(
|
||||||
id: constructor.idPresetEq(preset.nombre),
|
id: constructor.idPresetEq(preset.nombre),
|
||||||
@@ -637,29 +710,28 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
|||||||
/// [MediaControl.skipToPrevious]/play-pause/stop/[MediaControl.skipToNext]
|
/// [MediaControl.skipToPrevious]/play-pause/stop/[MediaControl.skipToNext]
|
||||||
/// at their existing indices 0-3, so `androidCompactActionIndices`
|
/// at their existing indices 0-3, so `androidCompactActionIndices`
|
||||||
/// (`[colaActiva ? 1 : 0]`) stays correct unchanged.
|
/// (`[colaActiva ? 1 : 0]`) stays correct unchanged.
|
||||||
/// NOTHING custom goes in this list. `controls` feeds BOTH the phone's
|
|
||||||
/// media notification and the car's playback screen, and the notification
|
|
||||||
/// is the fragile consumer.
|
|
||||||
///
|
///
|
||||||
/// `AudioService.setState` (AudioService.java:513-520) walks every control
|
/// A custom action here reaches the CAR ONLY, never the phone notification.
|
||||||
/// through `createCustomAction` BEFORE it reaches
|
/// `AudioService.setState` (AudioService.java:513-520) splits the list in
|
||||||
/// `mediaSession.setPlaybackState` (:552) and `enterPlayingState()` (:559),
|
/// two: `createCustomAction` returns non-null for a control carrying a
|
||||||
/// which is the ONLY place the notification is ever posted.
|
/// `customAction`, and that control goes into `customActions` — which feeds
|
||||||
/// `createCustomAction` resolves the icon by name via
|
/// `PlaybackStateCompat` and therefore the car's playback screen. Every
|
||||||
/// `getResources().getIdentifier(...)` (:415-420) — which returns 0 on a
|
/// other control falls to the `else` branch and becomes a
|
||||||
/// miss — and hands it to `PlaybackStateCompat.CustomAction.Builder`, which
|
/// `NotificationCompat.Action` in `nativeActions`, the list the media
|
||||||
/// throws on a 0 icon or an empty label. A throw there aborts the whole
|
/// notification is built from. The two never mix, so the equalizer toggle
|
||||||
/// `setState`, so the media session is never published and the
|
/// cannot displace a transport button and cannot shift the indices
|
||||||
/// notification is never posted: no shade widget, no lock-screen controls,
|
/// `androidCompactActionIndices` points at.
|
||||||
/// not even the small icon beside the clock. Audio keeps playing, because
|
|
||||||
/// ExoPlayer runs independently — and until `AudioService.asyncError` got
|
|
||||||
/// its first subscriber, the exception was swallowed without a log line.
|
|
||||||
///
|
///
|
||||||
/// The equalizer toggle that used to be appended here is NOT lost: the
|
/// THE ONE RULE for anything added here with a `customAction`: its
|
||||||
/// Android Auto browse tree has a dedicated `Ecualizador` folder listing
|
/// `androidIcon` must name a drawable that really exists, and its `label`
|
||||||
/// `Desactivar` plus every preset by name (`navegacion_auto.dart:342`),
|
/// must be non-empty in EVERY locale. `getResourceId` (:415-420) resolves
|
||||||
/// which is the idiom Auto is actually designed around — a list for
|
/// the icon by name through `getIdentifier` and yields 0 when it misses,
|
||||||
/// choosing among options, not a stateless icon-only button.
|
/// and `PlaybackStateCompat.CustomAction.Builder` throws on a 0 icon or an
|
||||||
|
/// empty label — a throw at :515 aborts `setState` before
|
||||||
|
/// `mediaSession.setPlaybackState` (:552), taking the whole media session
|
||||||
|
/// down with it. `servicio_audio_controles_notificacion_test.dart` holds
|
||||||
|
/// that line: it reads `android/app/src/main/res/drawable/` and fails if an
|
||||||
|
/// icon named here has no file behind it.
|
||||||
List<MediaControl> _controlesTransporte({
|
List<MediaControl> _controlesTransporte({
|
||||||
required bool colaActiva,
|
required bool colaActiva,
|
||||||
required bool playing,
|
required bool playing,
|
||||||
@@ -668,16 +740,23 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
|||||||
if (playing) MediaControl.pause else MediaControl.play,
|
if (playing) MediaControl.pause else MediaControl.play,
|
||||||
MediaControl.stop,
|
MediaControl.stop,
|
||||||
if (colaActiva) MediaControl.skipToNext,
|
if (colaActiva) MediaControl.skipToNext,
|
||||||
|
..._controlesEqPersonalizados(),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Re-pushes `playbackState` with a freshly built controls list.
|
List<MediaControl> _controlesEqPersonalizados() =>
|
||||||
///
|
controlesEcualizadorPersonalizados(
|
||||||
/// It no longer carries an equalizer action — see [_controlesTransporte]
|
disponible: _eqDisponible,
|
||||||
/// for why nothing custom may ride in `controls` — so this is now only a
|
activo: _ecualizadorActivo,
|
||||||
/// cheap, idempotent refresh of the transport buttons. Kept because the EQ
|
l10n: _textos,
|
||||||
/// state-change paths still legitimately want the notification's
|
);
|
||||||
/// play/pause/stop row rebuilt from current state, and because removing it
|
|
||||||
/// would silently change when `playbackState` is pushed.
|
/// Re-pushes `playbackState` with a freshly built controls list (item 4):
|
||||||
|
/// called whenever EQ availability/enabled state changes outside a
|
||||||
|
/// player-state transition (a custom-action tap, or a phone-side toggle),
|
||||||
|
/// so the equalizer action's icon and label stay in sync on the car's
|
||||||
|
/// now-playing screen without waiting for an unrelated player event.
|
||||||
|
/// Idempotent and cheap (no native calls) — safe to call from any EQ
|
||||||
|
/// state-changing path.
|
||||||
void _actualizarControlesEq() {
|
void _actualizarControlesEq() {
|
||||||
playbackState.add(
|
playbackState.add(
|
||||||
playbackState.value.copyWith(
|
playbackState.value.copyWith(
|
||||||
@@ -1408,6 +1487,9 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
|||||||
activo: _ecualizadorActivo,
|
activo: _ecualizadorActivo,
|
||||||
presetActual: _presetActual,
|
presetActual: _presetActual,
|
||||||
l10n: _textos,
|
l10n: _textos,
|
||||||
|
// Read per browse, not cached: a preset saved on the phone must
|
||||||
|
// show up in the car on the next open, with no app restart.
|
||||||
|
presetsPersonalizados: await _leerPresetsPersonalizados(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final fuente = _fuenteNavegacionGlobal;
|
final fuente = _fuenteNavegacionGlobal;
|
||||||
@@ -1495,6 +1577,11 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
|||||||
activo: _ecualizadorActivo,
|
activo: _ecualizadorActivo,
|
||||||
aplicarPreset: aplicarPreset,
|
aplicarPreset: aplicarPreset,
|
||||||
activarEcualizador: setEcualizadorActivo,
|
activarEcualizador: setEcualizadorActivo,
|
||||||
|
// The SAME list `itemsEcualizadorAuto` rendered from, so a tapped
|
||||||
|
// custom preset resolves instead of silently doing nothing.
|
||||||
|
presets: presetsEcualizadorAuto(
|
||||||
|
personalizados: await _leerPresetsPersonalizados(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,93 +1,191 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' show Locale;
|
||||||
|
|
||||||
import 'package:audio_service/audio_service.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||||
|
|
||||||
/// Regression guard for a real, user-reported outage: the media notification
|
/// Guards the ONE rule that makes a `MediaControl.custom` safe to put in the
|
||||||
/// vanished entirely — no shade widget, no lock-screen controls, not even the
|
/// handler's transport `controls`.
|
||||||
/// small icon beside the clock — while audio kept playing and nothing was
|
|
||||||
/// logged.
|
|
||||||
///
|
///
|
||||||
/// Cause: an equalizer `MediaControl.custom(...)` had been appended to the
|
/// `AudioService.setState` (AudioService.java:513-520) splits `controls` in
|
||||||
/// handler's transport `controls`. That list feeds BOTH the phone's media
|
/// two: a control carrying a `customAction` becomes a
|
||||||
/// notification and the car's playback screen, and
|
/// `PlaybackStateCompat.CustomAction` (the CAR's playback screen), everything
|
||||||
/// `AudioService.setState` (AudioService.java:513-520) walks every control
|
/// else becomes a `NotificationCompat.Action` (the PHONE's media
|
||||||
/// through `createCustomAction` BEFORE reaching
|
/// 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) —
|
/// `mediaSession.setPlaybackState` (:552) and `enterPlayingState()` (:559) —
|
||||||
/// the only place the notification is ever posted. `createCustomAction`
|
/// so the session is never published and the notification is never posted,
|
||||||
/// resolves the icon by NAME via `getResources().getIdentifier(...)`
|
/// while ExoPlayer keeps playing regardless.
|
||||||
/// (: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.
|
|
||||||
///
|
///
|
||||||
/// The equalizer is NOT lost from the car: the Android Auto browse tree has
|
/// A missing drawable is therefore a runtime-only failure on a real head
|
||||||
/// a dedicated `Ecualizador` folder listing `Desactivar` plus every preset by
|
/// unit: invisible to the analyzer, invisible to a widget test, and silent
|
||||||
/// name (`navegacion_auto.dart:342`) — a list, which is the idiom Auto is
|
/// unless someone is subscribed to `AudioService.asyncError`. The icon
|
||||||
/// designed around for choosing among options.
|
/// 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.
|
||||||
/// `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.
|
|
||||||
void main() {
|
void main() {
|
||||||
group('transport controls must never carry a custom action', () {
|
/// Resolves an `androidIcon` string (`'drawable/ic_foo'`) the same way
|
||||||
/// Mirrors `_controlesTransporte`'s construction exactly. Kept in the
|
/// `getResourceId` does: type directory, then resource name. Any file
|
||||||
/// test rather than reaching into the private member so the assertion
|
/// extension counts — a vector `.xml` and a raster `.png` are equally valid
|
||||||
/// documents the intended shape independently of the implementation.
|
/// 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({
|
List<MediaControl> transporte({
|
||||||
required bool colaActiva,
|
required bool colaActiva,
|
||||||
required bool playing,
|
required bool playing,
|
||||||
|
required bool eqDisponible,
|
||||||
}) => [
|
}) => [
|
||||||
if (colaActiva) MediaControl.skipToPrevious,
|
if (colaActiva) MediaControl.skipToPrevious,
|
||||||
if (playing) MediaControl.pause else MediaControl.play,
|
if (playing) MediaControl.pause else MediaControl.play,
|
||||||
MediaControl.stop,
|
MediaControl.stop,
|
||||||
if (colaActiva) MediaControl.skipToNext,
|
if (colaActiva) MediaControl.skipToNext,
|
||||||
|
...controlesEcualizadorPersonalizados(
|
||||||
|
disponible: eqDisponible,
|
||||||
|
activo: true,
|
||||||
|
l10n: lookupAppLocalizations(const Locale('es')),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (final colaActiva in [false, true]) {
|
for (final colaActiva in [false, true]) {
|
||||||
for (final playing in [false, true]) {
|
for (final playing in [false, true]) {
|
||||||
test(
|
test('compact index still points at play/pause '
|
||||||
'colaActiva=$colaActiva playing=$playing yields only native actions',
|
'(colaActiva=$colaActiva playing=$playing)', () {
|
||||||
() {
|
|
||||||
final controles = transporte(
|
final controles = transporte(
|
||||||
colaActiva: colaActiva,
|
colaActiva: colaActiva,
|
||||||
playing: playing,
|
playing: playing,
|
||||||
|
eqDisponible: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
// androidCompactActionIndices is `[colaActiva ? 1 : 0]`. The EQ
|
||||||
controles.where((c) => c.customAction != null),
|
// action is APPENDED, so the native transport buttons keep
|
||||||
isEmpty,
|
// indices 0-3 and the collapsed shade still shows play/pause.
|
||||||
reason:
|
|
||||||
'a custom action here aborts AudioService.setState before '
|
|
||||||
'enterPlayingState(), so no notification is ever posted',
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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;
|
final indiceCompacto = colaActiva ? 1 : 0;
|
||||||
expect(controles.length, greaterThan(indiceCompacto));
|
expect(controles.length, greaterThan(indiceCompacto));
|
||||||
expect(
|
expect(
|
||||||
controles[indiceCompacto],
|
controles[indiceCompacto],
|
||||||
playing ? MediaControl.pause : MediaControl.play,
|
playing ? MediaControl.pause : MediaControl.play,
|
||||||
);
|
);
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the equalizer control builder still exists for the car, unused by '
|
// The custom action must never sit among the transport buttons:
|
||||||
'the notification', () {
|
// `nativeActions` and `customActions` are built by walking this
|
||||||
// Deliberately still present and tested: removing it would be the wrong
|
// list in order, so an interleaved custom action would renumber
|
||||||
// lesson. The rule is "not in `controls`", not "never build one".
|
// the notification's own actions.
|
||||||
expect(
|
final indiceCustom = controles.indexWhere(
|
||||||
controlesEcualizadorPersonalizados,
|
(c) => c.customAction != null,
|
||||||
isNotNull,
|
|
||||||
reason: 'kept for any future car-only surface that is not `controls`',
|
|
||||||
);
|
);
|
||||||
|
expect(indiceCustom, controles.length - 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,4 +120,135 @@ void main() {
|
|||||||
expect(desactivar.title, contains(l10n.autoEqDisableOption));
|
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