feat(auto): browse and play local music folders in Android Auto [size:exception]
Phase 1: pick a device folder via SAF (persisted grant, no new permission), browse its nested subfolders/tracks as a 5th Android Auto root folder (hidden until configured), and play tracks through the existing pipeline (EQ, art rotation, cold-start-safe source). No metadata/sort/filter/shuffle yet -- filename is the title, generic rotating art is the placeholder; deferred to a follow-up phase. Adds a new pluriwave/file_actions native method (listAudioChildren) and an onActivityResult override in MainActivity for the SAF folder picker -- both static-review-only, no Android build available here.
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/servicios/musica_local_auto.dart';
|
||||
|
||||
void main() {
|
||||
group('esArchivoAudio', () {
|
||||
test('acepta cualquier MIME audio/*, en cualquier capitalización', () {
|
||||
expect(esArchivoAudio('audio/mpeg', 'cancion.mp3'), isTrue);
|
||||
expect(esArchivoAudio('audio/flac', 'cancion.flac'), isTrue);
|
||||
expect(esArchivoAudio('AUDIO/OGG', 'cancion.ogg'), isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'rechaza MIME no-audio aunque el nombre tenga una extensión de '
|
||||
'audio (defensa contra un MIME nativo incorrecto)',
|
||||
() {
|
||||
expect(esArchivoAudio('video/mp4', 'cancion.mp3'), isFalse);
|
||||
expect(
|
||||
esArchivoAudio('application/octet-stream', 'cancion.mp3'),
|
||||
isFalse,
|
||||
);
|
||||
expect(esArchivoAudio('text/plain', 'notas.mp3'), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('rechaza MIME null, vacío o en blanco', () {
|
||||
expect(esArchivoAudio(null, 'cancion.mp3'), isFalse);
|
||||
expect(esArchivoAudio('', 'cancion.mp3'), isFalse);
|
||||
expect(esArchivoAudio(' ', 'cancion.mp3'), isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'rechaza nombre null, vacío o en blanco aunque el MIME sea audio '
|
||||
'válido',
|
||||
() {
|
||||
expect(esArchivoAudio('audio/mpeg', null), isFalse);
|
||||
expect(esArchivoAudio('audio/mpeg', ''), isFalse);
|
||||
expect(esArchivoAudio('audio/mpeg', ' '), isFalse);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/modelos/grupo_favoritos.dart';
|
||||
import 'package:pluriwave/modelos/pista_local.dart';
|
||||
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||
import 'package:pluriwave/servicios/musica_local_auto.dart';
|
||||
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||
|
||||
void main() {
|
||||
@@ -219,28 +221,58 @@ void main() {
|
||||
});
|
||||
|
||||
group('ConstructorArbolAuto.raiz', () {
|
||||
test('devuelve exactamente 4 carpetas no reproducibles con los ids '
|
||||
'esperados, terminando en Ecualizador', () {
|
||||
final raiz = ConstructorArbolAuto().raiz();
|
||||
test(
|
||||
'con incluirMusicaLocal: true devuelve exactamente 5 carpetas no '
|
||||
'reproducibles con los ids esperados, Música Local en 4to lugar, '
|
||||
'terminando en Ecualizador',
|
||||
() {
|
||||
final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: true);
|
||||
|
||||
expect(raiz, hasLength(4));
|
||||
final ids = raiz.map((item) => item.id).toSet();
|
||||
expect(
|
||||
ids,
|
||||
equals({
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
ConstructorArbolAuto.idMisEmisoras,
|
||||
ConstructorArbolAuto.idEcualizador,
|
||||
}),
|
||||
);
|
||||
for (final item in raiz) {
|
||||
expect(item.playable, isFalse);
|
||||
expect(item.title, isNotEmpty);
|
||||
}
|
||||
expect(raiz.last.id, ConstructorArbolAuto.idEcualizador);
|
||||
expect(raiz.last.playable, isFalse);
|
||||
});
|
||||
expect(raiz, hasLength(5));
|
||||
final ids = raiz.map((item) => item.id).toSet();
|
||||
expect(
|
||||
ids,
|
||||
equals({
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
ConstructorArbolAuto.idMisEmisoras,
|
||||
ConstructorArbolAuto.idMusicaLocal,
|
||||
ConstructorArbolAuto.idEcualizador,
|
||||
}),
|
||||
);
|
||||
for (final item in raiz) {
|
||||
expect(item.playable, isFalse);
|
||||
expect(item.title, isNotEmpty);
|
||||
}
|
||||
expect(raiz[3].id, ConstructorArbolAuto.idMusicaLocal);
|
||||
expect(raiz.last.id, ConstructorArbolAuto.idEcualizador);
|
||||
expect(raiz.last.playable, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'con incluirMusicaLocal: false devuelve exactamente 4 carpetas, '
|
||||
'byte-idéntico al comportamiento previo al cambio (regresión) — '
|
||||
'Música Local queda OCULTA, no vacía',
|
||||
() {
|
||||
final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: false);
|
||||
|
||||
expect(raiz, hasLength(4));
|
||||
final ids = raiz.map((item) => item.id).toSet();
|
||||
expect(
|
||||
ids,
|
||||
equals({
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
ConstructorArbolAuto.idMisEmisoras,
|
||||
ConstructorArbolAuto.idEcualizador,
|
||||
}),
|
||||
);
|
||||
expect(ids, isNot(contains(ConstructorArbolAuto.idMusicaLocal)));
|
||||
expect(raiz.last.id, ConstructorArbolAuto.idEcualizador);
|
||||
expect(raiz.last.playable, isFalse);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('esPresetMediaId', () {
|
||||
@@ -263,6 +295,387 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('esCarpetaLocalMediaId / esPistaMediaId', () {
|
||||
test(
|
||||
'reconocen sus prefijos, rechazan el resto y no colisionan entre sí '
|
||||
'ni con emisora:/grupo:/eq_preset:/los ids de carpeta fijos',
|
||||
() {
|
||||
final builder = ConstructorArbolAuto();
|
||||
|
||||
expect(builder.esCarpetaLocalMediaId('carpeta_local:doc1'), isTrue);
|
||||
expect(builder.esCarpetaLocalMediaId('carpeta_local:'), isTrue);
|
||||
expect(esPistaMediaId('pista:doc1'), isTrue);
|
||||
expect(esPistaMediaId('pista:'), isTrue);
|
||||
|
||||
final noLocales = <String>[
|
||||
'emisora:x',
|
||||
'grupo:g1',
|
||||
'eq_preset:Rock',
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
ConstructorArbolAuto.idMisEmisoras,
|
||||
ConstructorArbolAuto.idEcualizador,
|
||||
ConstructorArbolAuto.idMusicaLocal,
|
||||
'',
|
||||
];
|
||||
for (final id in noLocales) {
|
||||
expect(
|
||||
builder.esCarpetaLocalMediaId(id),
|
||||
isFalse,
|
||||
reason: 'esCarpetaLocalMediaId($id) debería ser false',
|
||||
);
|
||||
expect(
|
||||
esPistaMediaId(id),
|
||||
isFalse,
|
||||
reason: 'esPistaMediaId($id) debería ser false',
|
||||
);
|
||||
}
|
||||
|
||||
// carpeta_local: y pista: nunca colisionan entre sí.
|
||||
expect(builder.esCarpetaLocalMediaId('pista:doc1'), isFalse);
|
||||
expect(esPistaMediaId('carpeta_local:doc1'), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'idCarpetaLocalDesde recorta por longitud, preservando un documentId '
|
||||
'que contiene ":" (SAF documentIds reales, p.ej. '
|
||||
'"primary:Music/Local")',
|
||||
() {
|
||||
final builder = ConstructorArbolAuto();
|
||||
const docId = 'primary:Music/Local';
|
||||
|
||||
expect(builder.idCarpetaLocalDesde('carpeta_local:$docId'), docId);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('artUriLocal', () {
|
||||
test(
|
||||
'reproduce la misma rotación que artUriPara/indiceArtePara, sembrada '
|
||||
'por documentId en vez de por uuid',
|
||||
() {
|
||||
const documentIds = ['doc-a', 'doc-b', 'doc-c', 'doc-d'];
|
||||
const nombresPorIndice = ['aurora', 'cosmic', 'pulse', 'nova'];
|
||||
|
||||
for (final docId in documentIds) {
|
||||
final indice = indiceArtePara(docId);
|
||||
expect(
|
||||
artUriLocal(docId),
|
||||
'android.resource://es.freetimelab.pluriwave/drawable/'
|
||||
'station_art_${nombresPorIndice[indice]}',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('ConstructorArbolAuto.itemsLocales', () {
|
||||
test('ordena alfabéticamente por nombre y capea a 50', () {
|
||||
final nodos = List.generate(
|
||||
60,
|
||||
(i) => NodoLocal(
|
||||
documentId: 'doc-$i',
|
||||
nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3',
|
||||
esDirectorio: false,
|
||||
),
|
||||
)..shuffle();
|
||||
|
||||
final items = ConstructorArbolAuto().itemsLocales(nodos);
|
||||
|
||||
expect(items, hasLength(50));
|
||||
expect(items.first.title, 'cancion_00');
|
||||
expect(items[1].title, 'cancion_01');
|
||||
});
|
||||
|
||||
test('subcarpeta vacía devuelve lista vacía, no un error', () {
|
||||
expect(ConstructorArbolAuto().itemsLocales(const []), isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'una carpeta mapea a un item no reproducible carpeta_local:<id> con '
|
||||
'el nombre crudo (sin recortar extensión)',
|
||||
() {
|
||||
const nodo = NodoLocal(
|
||||
documentId: 'doc-carpeta',
|
||||
nombre: 'Mi Carpeta',
|
||||
esDirectorio: true,
|
||||
);
|
||||
|
||||
final item = ConstructorArbolAuto().itemsLocales([nodo]).single;
|
||||
|
||||
expect(item.id, 'carpeta_local:doc-carpeta');
|
||||
expect(item.playable, isFalse);
|
||||
expect(item.title, 'Mi Carpeta');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'un archivo mapea a un item reproducible pista:<id> con título = '
|
||||
'nombre sin la última extensión',
|
||||
() {
|
||||
const nodo = NodoLocal(
|
||||
documentId: 'doc-pista',
|
||||
nombre: 'Cancion Genial.mp3',
|
||||
esDirectorio: false,
|
||||
);
|
||||
|
||||
final item = ConstructorArbolAuto().itemsLocales([nodo]).single;
|
||||
|
||||
expect(item.id, 'pista:doc-pista');
|
||||
expect(item.playable, isTrue);
|
||||
expect(item.title, 'Cancion Genial');
|
||||
},
|
||||
);
|
||||
|
||||
test('un archivo sin punto en el nombre conserva el nombre completo', () {
|
||||
const nodo = NodoLocal(
|
||||
documentId: 'doc-sin-ext',
|
||||
nombre: 'CancionSinExtension',
|
||||
esDirectorio: false,
|
||||
);
|
||||
|
||||
final item = ConstructorArbolAuto().itemsLocales([nodo]).single;
|
||||
|
||||
expect(item.title, 'CancionSinExtension');
|
||||
});
|
||||
|
||||
test(
|
||||
'un archivo con nombre en blanco cae al título de reserva, nunca '
|
||||
'queda vacío',
|
||||
() {
|
||||
for (final nombre in ['', ' ']) {
|
||||
final nodo = NodoLocal(
|
||||
documentId: 'doc-blanco',
|
||||
nombre: nombre,
|
||||
esDirectorio: false,
|
||||
);
|
||||
final item = ConstructorArbolAuto().itemsLocales([nodo]).single;
|
||||
expect(item.title, isNotEmpty);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'un archivo cuyo nombre es solo la extensión (punto en posición 0, '
|
||||
'p.ej. ".mp3") conserva el nombre completo en vez de recortar a '
|
||||
'blanco',
|
||||
() {
|
||||
const nodo = NodoLocal(
|
||||
documentId: 'doc-oculto',
|
||||
nombre: '.mp3',
|
||||
esDirectorio: false,
|
||||
);
|
||||
final item = ConstructorArbolAuto().itemsLocales([nodo]).single;
|
||||
expect(item.title, '.mp3');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'el artUri de un archivo usa la misma rotación que artUriLocal, '
|
||||
'sembrada con el documentId',
|
||||
() {
|
||||
const nodo = NodoLocal(
|
||||
documentId: 'doc-arte',
|
||||
nombre: 'Cancion.mp3',
|
||||
esDirectorio: false,
|
||||
);
|
||||
|
||||
final item = ConstructorArbolAuto().itemsLocales([nodo]).single;
|
||||
|
||||
expect(item.artUri.toString(), artUriLocal('doc-arte'));
|
||||
expect(
|
||||
item.artUri.toString(),
|
||||
startsWith(
|
||||
'android.resource://es.freetimelab.pluriwave/drawable/'
|
||||
'station_art_',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('hijosMusicaLocal', () {
|
||||
test(
|
||||
"id musica_local delega en fuente.hijos('') y mapea vía itemsLocales",
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
hijosPorDocId: {
|
||||
'': const [
|
||||
NodoLocal(
|
||||
documentId: 'd1',
|
||||
nombre: 'Cancion.mp3',
|
||||
esDirectorio: false,
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
final items = await hijosMusicaLocal(
|
||||
ConstructorArbolAuto.idMusicaLocal,
|
||||
fuente: fuente,
|
||||
);
|
||||
|
||||
expect(items, isNotNull);
|
||||
expect(items!.single.id, 'pista:d1');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'id carpeta_local:<id> recorta el prefijo y delega en '
|
||||
'fuente.hijos(id)',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
hijosPorDocId: {
|
||||
'sub1': const [
|
||||
NodoLocal(
|
||||
documentId: 'd2',
|
||||
nombre: 'Otra.mp3',
|
||||
esDirectorio: false,
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
final items = await hijosMusicaLocal(
|
||||
'carpeta_local:sub1',
|
||||
fuente: fuente,
|
||||
);
|
||||
|
||||
expect(items, isNotNull);
|
||||
expect(items!.single.id, 'pista:d2');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'un id que no es de música local devuelve null (deja pasar al '
|
||||
'siguiente branch del caller)',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto();
|
||||
|
||||
expect(await hijosMusicaLocal('favoritos', fuente: fuente), isNull);
|
||||
expect(await hijosMusicaLocal('grupo:g1', fuente: fuente), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'fuente null (cold-start, nunca registrada) devuelve lista vacía '
|
||||
'para un id de música local válido, no null y sin lanzar',
|
||||
() async {
|
||||
final resultado = await hijosMusicaLocal(
|
||||
ConstructorArbolAuto.idMusicaLocal,
|
||||
fuente: null,
|
||||
);
|
||||
|
||||
expect(resultado, isNotNull);
|
||||
expect(resultado, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'una fuente que lanza (permiso revocado) degrada a lista vacía, sin '
|
||||
'propagar la excepción',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
errorEnHijos: Exception('permiso revocado'),
|
||||
);
|
||||
|
||||
final resultado = await hijosMusicaLocal(
|
||||
ConstructorArbolAuto.idMusicaLocal,
|
||||
fuente: fuente,
|
||||
);
|
||||
|
||||
expect(resultado, isNotNull);
|
||||
expect(resultado, isEmpty);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('reproducirPistaLocal', () {
|
||||
test(
|
||||
'resuelve el content uri y delega a reproducir con un MediaItem '
|
||||
'reproducible',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
uriPorDocId: const {'doc1': 'content://provider/doc1'},
|
||||
);
|
||||
MediaItem? recibido;
|
||||
|
||||
await reproducirPistaLocal(
|
||||
'pista:doc1',
|
||||
fuente: fuente,
|
||||
reproducir: (item) async {
|
||||
recibido = item;
|
||||
},
|
||||
);
|
||||
|
||||
expect(recibido, isNotNull);
|
||||
expect(recibido!.id, 'content://provider/doc1');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'id obsoleto/desconocido (uriContenidoDePista devuelve null) no '
|
||||
'llama a reproducir ni lanza excepción',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto();
|
||||
var llamadas = 0;
|
||||
|
||||
await reproducirPistaLocal(
|
||||
'pista:doc-fantasma',
|
||||
fuente: fuente,
|
||||
reproducir: (item) async {
|
||||
llamadas++;
|
||||
},
|
||||
);
|
||||
|
||||
expect(llamadas, 0);
|
||||
},
|
||||
);
|
||||
|
||||
test('id sin el prefijo pista: es un no-op, no llama a reproducir', () async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
uriPorDocId: const {'doc1': 'content://provider/doc1'},
|
||||
);
|
||||
var llamadas = 0;
|
||||
|
||||
await reproducirPistaLocal(
|
||||
'emisora:doc1',
|
||||
fuente: fuente,
|
||||
reproducir: (item) async {
|
||||
llamadas++;
|
||||
},
|
||||
);
|
||||
|
||||
expect(llamadas, 0);
|
||||
});
|
||||
|
||||
test(
|
||||
'regresión EQ (estructural): reproducirPistaLocal expone el mismo '
|
||||
'seam `reproducir` que reproducirPorMediaId usa para playMediaItem — '
|
||||
'no existe un parámetro/seam alternativo para música local, por lo '
|
||||
'que la reproducción local pasa por la MISMA cadena de EQ',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
uriPorDocId: const {'doc1': 'content://provider/doc1'},
|
||||
);
|
||||
final llamadasReproducir = <MediaItem>[];
|
||||
|
||||
await reproducirPistaLocal(
|
||||
'pista:doc1',
|
||||
fuente: fuente,
|
||||
reproducir: (item) async => llamadasReproducir.add(item),
|
||||
// NOTE: no existe ningún parámetro alternativo de reproducción —
|
||||
// esa ausencia ES la prueba de que no hay un camino separado que
|
||||
// evite el EQ (mismo patrón que el test estructural de
|
||||
// aplicarPresetPorMediaId).
|
||||
);
|
||||
|
||||
expect(llamadasReproducir, hasLength(1));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('ConstructorArbolAuto.itemPresetEq', () {
|
||||
test('mapea un PresetEcualizador a un item reproducible con id '
|
||||
'eq_preset:<nombre>', () {
|
||||
@@ -890,3 +1303,38 @@ class _FakeFuenteEmisorasAuto implements FuenteEmisorasAuto {
|
||||
List<GrupoFavoritos>? grupos,
|
||||
}) {}
|
||||
}
|
||||
|
||||
class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto {
|
||||
_FakeFuenteMusicaLocalAuto({
|
||||
bool configurada = true,
|
||||
Map<String, List<NodoLocal>>? hijosPorDocId,
|
||||
Map<String, String?>? uriPorDocId,
|
||||
Object? errorEnHijos,
|
||||
Object? errorEnUriContenido,
|
||||
}) : _configurada = configurada,
|
||||
_hijosPorDocId = hijosPorDocId ?? const {},
|
||||
_uriPorDocId = uriPorDocId ?? const {},
|
||||
_errorEnHijos = errorEnHijos,
|
||||
_errorEnUriContenido = errorEnUriContenido;
|
||||
|
||||
final bool _configurada;
|
||||
final Map<String, List<NodoLocal>> _hijosPorDocId;
|
||||
final Map<String, String?> _uriPorDocId;
|
||||
final Object? _errorEnHijos;
|
||||
final Object? _errorEnUriContenido;
|
||||
|
||||
@override
|
||||
Future<bool> hayCarpetaConfigurada() async => _configurada;
|
||||
|
||||
@override
|
||||
Future<List<NodoLocal>> hijos(String documentId) async {
|
||||
if (_errorEnHijos != null) throw _errorEnHijos;
|
||||
return _hijosPorDocId[documentId] ?? const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> uriContenidoDePista(String documentId) async {
|
||||
if (_errorEnUriContenido != null) throw _errorEnUriContenido;
|
||||
return _uriPorDocId[documentId];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user