feat(auto): page local-music folders instead of truncating at 50 [size:exception]

Folders over the 50-item cap now show a "Mas..." item that reveals
the next page on tap, instead of silently dropping the rest. Paging
slices the cheap raw list before building any MediaItem, so items
beyond the requested page are never resolved (art, title) -- proven
by a call-count test. Also swaps the raw SAF content:// URI shown in
settings for a parsed, human-readable folder name with a localized
fallback across all 13 locales.

servicio_audio.dart is untouched; this stays entirely within the
local-music tree/dispatch layer.
This commit is contained in:
2026-07-19 22:14:05 +02:00
parent 977cbcd8cc
commit 725169cd31
25 changed files with 1601 additions and 40 deletions
+57
View File
@@ -511,6 +511,63 @@ void main() {
expect(fakeDispositivo.solicitarPermisoBluetoothCalls, equals(1));
});
});
// ── android-auto-local-music-paging Phase 7: friendly folder name ────────
group('_SeccionMusicaLocal — friendly folder name (Phase 7)', () {
testWidgets(
'7.1-A: carpeta configurada muestra el nombre amigable derivado de '
'la URI, nunca la URI cruda',
(tester) async {
SharedPreferences.setMockInitialValues({
'musica_local_uri':
'content://com.android.externalstorage.documents/tree/'
'primary%3AMusic%2FMyFolder',
});
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstado();
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Local music folder'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
expect(find.text('MyFolder'), findsOneWidget);
expect(find.textContaining('content://'), findsNothing);
},
);
testWidgets(
'7.1-B: sin carpeta configurada mantiene el mensaje '
'localMusicFolderNotConfigured',
(tester) async {
SharedPreferences.setMockInitialValues({});
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstado();
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
await tester.scrollUntilVisible(
find.text('Local music folder'),
300,
scrollable: find.byType(Scrollable).first,
);
await pumpStable(tester);
expect(find.text('No folder selected'), findsOneWidget);
},
);
});
}
// ── Infrastructure ──────────────────────────────────────────────────────────
@@ -38,4 +38,88 @@ void main() {
},
);
});
group('nombreCarpetaDesdeUri', () {
const generico = 'Carpeta seleccionada';
test('almacenamiento interno: toma el segmento tras el último ":"', () {
expect(
nombreCarpetaDesdeUri(
'content://com.android.externalstorage.documents/tree/'
'primary%3AMusic%2FMyFolder',
nombreGenerico: generico,
),
'MyFolder',
);
});
test('tarjeta SD: toma el segmento tras el último ":"', () {
expect(
nombreCarpetaDesdeUri(
'content://com.android.externalstorage.documents/tree/'
'1A2B-3C4D%3AMusic',
nombreGenerico: generico,
),
'Music',
);
});
test(
'carpeta anidada con "/": toma el segmento tras la última "/"',
() {
expect(
nombreCarpetaDesdeUri(
'content://com.android.externalstorage.documents/tree/'
'primary%3AMusic%2FAlbum%2FDeep',
nombreGenerico: generico,
),
'Deep',
);
},
);
test(
'raíz de almacenamiento con tail vacío tras ":" cae al genérico',
() {
final resultado = nombreCarpetaDesdeUri(
'content://com.android.externalstorage.documents/tree/primary%3A',
nombreGenerico: generico,
);
expect(resultado, generico);
},
);
test('URI no parseable o de forma inesperada cae al genérico', () {
for (final uri in [
'',
'not a uri \$\$\$ ///',
'content://com.android.externalstorage.documents/tree/',
]) {
final resultado = nombreCarpetaDesdeUri(uri, nombreGenerico: generico);
expect(resultado, generico);
}
});
test(
'nunca devuelve la URI cruda ni una cadena vacía, para ningún caso '
'de esta suite',
() {
final casos = [
'content://com.android.externalstorage.documents/tree/'
'primary%3AMusic%2FMyFolder',
'content://com.android.externalstorage.documents/tree/'
'1A2B-3C4D%3AMusic',
'content://com.android.externalstorage.documents/tree/primary%3A',
'',
'garbage',
];
for (final uri in casos) {
final resultado = nombreCarpetaDesdeUri(uri, nombreGenerico: generico);
expect(resultado, isNotEmpty);
expect(resultado, isNot(equals(uri)));
expect(resultado, isNot(contains('content://')));
}
},
);
});
}
+439 -14
View File
@@ -370,26 +370,365 @@ void main() {
);
});
group('paginaDe', () {
test('slicea al medio de la lista', () {
final items = List.generate(10, (i) => i);
expect(paginaDe(items, pagina: 1, tamano: 3), [3, 4, 5]);
});
test('lista vacía devuelve lista vacía', () {
expect(paginaDe(<int>[], pagina: 0, tamano: 5), isEmpty);
});
test('página más allá del rango devuelve lista vacía, sin lanzar', () {
final items = List.generate(5, (i) => i);
expect(paginaDe(items, pagina: 3, tamano: 5), isEmpty);
});
test('última página parcial devuelve solo los elementos restantes', () {
final items = List.generate(7, (i) => i);
expect(paginaDe(items, pagina: 1, tamano: 5), [5, 6]);
});
});
group('hayPaginaSiguiente', () {
test(
'boundary exacto: total == (pagina+1)*tamano es false, mayor es true',
() {
expect(hayPaginaSiguiente(50, pagina: 0, tamano: 50), isFalse);
expect(hayPaginaSiguiente(51, pagina: 0, tamano: 50), isTrue);
expect(hayPaginaSiguiente(100, pagina: 1, tamano: 50), isFalse);
expect(hayPaginaSiguiente(101, pagina: 1, tamano: 50), isTrue);
},
);
});
group(
'esCarpetaLocalPaginadaMediaId / paginaCarpetaLocalDesde',
() {
test(
'reconoce el prefijo carpeta_local_pag: y no colisiona con '
'emisora:/grupo:/eq_preset:/carpeta_local:/pista: ni con los ids de '
'carpeta fijos',
() {
final builder = ConstructorArbolAuto();
expect(
builder.esCarpetaLocalPaginadaMediaId('carpeta_local_pag:1:doc1'),
isTrue,
);
expect(
builder.esCarpetaLocalPaginadaMediaId('carpeta_local_pag:1:'),
isTrue,
);
final otros = <String>[
'emisora:x',
'grupo:g1',
'eq_preset:Rock',
'carpeta_local:doc1',
'pista:doc1',
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
ConstructorArbolAuto.idEcualizador,
ConstructorArbolAuto.idMusicaLocal,
'',
];
for (final id in otros) {
expect(
builder.esCarpetaLocalPaginadaMediaId(id),
isFalse,
reason: 'esCarpetaLocalPaginadaMediaId($id) debería ser false',
);
}
// Simétricamente, un carpeta_local: plano nunca matchea el prefijo
// paginado, y viceversa.
expect(
builder.esCarpetaLocalMediaId('carpeta_local_pag:1:doc1'),
isFalse,
);
},
);
test(
'parsea (documentId, pagina) recortando el prefijo y separando en '
'el PRIMER ":" — el documentId sobrevive intacto aunque contenga '
'":" o "/"',
() {
final builder = ConstructorArbolAuto();
expect(
builder.paginaCarpetaLocalDesde('carpeta_local_pag:1:doc1'),
('doc1', 1),
);
expect(
builder.paginaCarpetaLocalDesde(
'carpeta_local_pag:3:primary:Music/Local',
),
('primary:Music/Local', 3),
);
},
);
test(
'página raíz con tail vacío ("carpeta_local_pag:1:") parsea a '
'documentId vacío',
() {
final builder = ConstructorArbolAuto();
expect(
builder.paginaCarpetaLocalDesde('carpeta_local_pag:1:'),
('', 1),
);
},
);
},
);
group('ConstructorArbolAuto.itemsLocales', () {
test('ordena alfabéticamente por nombre y capea a 50', () {
test(
'invariante de conteo de llamadas (ADR-3): con 200 nodos, cada página '
'invoca construirItem exactamente min(50, restantes) veces — nunca '
'una vez por el total de la carpeta',
() {
final nodos = List.generate(
200,
(i) => NodoLocal(
documentId: 'doc-$i',
nombre: 'cancion_${i.toString().padLeft(3, '0')}.mp3',
esDirectorio: false,
),
);
final builder = ConstructorArbolAuto();
var llamadas = 0;
MediaItem espia(NodoLocal nodo) {
llamadas++;
return MediaItem(id: 'pista:${nodo.documentId}', title: nodo.nombre);
}
llamadas = 0;
builder.itemsLocales(
nodos,
documentIdPadre: 'x',
pagina: 0,
construirItem: espia,
);
expect(llamadas, 50);
llamadas = 0;
builder.itemsLocales(
nodos,
documentIdPadre: 'x',
pagina: 1,
construirItem: espia,
);
expect(llamadas, 50);
llamadas = 0;
builder.itemsLocales(
nodos,
documentIdPadre: 'x',
pagina: 3,
construirItem: espia,
);
expect(llamadas, 50); // 200 - 150 = 50 restantes
llamadas = 0;
builder.itemsLocales(
nodos,
documentIdPadre: 'x',
pagina: 4,
construirItem: espia,
);
expect(llamadas, 0); // más allá del rango, no queda nada
},
);
test(
'exactamente 50 items: no aparece "Más…", nada se pierde',
() {
final nodos = List.generate(
50,
(i) => NodoLocal(
documentId: 'doc-$i',
nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3',
esDirectorio: false,
),
)..shuffle();
final items = ConstructorArbolAuto().itemsLocales(
nodos,
documentIdPadre: 'x',
);
expect(items, hasLength(50));
expect(items.first.title, 'cancion_00');
expect(items.last.title, 'cancion_49');
expect(items.any((i) => i.title == 'Más…'), isFalse);
},
);
test(
'51 a 100 items: primera página trae 50 + "Más…"; la última página '
'trae el resto sin "Más…"',
() {
final nodos = List.generate(
60,
(i) => NodoLocal(
documentId: 'doc-$i',
nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3',
esDirectorio: false,
),
)..shuffle();
final builder = ConstructorArbolAuto();
final pagina0 = builder.itemsLocales(nodos, documentIdPadre: 'x');
expect(pagina0, hasLength(51));
expect(pagina0.first.title, 'cancion_00');
expect(pagina0[49].title, 'cancion_49');
final mas0 = pagina0.last;
expect(mas0.title, 'Más…');
expect(mas0.playable, isFalse);
expect(mas0.artUri, isNull);
final pagina1 = builder.itemsLocales(
nodos,
documentIdPadre: 'x',
pagina: 1,
);
expect(pagina1, hasLength(10));
expect(pagina1.last.title, 'cancion_59');
expect(pagina1.any((i) => i.title == 'Más…'), isFalse);
},
);
test(
'100+ items encadena 3+ páginas, cada item es alcanzable y nada se '
'pierde',
() {
final nodos = List.generate(
120,
(i) => NodoLocal(
documentId: 'doc-$i',
nombre: 'cancion_${i.toString().padLeft(3, '0')}.mp3',
esDirectorio: false,
),
)..shuffle();
final builder = ConstructorArbolAuto();
final vistos = <String>{};
var pagina = 0;
var seguir = true;
var paginasVisitadas = 0;
while (seguir) {
final items = builder.itemsLocales(
nodos,
documentIdPadre: 'x',
pagina: pagina,
);
paginasVisitadas++;
final tieneMas = items.isNotEmpty && items.last.title == 'Más…';
final hojas = tieneMas ? items.sublist(0, items.length - 1) : items;
for (final item in hojas) {
vistos.add(item.id);
}
seguir = tieneMas;
pagina++;
}
expect(paginasVisitadas, greaterThanOrEqualTo(3));
expect(vistos, hasLength(120));
},
);
test(
'el item "Más…" tiene forma round-trip: id '
'carpeta_local_pag:<n+1>:<docId>, playable false, sin artUri',
() {
final nodos = List.generate(
51,
(i) => NodoLocal(
documentId: 'doc-$i',
nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3',
esDirectorio: false,
),
);
final builder = ConstructorArbolAuto();
final items = builder.itemsLocales(
nodos,
documentIdPadre: 'sub1',
pagina: 0,
);
final mas = items.last;
expect(mas.id, 'carpeta_local_pag:1:sub1');
expect(mas.title, 'Más…');
expect(mas.playable, isFalse);
expect(mas.artUri, isNull);
expect(
builder.esCarpetaLocalPaginadaMediaId(mas.id),
isTrue,
);
expect(
builder.paginaCarpetaLocalDesde(mas.id),
('sub1', 1),
);
},
);
test('paginación en la raíz (documentIdPadre vacío) también funciona', () {
final nodos = List.generate(
60,
51,
(i) => NodoLocal(
documentId: 'doc-$i',
nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3',
esDirectorio: false,
),
)..shuffle();
);
final builder = ConstructorArbolAuto();
final items = ConstructorArbolAuto().itemsLocales(nodos);
final items = builder.itemsLocales(nodos, documentIdPadre: '');
final mas = items.last;
expect(items, hasLength(50));
expect(items.first.title, 'cancion_00');
expect(items[1].title, 'cancion_01');
expect(mas.id, 'carpeta_local_pag:1:');
expect(builder.paginaCarpetaLocalDesde(mas.id), ('', 1));
});
test(
'página obsoleta más allá del rango (la carpeta se achicó) devuelve '
'lista vacía, sin "Más…" y sin lanzar',
() {
final nodos = List.generate(
10,
(i) => NodoLocal(
documentId: 'doc-$i',
nombre: 'cancion_$i.mp3',
esDirectorio: false,
),
);
final items = ConstructorArbolAuto().itemsLocales(
nodos,
documentIdPadre: 'x',
pagina: 5,
);
expect(items, isEmpty);
},
);
test('subcarpeta vacía devuelve lista vacía, no un error', () {
expect(ConstructorArbolAuto().itemsLocales(const []), isEmpty);
expect(
ConstructorArbolAuto().itemsLocales(const [], documentIdPadre: 'x'),
isEmpty,
);
});
test(
@@ -402,7 +741,9 @@ void main() {
esDirectorio: true,
);
final item = ConstructorArbolAuto().itemsLocales([nodo]).single;
final item = ConstructorArbolAuto()
.itemsLocales([nodo], documentIdPadre: 'x')
.single;
expect(item.id, 'carpeta_local:doc-carpeta');
expect(item.playable, isFalse);
@@ -420,7 +761,9 @@ void main() {
esDirectorio: false,
);
final item = ConstructorArbolAuto().itemsLocales([nodo]).single;
final item = ConstructorArbolAuto()
.itemsLocales([nodo], documentIdPadre: 'x')
.single;
expect(item.id, 'pista:doc-pista');
expect(item.playable, isTrue);
@@ -435,7 +778,9 @@ void main() {
esDirectorio: false,
);
final item = ConstructorArbolAuto().itemsLocales([nodo]).single;
final item = ConstructorArbolAuto()
.itemsLocales([nodo], documentIdPadre: 'x')
.single;
expect(item.title, 'CancionSinExtension');
});
@@ -450,7 +795,9 @@ void main() {
nombre: nombre,
esDirectorio: false,
);
final item = ConstructorArbolAuto().itemsLocales([nodo]).single;
final item = ConstructorArbolAuto()
.itemsLocales([nodo], documentIdPadre: 'x')
.single;
expect(item.title, isNotEmpty);
}
},
@@ -466,7 +813,9 @@ void main() {
nombre: '.mp3',
esDirectorio: false,
);
final item = ConstructorArbolAuto().itemsLocales([nodo]).single;
final item = ConstructorArbolAuto()
.itemsLocales([nodo], documentIdPadre: 'x')
.single;
expect(item.title, '.mp3');
},
);
@@ -481,7 +830,9 @@ void main() {
esDirectorio: false,
);
final item = ConstructorArbolAuto().itemsLocales([nodo]).single;
final item = ConstructorArbolAuto()
.itemsLocales([nodo], documentIdPadre: 'x')
.single;
expect(item.artUri.toString(), artUriLocal('doc-arte'));
expect(
@@ -589,6 +940,80 @@ void main() {
expect(resultado, isEmpty);
},
);
test(
'un id carpeta_local_pag:<n>:<docId> enruta a (docId, n) y devuelve '
'esa página',
() async {
final nodos = List.generate(
60,
(i) => NodoLocal(
documentId: 'd$i',
nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3',
esDirectorio: false,
),
);
final fuente = _FakeFuenteMusicaLocalAuto(
hijosPorDocId: {'sub1': nodos},
);
final items = await hijosMusicaLocal(
'carpeta_local_pag:1:sub1',
fuente: fuente,
);
expect(items, isNotNull);
// Página 1 (0-indexed) de 60 items con tamaño 50 trae los 10
// restantes, sin "Más…".
expect(items, hasLength(10));
expect(items!.any((i) => i.title == 'Más…'), isFalse);
},
);
test(
'un id que no es de música local (incluido uno con prefijo distinto) '
'devuelve null',
() async {
final fuente = _FakeFuenteMusicaLocalAuto();
expect(
await hijosMusicaLocal('carpeta_local_pag_otro:1:x', fuente: fuente),
isNull,
);
},
);
test(
'fuente null con un id paginado válido devuelve lista vacía, no null '
'y sin lanzar',
() async {
final resultado = await hijosMusicaLocal(
'carpeta_local_pag:1:sub1',
fuente: null,
);
expect(resultado, isNotNull);
expect(resultado, isEmpty);
},
);
test(
'una fuente que lanza en la rama paginada degrada a lista vacía, sin '
'propagar la excepción',
() async {
final fuente = _FakeFuenteMusicaLocalAuto(
errorEnHijos: Exception('permiso revocado'),
);
final resultado = await hijosMusicaLocal(
'carpeta_local_pag:1:sub1',
fuente: fuente,
);
expect(resultado, isNotNull);
expect(resultado, isEmpty);
},
);
});
group('reproducirPistaLocal', () {