Files
pluriwave/test/servicios/servicio_audio_controles_notificacion_test.dart
FreeTLab 7054a4c871 fix(alarmas,auto): guard the native snooze anchor, surface the EQ in the car
Three reported issues, two fixed and one instrumented.

1. Posponer left the alarm snoozed ~1444 minutes (24h04m).

Traced end to end in Kotlin. onAlarmFired runs from the receiver BEFORE the
ringing notification exists, and persists snoozeOriginMillis = null plus a
triggerAtMillis already advanced to TOMORROW by computeNextTriggerMillis.
snooze() then anchored on `spec.snoozeOriginMillis ?: spec.triggerAtMillis`
and picked up tomorrow. The existing clamp could not catch it: it only
rescues anchors in the PAST, so an anchor +24h out sails through. The
countdown text is honest -- ceilMinutes(snoozeUntil - now) over Dart's own
template -- the corrupt value is snoozeUntil. With N=5 and a tap at T+1min
the arithmetic lands on 1444 exactly.

This is the defect a9da855 fixed on the Dart side with
EstadoAlarmas._ocurrenciaSonando, after 9c7cf4e had fixed only one of two
adjacent callers. The native lane never got that guard. Now it has
anchorOccurrenceMillis, with a per-surface forward allowance: none for
snooze() (the ringing notification closes an occurrence that has arrived)
and a full PRE_NOTICE_MILLIS for postponeNext() (the pre-notice
notification's occurrence legitimately has not).

No Kotlin test source set exists in this project, so CI cannot verify this
and no Dart test sees it (all use FakePuertoAlarmasAndroid). Verified by
reading; needs an on-device pass.

2. The equalizer toggle stayed invisible on the Android Auto playback
screen even on v1.2.14+136, which does contain it.

On Android 13+ createCustomAction (AudioService.java:466-469) turns
MediaControl.stop into a custom action too, so the car receives TWO in list
order and stop was first -- a head unit exposing one custom-action slot
shows that and buries the rest in an overflow menu. The equalizer now
precedes stop and wins the slot; it is the better occupant, since the car
has its own path to stop playback while the equalizer is reachable no other
way from that screen.

No platform detection needed, and the phone notification is untouched on
every API level: nativeActions comes out [prev?, play/pause, stop, next?]
below 13 and [prev?, play/pause, next?] on 13+, exactly as before. Both are
now asserted.

The list also moves to a public construirControlesTransporte. The guard
test used to re-declare its own copy of the shape, so it stayed green while
asserting a list lib/ no longer produced. It calls the real builder now.

3. Android Auto shows PLAY while a station is audibly playing: NOT fixed,
deliberately.

The car takes that icon from PlaybackStateCompat.getState()
(AudioService.java:601-611), not from controls -- so none of the recent
controls work can be the cause. All eight playbackState.add sites were
audited and none publishes playing:false while audio runs, which leaves no
traced input to fix. A proposed resync off bufferedPositionStream was
rejected: it can publish a spurious idle, which AudioService.java:565-567
turns into stop() and tears down the foreground service -- the exact
regression abc6b47 fixed, on the highest-frequency listener in the handler.

Added instead a change-gated trace of the state actually published, with
eqDisponible alongside it (that flag gates the equalizer action and is
otherwise unobservable). One car session with `adb logcat -s ServicioAudio`
settles both this and issue 2.

Tests: 1124 -> 1127.
2026-08-05 10:17:41 +02:00

251 lines
9.4 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', () {
// 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<MediaControl> 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<MediaControl> nativas(
List<MediaControl> 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,
]);
});
});
}