fix: cumplir las guias de calidad de Android Auto y localizar el arbol del coche
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.
This commit is contained in:
@@ -1,106 +1,142 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||
|
||||
/// Android Auto entitlement gating (android-auto-media spec "Free-Tier
|
||||
/// Reduced Root Browse" + "Free-Tier Browse Never Leaks Real Content",
|
||||
/// design.md ADR-4). All pure — no handler instantiation needed
|
||||
/// (`PluriWaveAudioHandler` cannot be constructed in a unit test).
|
||||
/// Android Auto entitlement gating — the id-shape matrix.
|
||||
///
|
||||
/// REWRITTEN for fix/auto-quality-guidelines item 10. This suite used to
|
||||
/// assert the opposite design: that every non-root id, for a free-tier user,
|
||||
/// collapsed to a single non-playable `premium:info` row. Google Play cited
|
||||
/// that browse tree against the Android for Cars App Quality Guidelines, so
|
||||
/// the contract is now content-scoping — the free tier sees LESS, never a
|
||||
/// row that does nothing.
|
||||
///
|
||||
/// [respuestaBloqueadaPorEntitlement]'s return VALUE is covered in
|
||||
/// `navegacion_auto_destacadas_test.dart`; this file pins the decision
|
||||
/// surface ([idPermitidoEnFree]) across every id shape the tree can produce,
|
||||
/// including the stale/deep-linked ones a head unit's cached tree replays.
|
||||
void main() {
|
||||
group('raiz(premium:) — root keeps its labels for every tier', () {
|
||||
test('premium: identical to today\'s tree (regression guard)', () {
|
||||
final constructor = ConstructorArbolAuto();
|
||||
const gratuitas = [
|
||||
Emisora(uuid: 'libre-1', nombre: 'Libre 1', url: 'https://libre1.example'),
|
||||
Emisora(uuid: 'libre-2', nombre: 'Libre 2', url: 'https://libre2.example'),
|
||||
];
|
||||
|
||||
final premiumConLocal = constructor.raiz(
|
||||
incluirMusicaLocal: true,
|
||||
premium: true,
|
||||
group('idPermitidoEnFree', () {
|
||||
test('la raíz siempre pasa: es lo único que decide qué ve el tier', () {
|
||||
expect(
|
||||
idPermitidoEnFree(
|
||||
AudioService.browsableRootId,
|
||||
destacadas: gratuitas,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
final premiumSinLocal = constructor.raiz(
|
||||
incluirMusicaLocal: false,
|
||||
premium: true,
|
||||
);
|
||||
|
||||
expect(premiumConLocal.map((m) => m.id), [
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
ConstructorArbolAuto.idMisEmisoras,
|
||||
ConstructorArbolAuto.idMusicaLocal,
|
||||
]);
|
||||
expect(premiumConLocal.every((m) => m.playable == false), isTrue);
|
||||
expect(premiumConLocal.every((m) => m.displaySubtitle == null), isTrue);
|
||||
expect(premiumSinLocal.map((m) => m.id), [
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
ConstructorArbolAuto.idMisEmisoras,
|
||||
]);
|
||||
});
|
||||
|
||||
test('free: same folder ids/titles, non-blank, never playable', () {
|
||||
final constructor = ConstructorArbolAuto();
|
||||
|
||||
final libre = constructor.raiz(incluirMusicaLocal: true, premium: false);
|
||||
|
||||
expect(libre, isNotEmpty);
|
||||
expect(libre.map((m) => m.id), [
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
ConstructorArbolAuto.idMisEmisoras,
|
||||
ConstructorArbolAuto.idMusicaLocal,
|
||||
]);
|
||||
expect(libre.every((m) => m.playable == false), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
test(
|
||||
'itemPremiumBloqueado(): id fijo, no reproducible, etiqueta premium',
|
||||
() {
|
||||
final item = ConstructorArbolAuto().itemPremiumBloqueado();
|
||||
|
||||
expect(item.id, 'premium:info');
|
||||
expect(item.playable, isFalse);
|
||||
expect(item.title, isNotEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
group('respuestaBloqueadaPorEntitlement — backstop de navegacion', () {
|
||||
test('root nunca es bloqueada (root siempre resuelve via raiz)', () {
|
||||
final respuesta = respuestaBloqueadaPorEntitlement(
|
||||
parentMediaId: AudioService.browsableRootId,
|
||||
premium: false,
|
||||
test('la carpeta gratuita pasa', () {
|
||||
expect(
|
||||
idPermitidoEnFree(
|
||||
ConstructorArbolAuto.idDestacadas,
|
||||
destacadas: gratuitas,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
expect(respuesta, isNull);
|
||||
});
|
||||
|
||||
test('cualquier id no-root, en free, retorna SOLO el item bloqueado', () {
|
||||
test('un emisora:<uuid> del set gratuito pasa', () {
|
||||
for (final e in gratuitas) {
|
||||
expect(
|
||||
idPermitidoEnFree('emisora:${e.uuid}', destacadas: gratuitas),
|
||||
isTrue,
|
||||
reason: e.uuid,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('las carpetas premium NO pasan', () {
|
||||
for (final id in [
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
ConstructorArbolAuto.idMisEmisoras,
|
||||
ConstructorArbolAuto.idMusicaLocal,
|
||||
ConstructorArbolAuto.idEcualizador,
|
||||
// Stale/deep-linked id from before a downgrade — the backstop must
|
||||
// not special-case known ids (Spec "Stale folder id bypass
|
||||
// attempt").
|
||||
'emisora:algun-uuid-viejo',
|
||||
'grupo:algo',
|
||||
]) {
|
||||
final respuesta = respuestaBloqueadaPorEntitlement(
|
||||
parentMediaId: id,
|
||||
premium: false,
|
||||
);
|
||||
expect(respuesta, hasLength(1));
|
||||
expect(respuesta!.single.id, 'premium:info');
|
||||
expect(idPermitidoEnFree(id, destacadas: gratuitas), isFalse,
|
||||
reason: id);
|
||||
}
|
||||
});
|
||||
|
||||
test('cualquier id no-root, en premium, no es bloqueada', () {
|
||||
final respuesta = respuestaBloqueadaPorEntitlement(
|
||||
parentMediaId: ConstructorArbolAuto.idFavoritos,
|
||||
premium: true,
|
||||
test('un id rancio/deep-link de antes de una bajada de tier NO pasa: '
|
||||
'ésa es la propiedad de seguridad que el rediseño tenía que '
|
||||
'conservar', () {
|
||||
for (final id in [
|
||||
'emisora:uuid-del-catalogo',
|
||||
'grupo:algun-grupo',
|
||||
'pista:doc-id',
|
||||
'carpeta_local:doc-id',
|
||||
'carpeta_local_reproducir:doc-id',
|
||||
'carpeta_local_aleatorio:doc-id',
|
||||
'eq_preset:Rock',
|
||||
'premium:info', // la fila muerta que ya no existe
|
||||
'',
|
||||
]) {
|
||||
expect(idPermitidoEnFree(id, destacadas: gratuitas), isFalse,
|
||||
reason: id);
|
||||
}
|
||||
});
|
||||
|
||||
test('emisora: con uuid vacío NO pasa (id malformado, no comodín)', () {
|
||||
expect(idPermitidoEnFree('emisora:', destacadas: gratuitas), isFalse);
|
||||
});
|
||||
|
||||
test('con el set gratuito vacío solo pasan la raíz y su carpeta', () {
|
||||
expect(
|
||||
idPermitidoEnFree(AudioService.browsableRootId, destacadas: const []),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
idPermitidoEnFree(
|
||||
ConstructorArbolAuto.idDestacadas,
|
||||
destacadas: const [],
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
idPermitidoEnFree('emisora:libre-1', destacadas: const []),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('respuestaBloqueadaPorEntitlement', () {
|
||||
test('premium: ningún id se bloquea, ni siquiera uno inventado', () {
|
||||
for (final id in [
|
||||
AudioService.browsableRootId,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
'emisora:cualquier-cosa',
|
||||
'basura',
|
||||
]) {
|
||||
expect(
|
||||
respuestaBloqueadaPorEntitlement(
|
||||
parentMediaId: id,
|
||||
premium: true,
|
||||
destacadas: gratuitas,
|
||||
),
|
||||
isNull,
|
||||
reason: id,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('free: lo bloqueado NUNCA incluye un item no reproducible', () {
|
||||
final bloqueada = respuestaBloqueadaPorEntitlement(
|
||||
parentMediaId: ConstructorArbolAuto.idMisEmisoras,
|
||||
premium: false,
|
||||
destacadas: gratuitas,
|
||||
);
|
||||
|
||||
expect(respuesta, isNull);
|
||||
expect(bloqueada, isNotNull);
|
||||
expect(bloqueada!.every((m) => m.playable == true), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user