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', () { // Calls the REAL builder, never a copy of it. This group used to // re-declare the list inline, which meant it stayed green while asserting // a shape lib/ no longer produced — a guard blind to the thing it guards. List transporte({ required bool colaActiva, required bool playing, required bool eqDisponible, }) => construirControlesTransporte( colaActiva: colaActiva, playing: playing, eqDisponible: eqDisponible, eqActivo: true, l10n: lookupAppLocalizations(const Locale('es')), ); /// What `AudioService.setState` (AudioService.java:513-521) would route to /// `nativeActions` — the ONLY list the phone's media notification is built /// from, and the list `androidCompactActionIndices` indexes into. /// /// On Android 13+ `MediaControl.stop` also becomes a custom action /// (:466-469), so pass [sdk33] to model that split. List nativas( List controles, { required bool sdk33, }) => controles .where((c) => c.customAction == null) .where((c) => !(sdk33 && c == MediaControl.stop)) .toList(); for (final colaActiva in [false, true]) { for (final playing in [false, true]) { test('compact index still points at play/pause on BOTH API levels ' '(colaActiva=$colaActiva playing=$playing)', () { final controles = transporte( colaActiva: colaActiva, playing: playing, eqDisponible: true, ); final indiceCompacto = colaActiva ? 1 : 0; for (final sdk33 in [false, true]) { final row = nativas(controles, sdk33: sdk33); expect(row.length, greaterThan(indiceCompacto)); expect( row[indiceCompacto], playing ? MediaControl.pause : MediaControl.play, reason: 'androidCompactActionIndices is [colaActiva ? 1 : 0] and it ' 'indexes nativeActions (AudioService.java:613-618, :637-639)', ); } }); } } test('the equalizer comes BEFORE stop, so it wins the first custom-action ' 'slot on the car', () { // Reported on v1.2.14+136: the toggle was in the binary but invisible on // the head unit. On Android 13+ stop is ALSO a custom action, and it used // to be first — a unit exposing one slot showed stop and buried the // equalizer in an overflow menu. final controles = transporte( colaActiva: true, playing: true, eqDisponible: true, ); final custom = controles.where( (c) => c.customAction != null || c == MediaControl.stop, ); expect( custom.first.customAction, isNotNull, reason: 'on Android 13+ this is the order the car receives them in', ); }); test('reordering did NOT disturb the notification row', () { // The whole safety argument for the swap: nativeActions must come out // [prev?, play/pause, stop, next?] on <13 and [prev?, play/pause, next?] // on 13+, exactly as before the equalizer moved. final controles = transporte( colaActiva: true, playing: true, eqDisponible: true, ); expect(nativas(controles, sdk33: false), [ MediaControl.skipToPrevious, MediaControl.pause, MediaControl.stop, MediaControl.skipToNext, ]); expect(nativas(controles, sdk33: true), [ MediaControl.skipToPrevious, MediaControl.pause, MediaControl.skipToNext, ]); }); test('a device with no equalizer gets the exact pre-existing list', () { expect(transporte(colaActiva: true, playing: true, eqDisponible: false), [ MediaControl.skipToPrevious, MediaControl.pause, MediaControl.stop, MediaControl.skipToNext, ]); }); }); }