fix(auto): keep the service alive through interruptions, restore local music
Four car reports, two root causes.
1. Local music vanished from the Android Auto menu. Self-inflicted, by
c1afe72 yesterday.
That commit moved registrarFuenteNavegacion above every await to keep a
headless engine from dying before it ran -- but left
registrarFuenteMusicaLocal below `await SharedPreferences.getInstance()`.
The root menu decides whether to offer "Música Local" with
`fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada()`, so
the car could now get a root response in the window between the two
registrations, find a null source, and be told there is no local music.
Android Auto caches the browse root, so it stayed missing for the whole
session. Before the reorder both registrations sat together after the
await and the window did not exist.
FuenteMusicaLocalAutoImpl never needed prefs to be CONSTRUCTED -- it
resolves them lazily per call, the same convention ServicioAlarmas uses
-- so it now registers beside the station source, above every await, and
the window is gone rather than narrowed.
2. PluriWave disappeared from the Auto pane mid-drive, the playback
screen sat frozen, and the equalizer was lost on every navigation
prompt. One cause for all three.
androidWillPauseWhenDucked: true made audio_session translate Android's
AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK into a full PAUSE. In a car that
fires constantly: every navigation instruction, every speed-camera
warning, every voice assistant. And a pause publishes playing:false,
which AudioService.setState turns into exitPlayingState() and, with
androidStopForegroundOnPause: true, into stopForeground(...). The
plugin's own doc for that flag says what follows: "while in this lower
priority state, the operating system will also be able to kill your
service at any time to reclaim resources". A killed service is a media
session that vanishes from the car pane -- and another media app takes
the slot.
Now the app ducks instead of pausing, so playing stays true and session,
notification and pane all survive an interruption; and the service stays
foreground even on a real pause, so a genuine one is not a death
sentence either. androidNotificationOngoing goes to false because the
plugin asserts it implies stopForegroundOnPause, and nothing is lost: a
foreground service already forces the notification to be ongoing.
A real, non-duckable focus loss (a phone call) still pauses and still
auto-resumes -- asserted, so the duck change cannot silently turn a call
into a station playing over it.
3. Previous/next on the car playback screen, for stations too.
skipToPrevious/skipToNext are now advertised unconditionally, since
Android Auto only draws those buttons when the app declares support.
They are no longer inert without a local queue: they walk the narrowest
list the current station belongs to -- favourites, then my stations,
then the catalogue -- wrapping at both ends, because a button that goes
dead at the end of a list reads as broken on a screen with no visible
list position. Matching is by uuid so a refreshed snapshot still
resolves, and a station in no list leaves playback untouched.
The equalizer toggle still fits alongside them: prev/next take their two
reserved slots and the equalizer claims the remaining custom-action room
because construirControlesTransporte places it before MediaControl.stop.
The phone notification is deliberately untouched: `controls` still gates
skip on an active queue, so nativeActions and
androidCompactActionIndices are byte-identical. Only systemActions
changed, and only the car reads those.
Tests: 1146 -> 1158.
This commit is contained in:
+44
-9
@@ -26,8 +26,30 @@ const androidNotificationIconResource = 'drawable/ic_stat_pluriwave';
|
||||
const configuracionAudioService = AudioServiceConfig(
|
||||
androidNotificationChannelId: 'es.freetimelab.pluriwave.audio',
|
||||
androidNotificationChannelName: 'PluriWave Radio',
|
||||
androidNotificationOngoing: true,
|
||||
androidStopForegroundOnPause: true,
|
||||
// Paired with `androidStopForegroundOnPause: false` below, and required to
|
||||
// be: the plugin asserts `androidNotificationOngoing` implies
|
||||
// `androidStopForegroundOnPause`. Nothing is lost by turning it off —
|
||||
// while the service is in the foreground the OS forces the notification to
|
||||
// be ongoing anyway, which is now the whole time playback is alive.
|
||||
androidNotificationOngoing: false,
|
||||
// The service stays in the FOREGROUND while paused.
|
||||
//
|
||||
// With `true`, a pause called `stopForeground(...)`, and a service that is
|
||||
// not in the foreground is a service Android may kill at will. In the car
|
||||
// that is exactly what happened: an interruption paused playback, the
|
||||
// service dropped out of the foreground, Android reclaimed it, and
|
||||
// PluriWave disappeared from the Android Auto pane — another media app
|
||||
// took the slot. Ducking (see `ServicioAudioSession.configurar`) removes
|
||||
// most pauses, but a real pause must not be a death sentence either.
|
||||
//
|
||||
// The plugin's own doc for this flag says it outright: «while in this
|
||||
// lower priority state, the operating system will also be able to kill
|
||||
// your service at any time to reclaim resources».
|
||||
//
|
||||
// Cost of `false`: the notification is not swipe-dismissible while paused,
|
||||
// only after Stop. That is how every serious media app behaves, and Stop
|
||||
// still tears everything down.
|
||||
androidStopForegroundOnPause: false,
|
||||
notificationColor: PluriWaveTokens.brand,
|
||||
androidNotificationIcon: androidNotificationIconResource,
|
||||
);
|
||||
@@ -56,6 +78,26 @@ Future<void> main() async {
|
||||
final fuenteAuto = FuenteEmisorasAutoLocal();
|
||||
registrarFuenteNavegacion(fuenteAuto);
|
||||
|
||||
// Local music registers HERE, above every await, alongside the station
|
||||
// source — not after `SharedPreferences.getInstance()` where it used to
|
||||
// sit.
|
||||
//
|
||||
// Regression this fixes, self-inflicted by the reordering above: the root
|
||||
// menu decides whether to offer "Música Local" with
|
||||
// `fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada()`.
|
||||
// Moving ONLY the station source above the awaits meant the car could get
|
||||
// a root response in the window before this line ran, find a null source,
|
||||
// and be told there is no local music — and Android Auto caches the browse
|
||||
// root, so it stayed missing for the whole session. Before the reorder
|
||||
// both registrations sat together after the await, so the window did not
|
||||
// exist.
|
||||
//
|
||||
// `FuenteMusicaLocalAutoImpl` needs no prefs to be CONSTRUCTED: it
|
||||
// resolves them lazily per call (`_resolverPrefs`, falling back to
|
||||
// `getInstance()`), the same convention `ServicioAlarmas` uses. So there
|
||||
// was never a reason for it to wait on that await.
|
||||
registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl());
|
||||
|
||||
// Cosmetic, and deliberately NOT awaited: a display preference must never
|
||||
// gate `runApp`. `_OrientacionResponsiveApp.didChangeDependencies` applies
|
||||
// it again as soon as a real view exists, which is the only moment it can
|
||||
@@ -66,13 +108,6 @@ Future<void> main() async {
|
||||
// injected into every state/service below.
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Local-music browse source (Design "getChildren data source
|
||||
// registration"), same injectable-prefs DI convention as every other
|
||||
// startup service — required so `_fuenteMusicaLocalGlobal` is ever
|
||||
// non-null; without this registration the local-music root would stay
|
||||
// permanently hidden (`hayCarpetaConfigurada()` has no fuente to ask).
|
||||
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
|
||||
|
||||
@@ -936,6 +936,58 @@ Future<void> reproducirPorMediaId(
|
||||
await reproducir(item);
|
||||
}
|
||||
|
||||
/// Which list previous/next should walk for [actual]: the NARROWEST list the
|
||||
/// station actually belongs to, favourites first, then my stations, then the
|
||||
/// full catalogue.
|
||||
///
|
||||
/// Narrowest-first is the point. "Next station" while playing a favourite
|
||||
/// should land on the next favourite, not on entry 4,318 of a 50,000-station
|
||||
/// catalogue that happens to sit beside it alphabetically. Falling through to
|
||||
/// [todas] only when the station is in neither curated list keeps the button
|
||||
/// working for a station reached by search.
|
||||
///
|
||||
/// Returns an empty list when [actual] is in none of them, which
|
||||
/// [emisoraVecina] turns into "do nothing".
|
||||
List<Emisora> listaParaSaltoEmisora({
|
||||
required Emisora actual,
|
||||
required List<Emisora> favoritos,
|
||||
required List<Emisora> misEmisoras,
|
||||
required List<Emisora> todas,
|
||||
}) {
|
||||
bool contiene(List<Emisora> lista) => lista.any((e) => e.uuid == actual.uuid);
|
||||
if (contiene(favoritos)) return favoritos;
|
||||
if (contiene(misEmisoras)) return misEmisoras;
|
||||
if (contiene(todas)) return todas;
|
||||
return const [];
|
||||
}
|
||||
|
||||
/// The station before or after [actual] in [lista], wrapping around at both
|
||||
/// ends.
|
||||
///
|
||||
/// Wrapping is deliberate: on a car's transport row a button that goes dead
|
||||
/// at the end of a list reads as a broken app, and there is no visible list
|
||||
/// position to explain it. Matching is by `uuid`, the same identity the
|
||||
/// browse tree uses, so a refreshed snapshot with different object instances
|
||||
/// still resolves.
|
||||
///
|
||||
/// Returns `null` when [lista] has fewer than two entries, or when [actual]
|
||||
/// is not in it — the caller must then leave playback alone rather than jump
|
||||
/// somewhere arbitrary.
|
||||
Emisora? emisoraVecina(
|
||||
Emisora? actual,
|
||||
List<Emisora> lista, {
|
||||
required bool haciaAtras,
|
||||
}) {
|
||||
if (actual == null || lista.length < 2) return null;
|
||||
final indice = lista.indexWhere((e) => e.uuid == actual.uuid);
|
||||
if (indice < 0) return null;
|
||||
final destino =
|
||||
haciaAtras
|
||||
? (indice - 1 + lista.length) % lista.length
|
||||
: (indice + 1) % lista.length;
|
||||
return lista[destino];
|
||||
}
|
||||
|
||||
/// Picks the station a spoken query refers to ("pon Radio Clásica"), over the
|
||||
/// stations the car can already browse.
|
||||
///
|
||||
|
||||
@@ -735,8 +735,14 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
MediaAction.playFromMediaId,
|
||||
MediaAction.playFromSearch,
|
||||
MediaAction.seek,
|
||||
if (colaActiva) MediaAction.skipToPrevious,
|
||||
if (colaActiva) MediaAction.skipToNext,
|
||||
// Previous/next are advertised ALWAYS now, not only for a local
|
||||
// queue. Android Auto reserves those two slots and only hands the
|
||||
// space to custom actions when the app declares no support, so
|
||||
// this is what puts prev/next on the car's transport row -- and
|
||||
// `skipToNext`/`skipToPrevious` fall back to station-to-station
|
||||
// skipping when there is no queue, so neither button is inert.
|
||||
MediaAction.skipToPrevious,
|
||||
MediaAction.skipToNext,
|
||||
},
|
||||
androidCompactActionIndices: [colaActiva ? 1 : 0],
|
||||
processingState: mapearEstadoProceso(
|
||||
@@ -1485,13 +1491,19 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
Future<void> seek(Duration position) => _player.seek(position);
|
||||
|
||||
/// Moves to the next queued track (Design ADR-3/ADR-4, Phase 3 task
|
||||
/// 3.6): a no-op when no local queue is active. Past the last track,
|
||||
/// clears the queue and stops — mirroring auto-advance's end-of-queue
|
||||
/// behavior (no wraparound).
|
||||
/// 3.6). Past the last track, clears the queue and stops — mirroring
|
||||
/// auto-advance's end-of-queue behavior (no wraparound).
|
||||
///
|
||||
/// With NO local queue this now moves to the next STATION instead of doing
|
||||
/// nothing: the car's transport row offers previous/next for radio too,
|
||||
/// and a button that is present but inert is worse than no button.
|
||||
@override
|
||||
Future<void> skipToNext() async {
|
||||
final cola = _colaLocal;
|
||||
if (cola == null) return;
|
||||
if (cola == null) {
|
||||
await _saltarEmisora(haciaAtras: false);
|
||||
return;
|
||||
}
|
||||
final siguiente = cola.conSiguiente();
|
||||
if (siguiente == null) {
|
||||
_desactivarCola();
|
||||
@@ -1502,18 +1514,53 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
await _reproducirEntradaCola(siguiente.actual);
|
||||
}
|
||||
|
||||
/// Moves to the previous queued track (Design ADR-4, Phase 3 task 3.6):
|
||||
/// a no-op when no local queue is active. Clamps at the first track
|
||||
/// (restarts it) instead of wrapping to the last one.
|
||||
/// Moves to the previous queued track (Design ADR-4, Phase 3 task 3.6).
|
||||
/// Clamps at the first track (restarts it) instead of wrapping to the last
|
||||
/// one. With no local queue, moves to the previous STATION — see
|
||||
/// [skipToNext].
|
||||
@override
|
||||
Future<void> skipToPrevious() async {
|
||||
final cola = _colaLocal;
|
||||
if (cola == null) return;
|
||||
if (cola == null) {
|
||||
await _saltarEmisora(haciaAtras: true);
|
||||
return;
|
||||
}
|
||||
final anterior = cola.conAnterior();
|
||||
_colaLocal = anterior;
|
||||
await _reproducirEntradaCola(anterior.actual);
|
||||
}
|
||||
|
||||
/// Station-to-station skipping for the car's transport row.
|
||||
///
|
||||
/// The list to walk is resolved by [listaParaSaltoEmisora]: the narrowest
|
||||
/// list the current station actually belongs to, favourites first. Anything
|
||||
/// unresolvable — no source, no current station, a station that is in no
|
||||
/// list, a single-entry list — leaves playback untouched. Never throws;
|
||||
/// this runs from a hardware/steering-wheel button and an exception here
|
||||
/// would surface as the app going silent mid-drive.
|
||||
Future<void> _saltarEmisora({required bool haciaAtras}) async {
|
||||
try {
|
||||
final fuente = _fuenteNavegacionGlobal;
|
||||
final actual = emisoraActual;
|
||||
if (fuente == null || actual == null) return;
|
||||
final lista = listaParaSaltoEmisora(
|
||||
actual: actual,
|
||||
favoritos: await fuente.favoritos(),
|
||||
misEmisoras: await fuente.misEmisoras(),
|
||||
todas: await fuente.todas(),
|
||||
);
|
||||
final destino = emisoraVecina(actual, lista, haciaAtras: haciaAtras);
|
||||
if (destino == null) return;
|
||||
await playMediaItem(mediaItemParaEmisora(destino, l10n: _textos));
|
||||
} catch (e) {
|
||||
developer.log(
|
||||
'[PluriWave] Error saltando de emisora: $e',
|
||||
name: 'ServicioAudio',
|
||||
level: 900,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatches the equalizer's only custom action (decision
|
||||
/// `auto/ecualizador-diseno`): `accionEqToggle` flips on/off, delegating
|
||||
/// to the existing [setEcualizadorActivo] — the SAME entry point the
|
||||
|
||||
@@ -58,9 +58,26 @@ class ServicioAudioSession {
|
||||
Future<void> configurar() async {
|
||||
try {
|
||||
final sesion = await _obtenerSesion();
|
||||
// DUCK, never pause, when another app asks for transient focus.
|
||||
//
|
||||
// `androidWillPauseWhenDucked: true` makes `audio_session` translate
|
||||
// Android's AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK into a full PAUSE. In a
|
||||
// car that fires constantly — every navigation instruction, every
|
||||
// speed-camera warning, every "OK Google" — and each one used to stop
|
||||
// the radio outright instead of dipping the volume for two seconds.
|
||||
//
|
||||
// Worse than the audio gap: a pause publishes `playing: false`, which
|
||||
// `AudioService.setState` turns into `exitPlayingState()` and, with
|
||||
// `androidStopForegroundOnPause`, into `stopForeground(...)`. A service
|
||||
// that is no longer in the foreground is killable, and when Android
|
||||
// took it the app vanished from the Android Auto pane mid-drive and
|
||||
// another media app took its slot. Ducking keeps `playing: true`
|
||||
// throughout, so the session, the notification and the car pane all
|
||||
// survive an interruption — which is also what keeps the equalizer
|
||||
// alive across it.
|
||||
await sesion.configure(
|
||||
const AudioSessionConfiguration.music().copyWith(
|
||||
androidWillPauseWhenDucked: true,
|
||||
androidWillPauseWhenDucked: false,
|
||||
),
|
||||
);
|
||||
await _interrupcionesSub?.cancel();
|
||||
|
||||
@@ -70,10 +70,11 @@ void main() {
|
||||
// and showing the test still finishes -- if startup awaited it, this
|
||||
// future is exactly what would hang forever on the headless engine.
|
||||
var termino = false;
|
||||
// ignore: unawaited_futures
|
||||
aplicarPoliticaOrientacion(
|
||||
aplicar: (_) => Completer<void>().future,
|
||||
).then((_) => termino = true);
|
||||
unawaited(
|
||||
aplicarPoliticaOrientacion(
|
||||
aplicar: (_) => Completer<void>().future,
|
||||
).then((_) => termino = true),
|
||||
);
|
||||
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(termino, isFalse, reason: 'sigue pendiente, como debe');
|
||||
|
||||
@@ -31,8 +31,8 @@ void main() {
|
||||
MediaAction.playFromMediaId,
|
||||
MediaAction.playFromSearch,
|
||||
MediaAction.seek,
|
||||
if (colaActiva) MediaAction.skipToPrevious,
|
||||
if (colaActiva) MediaAction.skipToNext,
|
||||
MediaAction.skipToPrevious,
|
||||
MediaAction.skipToNext,
|
||||
};
|
||||
|
||||
group('acciones que Android for Cars documenta como obligatorias', () {
|
||||
@@ -55,22 +55,31 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
test('los saltos SOLO se anuncian con cola activa', () {
|
||||
// Android Auto reserves the prev/next slots for these actions and, when
|
||||
// the app does not support them, hands the space to custom actions --
|
||||
// which is where the equalizer toggle lives. Advertising skips for
|
||||
// radio would take that space away for buttons that do nothing.
|
||||
expect(
|
||||
systemActions(colaActiva: false),
|
||||
isNot(contains(MediaAction.skipToPrevious)),
|
||||
);
|
||||
expect(
|
||||
systemActions(colaActiva: true),
|
||||
containsAll(<MediaAction>[
|
||||
MediaAction.skipToPrevious,
|
||||
MediaAction.skipToNext,
|
||||
]),
|
||||
);
|
||||
test('los saltos se anuncian SIEMPRE, también para radio', () {
|
||||
// Reversal of the previous version of this test, and deliberate.
|
||||
//
|
||||
// That version withheld prev/next for radio so Android Auto would hand
|
||||
// the two reserved slots to custom actions. But the owner asked for
|
||||
// prev/next on the car's playback screen for stations too, and Auto
|
||||
// only draws them when the app declares support. `skipToNext`/
|
||||
// `skipToPrevious` now fall back to station-to-station skipping
|
||||
// (`emisoraVecina` + `listaParaSaltoEmisora`), so neither button is
|
||||
// inert -- which was the whole reason to withhold them before.
|
||||
//
|
||||
// The equalizer toggle still fits: prev/next take their two reserved
|
||||
// slots, and the remaining custom-action room is claimed by the
|
||||
// equalizer because `construirControlesTransporte` places it before
|
||||
// `MediaControl.stop`.
|
||||
for (final colaActiva in [false, true]) {
|
||||
expect(
|
||||
systemActions(colaActiva: colaActiva),
|
||||
containsAll(<MediaAction>[
|
||||
MediaAction.skipToPrevious,
|
||||
MediaAction.skipToNext,
|
||||
]),
|
||||
reason: 'colaActiva=$colaActiva',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||
|
||||
/// Requested: the Android Auto playback screen must offer previous/next for
|
||||
/// stations too, not only for a local-music queue. Those buttons appear
|
||||
/// because `skipToPrevious`/`skipToNext` are advertised in `systemActions` —
|
||||
/// so they have to actually move, or the car shows two dead buttons.
|
||||
void main() {
|
||||
Emisora emisora(String uuid) =>
|
||||
Emisora(uuid: uuid, nombre: uuid, url: 'https://example.com/$uuid');
|
||||
|
||||
final a = emisora('a');
|
||||
final b = emisora('b');
|
||||
final c = emisora('c');
|
||||
|
||||
group('emisoraVecina', () {
|
||||
test('avanza y retrocede dentro de la lista', () {
|
||||
expect(emisoraVecina(a, [a, b, c], haciaAtras: false), b);
|
||||
expect(emisoraVecina(b, [a, b, c], haciaAtras: true), a);
|
||||
});
|
||||
|
||||
test('da la vuelta en los dos extremos', () {
|
||||
// A dead button at the end of a list reads as a broken app on a car
|
||||
// screen, where there is no visible list position to explain it.
|
||||
expect(emisoraVecina(c, [a, b, c], haciaAtras: false), a);
|
||||
expect(emisoraVecina(a, [a, b, c], haciaAtras: true), c);
|
||||
});
|
||||
|
||||
test('identifica por uuid, no por instancia: un snapshot refrescado trae '
|
||||
'objetos distintos', () {
|
||||
final copiaDeB = Emisora(
|
||||
uuid: 'b',
|
||||
nombre: 'otro nombre',
|
||||
url: 'https://example.com/cambiada',
|
||||
);
|
||||
expect(emisoraVecina(copiaDeB, [a, b, c], haciaAtras: false), c);
|
||||
});
|
||||
|
||||
test('no hace nada sin contexto suficiente', () {
|
||||
expect(emisoraVecina(null, [a, b], haciaAtras: false), isNull);
|
||||
expect(emisoraVecina(a, [a], haciaAtras: false), isNull);
|
||||
expect(emisoraVecina(a, const [], haciaAtras: false), isNull);
|
||||
expect(
|
||||
emisoraVecina(emisora('fuera'), [a, b], haciaAtras: false),
|
||||
isNull,
|
||||
reason: 'una emisora que no está en la lista no debe saltar a ciegas',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('listaParaSaltoEmisora', () {
|
||||
test('favoritos gana: "siguiente" desde un favorito va al siguiente '
|
||||
'favorito, no a la entrada 4318 del catálogo', () {
|
||||
expect(
|
||||
listaParaSaltoEmisora(
|
||||
actual: a,
|
||||
favoritos: [a, b],
|
||||
misEmisoras: [a, c],
|
||||
todas: [a, b, c],
|
||||
),
|
||||
[a, b],
|
||||
);
|
||||
});
|
||||
|
||||
test('mis emisoras cuando no es favorita', () {
|
||||
expect(
|
||||
listaParaSaltoEmisora(
|
||||
actual: c,
|
||||
favoritos: [a, b],
|
||||
misEmisoras: [c, a],
|
||||
todas: [a, b, c],
|
||||
),
|
||||
[c, a],
|
||||
);
|
||||
});
|
||||
|
||||
test('cae al catálogo completo para una emisora llegada por búsqueda', () {
|
||||
expect(
|
||||
listaParaSaltoEmisora(
|
||||
actual: c,
|
||||
favoritos: [a],
|
||||
misEmisoras: [b],
|
||||
todas: [a, b, c],
|
||||
),
|
||||
[a, b, c],
|
||||
);
|
||||
});
|
||||
|
||||
test('vacía si no está en ninguna: el salto queda en no-op', () {
|
||||
expect(
|
||||
listaParaSaltoEmisora(
|
||||
actual: emisora('huerfana'),
|
||||
favoritos: [a],
|
||||
misEmisoras: [b],
|
||||
todas: [a, b],
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio_session.dart';
|
||||
|
||||
/// Reported from the car: PluriWave disappeared from the Android Auto pane
|
||||
/// mid-drive and another media app took its slot, the playback screen sat
|
||||
/// frozen, and the chosen equalizer was lost whenever navigation, a
|
||||
/// speed-camera app or the car's voice assistant spoke.
|
||||
///
|
||||
/// One cause behind all three. `androidWillPauseWhenDucked: true` made
|
||||
/// `audio_session` translate Android's AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK
|
||||
/// into a full PAUSE. In a car that fires on every navigation instruction. A
|
||||
/// pause publishes `playing: false`, `AudioService.setState` turns that into
|
||||
/// `exitPlayingState()` and — with `androidStopForegroundOnPause` — into
|
||||
/// `stopForeground(...)`. The plugin's own doc for that flag: «while in this
|
||||
/// lower priority state, the operating system will also be able to kill your
|
||||
/// service at any time to reclaim resources». A killed service is a media
|
||||
/// session that vanishes from the car.
|
||||
///
|
||||
/// Ducking keeps `playing: true` throughout, so session, notification and
|
||||
/// car pane all survive the interruption.
|
||||
class _ObjetivoFalso implements ObjetivoAudioInterrumpible {
|
||||
bool atenuado = false;
|
||||
int pausas = 0;
|
||||
int reanudaciones = 0;
|
||||
int reaplicacionesEq = 0;
|
||||
|
||||
@override
|
||||
bool intencionReproducir = true;
|
||||
|
||||
@override
|
||||
bool estaReproduciendo = true;
|
||||
|
||||
@override
|
||||
Future<void> pausar() async => pausas++;
|
||||
|
||||
@override
|
||||
Future<void> reanudar() async => reanudaciones++;
|
||||
|
||||
@override
|
||||
Future<void> setAtenuado(bool valor) async => atenuado = valor;
|
||||
|
||||
@override
|
||||
Future<void> reaplicarEcualizador() async => reaplicacionesEq++;
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('un aviso de navegación ATENÚA, nunca pausa', () async {
|
||||
final objetivo = _ObjetivoFalso();
|
||||
final servicio = ServicioAudioSession(objetivo: objetivo);
|
||||
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(true, AudioInterruptionType.duck),
|
||||
);
|
||||
|
||||
expect(objetivo.atenuado, isTrue);
|
||||
expect(
|
||||
objetivo.pausas,
|
||||
0,
|
||||
reason:
|
||||
'pausar publica playing:false -> exitPlayingState -> el servicio '
|
||||
'sale de primer plano y Android puede matarlo; ahí es donde la app '
|
||||
'desaparecía del panel del coche',
|
||||
);
|
||||
});
|
||||
|
||||
test('al terminar el aviso se recupera el volumen Y se reasienta el '
|
||||
'ecualizador', () async {
|
||||
final objetivo = _ObjetivoFalso();
|
||||
final servicio = ServicioAudioSession(objetivo: objetivo);
|
||||
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(true, AudioInterruptionType.duck),
|
||||
);
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(false, AudioInterruptionType.duck),
|
||||
);
|
||||
|
||||
expect(objetivo.atenuado, isFalse);
|
||||
expect(
|
||||
objetivo.reaplicacionesEq,
|
||||
1,
|
||||
reason:
|
||||
'Android puede desactivar en silencio el efecto de esta app cuando '
|
||||
'otro cliente de mayor prioridad toma el foco, y el id de sesión no '
|
||||
'cambia, así que el disparador por rotación de sesión no salta',
|
||||
);
|
||||
expect(objetivo.pausas, 0);
|
||||
});
|
||||
|
||||
test('una llamada entrante SÍ pausa: no todo es un aviso corto', () async {
|
||||
// The duck change must not turn a real, non-duckable focus loss into a
|
||||
// station that keeps playing over a phone call.
|
||||
final objetivo = _ObjetivoFalso();
|
||||
final servicio = ServicioAudioSession(objetivo: objetivo);
|
||||
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(true, AudioInterruptionType.pause),
|
||||
);
|
||||
|
||||
expect(objetivo.pausas, 1);
|
||||
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(false, AudioInterruptionType.pause),
|
||||
);
|
||||
|
||||
expect(objetivo.reanudaciones, 1);
|
||||
expect(objetivo.reaplicacionesEq, 1);
|
||||
});
|
||||
|
||||
test('desconectar los auriculares pausa y NO reanuda solo', () async {
|
||||
final objetivo = _ObjetivoFalso();
|
||||
final servicio = ServicioAudioSession(objetivo: objetivo);
|
||||
|
||||
await servicio.manejarDesconexionSalida();
|
||||
|
||||
expect(objetivo.pausas, 1);
|
||||
expect(objetivo.reanudaciones, 0);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user