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:
2026-07-19 20:30:50 +02:00
parent 99897ec848
commit 6ae7e378c4
28 changed files with 2425 additions and 40 deletions
@@ -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);
},
);
});
}
+469 -21
View File
@@ -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];
}
}