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().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 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); }); } } }); }