feat(audio): audio session integration and runtime robustness

- Integrate audio_session (new servicio_audio_session.dart): incoming calls pause the radio and resume on end, headphone unplug pauses without auto-resume, permanent focus loss never auto-resumes, duck lowers volume
- Add play-intent flag to ServicioAudio so interruption handling and future reconnect logic can distinguish user pause from system-driven stops
- Eliminate read-modify-write race in ServicioAlarmas with an in-memory cache and single-writer queue across all mutations; recalcularTodas persists only when state actually changed
- Convert ServicioAlarmasAndroid static StreamController/handler to injectable instance fields, restoring test isolation
- Inject a single cached SharedPreferences from main.dart across services and state (removes 23 inline getInstance() calls)
- Move configurarLocalizaciones out of MiniReproductor.build() (was running on every rebuild during playback)
- Bound the alarm fire-dedup set (cap 200 entries, 24h pruning)
- 12 new tests (89 total green), flutter analyze clean
This commit is contained in:
2026-06-11 16:25:09 +02:00
parent f3e9487215
commit 079e19f0ee
21 changed files with 1059 additions and 151 deletions
@@ -0,0 +1,53 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/servicio_alarmas_android.dart';
/// S3-R2: the event controller and handler flag must be instance state so
/// two bridges created independently never share events.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
Future<void> emitirAlarmFired(String canal, Map<String, Object?> payload) {
final mensaje = const StandardMethodCodec().encodeMethodCall(
MethodCall('alarmFired', payload),
);
return TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(canal, mensaje, (_) {});
}
test('dos instancias no comparten el stream de eventos (S3-R2-A)', () async {
final servicioA = ServicioAlarmasAndroid(
channel: const MethodChannel('pluriwave/alarm_scheduler_test_a'),
);
final servicioB = ServicioAlarmasAndroid(
channel: const MethodChannel('pluriwave/alarm_scheduler_test_b'),
);
final eventosA = <EventoAlarmaAndroid>[];
final eventosB = <EventoAlarmaAndroid>[];
final subA = servicioA.eventosAlarma.listen(eventosA.add);
final subB = servicioB.eventosAlarma.listen(eventosB.add);
addTearDown(subA.cancel);
addTearDown(subB.cancel);
await emitirAlarmFired('pluriwave/alarm_scheduler_test_a', {
'alarmId': 'solo-a',
'alarmTitle': 'Alarma A',
'alarmAction': 'es.freetimelab.pluriwave.ALARM_FIRE',
});
await Future<void>.delayed(Duration.zero);
expect(eventosA.map((e) => e.alarmaId), ['solo-a']);
expect(eventosB, isEmpty, reason: 'B no debe ver los eventos de A');
await emitirAlarmFired('pluriwave/alarm_scheduler_test_b', {
'alarmId': 'solo-b',
'alarmTitle': 'Alarma B',
'alarmAction': 'es.freetimelab.pluriwave.ALARM_FIRE',
});
await Future<void>.delayed(Duration.zero);
expect(eventosA.map((e) => e.alarmaId), ['solo-a']);
expect(eventosB.map((e) => e.alarmaId), ['solo-b']);
});
}
@@ -0,0 +1,107 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// SharedPreferences spy: only the members ServicioAlarmas touches are
/// implemented; everything else throws via noSuchMethod.
class _PrefsEspia implements SharedPreferences {
final Map<String, Object> _datos = {};
int escriturasString = 0;
int lecturasString = 0;
@override
String? getString(String key) {
lecturasString++;
return _datos[key] as String?;
}
@override
Future<bool> setString(String key, String value) async {
escriturasString++;
_datos[key] = value;
return true;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
AlarmaMusical alarmaDiaria(
ServicioAlarmas servicio,
String nombre,
int hora,
) {
return servicio.crearAlarma(
nombre: nombre,
hora: hora,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
);
}
test(
'recalcularTodas NO escribe cuando la agenda no cambio (S3-R5-A)',
() async {
final prefs = _PrefsEspia();
final reloj = DateTime(2026, 6, 11, 6, 0);
final servicio = ServicioAlarmas(prefs: prefs, reloj: () => reloj);
await servicio.guardarAlarma(alarmaDiaria(servicio, 'Sin cambios', 7));
final escriturasBase = prefs.escriturasString;
await servicio.recalcularTodas();
expect(
prefs.escriturasString,
escriturasBase,
reason: 'agenda identica => sin setString',
);
},
);
test(
'recalcularTodas escribe exactamente una vez cuando cambia (S3-R5-B)',
() async {
var ahora = DateTime(2026, 6, 11, 6, 0);
final prefs = _PrefsEspia();
final servicio = ServicioAlarmas(prefs: prefs, reloj: () => ahora);
await servicio.guardarAlarma(alarmaDiaria(servicio, 'Cambia', 7));
final escriturasBase = prefs.escriturasString;
// A day later the next execution moves, so the schedule changed.
ahora = DateTime(2026, 6, 12, 8, 0);
await servicio.recalcularTodas();
expect(prefs.escriturasString, escriturasBase + 1);
},
);
test('mutaciones concurrentes no pierden escrituras (S3-R7-A)', () async {
final prefs = _PrefsEspia();
final servicio = ServicioAlarmas(
prefs: prefs,
reloj: () => DateTime(2026, 6, 11, 6, 0),
);
final alarmaA = alarmaDiaria(servicio, 'Concurrente A', 7);
final alarmaB = alarmaDiaria(servicio, 'Concurrente B', 8);
final lecturasBase = prefs.lecturasString;
// Dispatched WITHOUT awaiting in between: without the cache + writer
// queue both read the same base config and the last write wins.
await Future.wait([
servicio.guardarAlarma(alarmaA),
servicio.guardarAlarma(alarmaB),
]);
final config = await servicio.cargar();
expect(config.alarmas.map((a) => a.id).toSet(), {alarmaA.id, alarmaB.id});
expect(
prefs.lecturasString - lecturasBase,
lessThanOrEqualTo(2),
reason:
'las mutaciones hidratan la cache UNA vez; la lectura extra es el cargar() final',
);
});
}
@@ -0,0 +1,132 @@
import 'package:audio_session/audio_session.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/servicio_audio_session.dart';
class _ObjetivoFake implements ObjetivoAudioInterrumpible {
bool intencion = false;
bool reproduciendo = false;
int pausas = 0;
int reanudaciones = 0;
final List<bool> atenuaciones = [];
@override
bool get intencionReproducir => intencion;
@override
bool get estaReproduciendo => reproduciendo;
@override
Future<void> pausar() async {
pausas++;
reproduciendo = false;
intencion = false;
}
@override
Future<void> reanudar() async {
reanudaciones++;
reproduciendo = true;
intencion = true;
}
@override
Future<void> setAtenuado(bool atenuado) async {
atenuaciones.add(atenuado);
}
}
/// S3-R1: audio-session interruptions (phone call, transient loss, duck) and
/// becoming-noisy (headphones unplugged) must pause/duck and auto-resume.
void main() {
test('interrupcion begin/pause pausa y baja la intencion (S3-R1)', () async {
final objetivo =
_ObjetivoFake()
..reproduciendo = true
..intencion = true;
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(true, AudioInterruptionType.pause),
);
expect(objetivo.pausas, 1);
expect(
objetivo.intencionReproducir,
isFalse,
reason: 'el reconnect de S7 no debe pelear con la llamada en curso',
);
});
test(
'interrupcion end/shouldResume reanuda la reproduccion (S3-R1)',
() async {
final objetivo =
_ObjetivoFake()
..reproduciendo = true
..intencion = true;
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(true, AudioInterruptionType.pause),
);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(false, AudioInterruptionType.pause),
);
expect(objetivo.reanudaciones, 1);
expect(objetivo.intencionReproducir, isTrue);
},
);
test('end sin pausa previa por interrupcion NO reanuda', () async {
final objetivo = _ObjetivoFake();
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(false, AudioInterruptionType.pause),
);
expect(
objetivo.reanudaciones,
0,
reason:
'si el usuario ya estaba en pausa, el fin de llamada no arranca audio',
);
});
test('duck atenua al comenzar y restaura al terminar', () async {
final objetivo =
_ObjetivoFake()
..reproduciendo = true
..intencion = true;
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(true, AudioInterruptionType.duck),
);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(false, AudioInterruptionType.duck),
);
expect(objetivo.atenuaciones, [true, false]);
expect(objetivo.pausas, 0);
});
test('becoming-noisy (auriculares desconectados) pausa (S3-R1)', () async {
final objetivo =
_ObjetivoFake()
..reproduciendo = true
..intencion = true;
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarDesconexionSalida();
expect(objetivo.pausas, 1);
// A later interruption end must NOT resume: unplugging is a hard pause.
await servicio.manejarInterrupcion(
AudioInterruptionEvent(false, AudioInterruptionType.pause),
);
expect(objetivo.reanudaciones, 0);
});
}