Google Play devolvio "Approved with Issues" en el codigo 157: "clicking on stop button makes the entire app useless", citado contra las Android for Cars App Quality Guidelines. La causa no era el boton de parar. Maquina de estados del transporte _cambiarFuente publicaba mediaItem y loading ANTES de su primer await y solo comprobaba su revision despues de que _recrearPlayer retornase. Los cambios de fuente se encolan incrementando la revision al encolar, no al ejecutar, asi que tocar una emisora, tocar otra antes de que cargue y pulsar Stop dejaba que las entradas obsoletas reescribieran loading sobre el idle que stop() acababa de publicar. Estado final: loading para siempre sobre una sesion que audio_service ya habia desactivado. Ahora la guarda de revision es la primera sentencia del metodo. pause() no invalidaba una carga en vuelo, asi que la emisora arrancaba igual despues de pulsar pausa; se revalida la intencion antes de llamar a play(). Se anade un suelo de estado que cierra cualquier loading o buffering sin carga viva, exento cuando el reproductor ya entrego audio y solo esta rebufferando, para no convertir un tunel en un error. El presupuesto hasta el primer mensaje baja a menos de diez segundos y los reintentos ya no borran el mensaje visible. Tier gratuito en el coche El arbol devolvia una unica fila no reproducible para cualquier carpeta cuando no habia premium, y un revisor con instalacion limpia siempre es tier gratuito. Ademas skipToNext, skipToPrevious, playFromSearch y playFromMediaId retornaban en silencio. La raiz gratuita pasa a ofrecer una sola carpeta con emisoras reales y reproducibles, compiladas en el binario para que existan en frio, y la puerta de entitlement acota contenido en vez de bloquear acciones. Se elimina la fila "Funcion Premium". Una consulta de voz vacia arranca la ultima emisora, que fallaba tambien a los clientes de pago. Localizacion El locale del handler solo lo fijaba un widget que el motor headless nunca construye, asi que todo error del coche salia en castellano. Se resuelve desde el locale de plataforma. Se traducen las once etiquetas del arbol que estaban a fuego y se retira la convencion que lo justificaba. Un test nuevo falla si vuelve a aparecer texto visible fuera del sistema de traduccion. Suite completa: 1455 pasan, 2 omitidos. Los mecanismos se verificaron por mutacion: borrar cada uno pone la suite en rojo. flutter analyze mantiene los 5 avisos preexistentes.
224 lines
8.1 KiB
Dart
224 lines
8.1 KiB
Dart
import 'package:audio_service/audio_service.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:pluriwave/modelos/pista_local.dart';
|
|
import 'package:pluriwave/servicios/musica_local_auto.dart';
|
|
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
|
import 'package:pluriwave/servicios/servicio_audio.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import '../helpers/handlers_audio.dart';
|
|
|
|
/// Android Auto play-path backstop (design.md ADR-4, android-auto-media
|
|
/// spec "Free-Tier Browse Never Leaks Real Content" + "Current-Station
|
|
/// Playback Unaffected By Free Tier"): `playFromMediaId`, `playFromSearch`,
|
|
/// `skipToNext`, `skipToPrevious` must ALL no-op for a free-tier user,
|
|
/// regardless of the target id — gating `getChildren` alone is
|
|
/// insufficient because a head unit caches browse trees, so a stale
|
|
/// `emisora:<uuid>` tap could otherwise bypass browsing entirely. Pure —
|
|
/// `PluriWaveAudioHandler` cannot be instantiated in a unit test (needs a
|
|
/// real platform `AudioPlayer`), so this is the extracted decision the
|
|
/// handler's dispatch methods delegate to (mirrors `mapearEstadoProceso`
|
|
/// and every other pure helper in this file).
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
final crearHandler = registrarHandlersLiberables();
|
|
|
|
// fix/auto-quality-guidelines item 11: the gate is CONTENT-scoped now.
|
|
// Blocking every switch for the free tier is what made the car surface
|
|
// useless for the only tier a Play reviewer can be in.
|
|
test('free tier: bloquea una emisora del catálogo premium', () {
|
|
expect(
|
|
debeBloquearCambioDeEmisora(premium: false, esEmisoraGratuita: false),
|
|
isTrue,
|
|
);
|
|
});
|
|
|
|
test('free tier: NO bloquea una emisora del set gratuito', () {
|
|
expect(
|
|
debeBloquearCambioDeEmisora(premium: false, esEmisoraGratuita: true),
|
|
isFalse,
|
|
);
|
|
});
|
|
|
|
test('premium: nunca bloquea', () {
|
|
expect(
|
|
debeBloquearCambioDeEmisora(premium: true, esEmisoraGratuita: false),
|
|
isFalse,
|
|
);
|
|
});
|
|
|
|
/// fix/android-auto-musica-local, item 4: el hook dejó de ser «solo la
|
|
/// transición free -> premium». Android Auto cachea la raíz, así que
|
|
/// CUALQUIER momento en el que el árbol pasa a poder mostrar algo que
|
|
/// antes no podía tiene que invalidarla — muy en particular, que aparezca
|
|
/// por fin una Activity (y con ella el handler nativo del canal
|
|
/// `pluriwave/file_actions`) o que el usuario acabe de elegir carpeta.
|
|
/// De ahí el nombre neutro.
|
|
group('invalidarArbolAuto / registrarInvalidacionArbolAuto', () {
|
|
test('sin hook registrado, es un no-op seguro', () {
|
|
expect(() => invalidarArbolAuto(), returnsNormally);
|
|
});
|
|
|
|
test('invoca el hook registrado exactamente una vez por llamada', () {
|
|
var llamadas = 0;
|
|
registrarInvalidacionArbolAuto(() => llamadas++);
|
|
|
|
invalidarArbolAuto();
|
|
|
|
expect(llamadas, 1);
|
|
});
|
|
|
|
test('registrarHandler conecta la invalidación al handler: una llamada '
|
|
'notifica la raíz Y Música Local', () async {
|
|
final handler = crearHandler();
|
|
registrarHandler(handler);
|
|
|
|
final raiz = <Map<String, dynamic>>[];
|
|
final local = <Map<String, dynamic>>[];
|
|
final subRaiz = handler
|
|
.subscribeToChildren(AudioService.browsableRootId)
|
|
.listen(raiz.add);
|
|
final subLocal = handler
|
|
.subscribeToChildren(ConstructorArbolAuto.idMusicaLocal)
|
|
.listen(local.add);
|
|
|
|
invalidarArbolAuto();
|
|
await Future<void>.delayed(Duration.zero);
|
|
await subRaiz.cancel();
|
|
await subLocal.cancel();
|
|
|
|
expect(raiz, hasLength(1));
|
|
expect(local, hasLength(1));
|
|
});
|
|
});
|
|
|
|
/// fix/android-auto-musica-local, item 5.
|
|
///
|
|
/// `subscribeToChildren` sembraba el `BehaviorSubject` con un mapa vacío.
|
|
/// El listener interno de `audio_service` se suscribe en cuanto el head
|
|
/// unit navega un id, recibe ESE valor semilla de inmediato y lo reenvía
|
|
/// como `notifyChildrenChanged` — o sea, el primer browse de cada id
|
|
/// provocaba un segundo `getChildren` espurio. En la raíz eso era un
|
|
/// SEGUNDO round trip de permisos justo en la ruta que ya estaba
|
|
/// fallando. Sin semilla no hay valor que reenviar, y la invalidación
|
|
/// explícita (`notificarHijosCambiaron`) sigue funcionando igual.
|
|
group('subscribeToChildren', () {
|
|
test('el sujeto arranca SIN valor: nada que reenviar en la primera '
|
|
'suscripción, así que no hay notifyChildrenChanged espurio', () {
|
|
final handler = crearHandler();
|
|
|
|
expect(handler.subscribeToChildren('musica_local').hasValue, isFalse);
|
|
});
|
|
|
|
test('memoiza por id: dos llamadas devuelven el MISMO stream', () {
|
|
final handler = crearHandler();
|
|
|
|
expect(
|
|
identical(
|
|
handler.subscribeToChildren('musica_local'),
|
|
handler.subscribeToChildren('musica_local'),
|
|
),
|
|
isTrue,
|
|
);
|
|
expect(
|
|
identical(
|
|
handler.subscribeToChildren('musica_local'),
|
|
handler.subscribeToChildren('favoritos'),
|
|
),
|
|
isFalse,
|
|
);
|
|
});
|
|
|
|
test('notificarHijosCambiaron sí empuja un valor al sujeto ya suscrito',
|
|
() async {
|
|
final handler = crearHandler();
|
|
final stream = handler.subscribeToChildren('musica_local');
|
|
final recibidos = <Map<String, dynamic>>[];
|
|
final sub = stream.listen(recibidos.add);
|
|
|
|
handler.notificarHijosCambiaron('musica_local');
|
|
await Future<void>.delayed(Duration.zero);
|
|
await sub.cancel();
|
|
|
|
expect(recibidos, hasLength(1));
|
|
});
|
|
});
|
|
|
|
/// fix/android-auto-musica-local — «no aparece la opción de reproducir
|
|
/// música local, no aparece ni el menú», con la compra PRO hecha.
|
|
///
|
|
/// La raíz decidía la existencia del nodo con un round trip de permisos
|
|
/// por `MethodChannel`. En el motor headless que Android Auto levanta sin
|
|
/// Activity ese canal no tiene handler, la llamada lanzaba
|
|
/// `MissingPluginException` y el nodo se omitía — y Android Auto CACHEA
|
|
/// la raíz, así que se quedaba fuera toda la sesión.
|
|
///
|
|
/// La pertenencia a la raíz ya no depende de poder contestar esa
|
|
/// pregunta: basta con que el estado NO sea [EstadoCarpetaLocal.noConfigurada].
|
|
group('getChildren(root): pertenencia de Música Local', () {
|
|
setUp(() {
|
|
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
|
|
});
|
|
|
|
Future<List<String>> idsRaizCon(EstadoCarpetaLocal estado) async {
|
|
registrarFuenteMusicaLocal(_FakeFuenteMusicaLocalGating(estado));
|
|
final handler = crearHandler();
|
|
final items = await handler.getChildren(AudioService.browsableRootId);
|
|
return items.map((i) => i.id).toList();
|
|
}
|
|
|
|
test(
|
|
'canalNoDisponible (motor sin Activity, pero el usuario SÍ eligió '
|
|
'carpeta): la raíz sigue ofreciendo Música Local',
|
|
() async {
|
|
expect(
|
|
await idsRaizCon(EstadoCarpetaLocal.canalNoDisponible),
|
|
contains(ConstructorArbolAuto.idMusicaLocal),
|
|
);
|
|
},
|
|
);
|
|
|
|
test('configurada: la raíz ofrece Música Local', () async {
|
|
expect(
|
|
await idsRaizCon(EstadoCarpetaLocal.configurada),
|
|
contains(ConstructorArbolAuto.idMusicaLocal),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'noConfigurada (nunca se eligió carpeta, o el permiso está revocado '
|
|
'de verdad): la raíz sigue omitiendo Música Local',
|
|
() async {
|
|
expect(
|
|
await idsRaizCon(EstadoCarpetaLocal.noConfigurada),
|
|
isNot(contains(ConstructorArbolAuto.idMusicaLocal)),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
/// Misma forma que `_FakeFuenteMusicaLocalAuto` en
|
|
/// `navegacion_auto_test.dart`, reducida a lo que esta suite necesita: solo
|
|
/// el estado de la carpeta decide la raíz.
|
|
class _FakeFuenteMusicaLocalGating implements FuenteMusicaLocalAuto {
|
|
_FakeFuenteMusicaLocalGating(this._estado);
|
|
|
|
final EstadoCarpetaLocal _estado;
|
|
|
|
@override
|
|
Future<EstadoCarpetaLocal> estadoCarpeta() async => _estado;
|
|
|
|
@override
|
|
Future<List<NodoLocal>> hijos(String documentId) async => const [];
|
|
|
|
@override
|
|
Future<String?> uriContenidoDePista(String documentId) async => null;
|
|
|
|
@override
|
|
Future<Map<String, MetadatosPista>> metadatosDe(
|
|
List<String> documentIds,
|
|
) async => const {};
|
|
}
|