merge: resource guard in CI and group-aware station skipping

This commit is contained in:
2026-08-07 11:38:27 +02:00
4 changed files with 153 additions and 12 deletions
+30
View File
@@ -134,6 +134,36 @@ jobs:
- name: Build APK release
run: flutter build apk --release
# Guardian de recursos: el APK debe contener los drawables que el codigo
# resuelve POR NOMBRE en runtime (getResources().getIdentifier).
#
# Un nombre que no resuelve devuelve id 0, y eso no falla la
# compilacion: falla en el coche. Concretamente
# PlaybackStateCompat.CustomAction.Builder lanza con icono 0, ese throw
# aborta AudioService.setState antes de activar la sesion de medios, y
# Android Auto se queda con la interfaz congelada. Paso exactamente eso
# entre el 31-07 (commit 2540556) y el 07-08 sin que nada lo detectara.
#
# Anadir un drawable nuevo referenciado por nombre => anadirlo aqui.
- name: Verificar recursos criticos en el APK
run: |
APK=build/app/outputs/flutter-apk/app-release.apk
FALTAN=0
for RECURSO in ic_auto_eq_on ic_auto_eq_off ic_stat_pluriwave; do
if unzip -l "$APK" | grep -q "res/drawable/$RECURSO.xml"; then
echo "OK res/drawable/$RECURSO.xml"
else
echo "FALTA res/drawable/$RECURSO.xml"
FALTAN=1
fi
done
if [ "$FALTAN" -ne 0 ]; then
echo ""
echo "Hay drawables resueltos por nombre que NO estan en el APK."
echo "En runtime resolveran a id 0 y tumbaran la sesion de medios."
exit 1
fi
- name: Build AAB release
run: flutter build appbundle --release
+41 -12
View File
@@ -936,15 +936,26 @@ Future<void> reproducirPorMediaId(
await reproducir(item);
}
/// Which list previous/next should walk for [actual]: the NARROWEST list the
/// station actually belongs to, favourites first, then my stations, then the
/// full catalogue.
/// Which list previous/next should walk for [actual]: the NARROWEST context
/// the station belongs to.
///
/// Narrowest-first is the point. "Next station" while playing a favourite
/// should land on the next favourite, not on entry 4,318 of a 50,000-station
/// catalogue that happens to sit beside it alphabetically. Falling through to
/// [todas] only when the station is in neither curated list keeps the button
/// working for a station reached by search.
/// Tightest first:
/// 1. its FAVOURITES GROUP, when it is a favourite filed under a real group,
/// 2. all favourites,
/// 3. my stations,
/// 4. the full catalogue.
///
/// The group tier is what the owner asked for: driving with a themed group,
/// "next" should stay inside that group rather than wander across every
/// favourite. And "next" from a favourite must never land on entry 4,318 of a
/// 50,000-station catalogue that happens to sit beside it alphabetically.
/// Falling through to [todas] only when the station is in neither curated
/// list keeps the button alive for a station reached by search.
///
/// [GrupoFavoritos.sinAsignarId] is deliberately NOT treated as a group: it
/// is the ABSENCE of one, so those stations walk all favourites instead of a
/// bucket that only means "unfiled". A group with a single member also falls
/// through to all favourites — otherwise both buttons would be dead ends.
///
/// Returns an empty list when [actual] is in none of them, which
/// [emisoraVecina] turns into "do nothing".
@@ -954,10 +965,28 @@ List<Emisora> listaParaSaltoEmisora({
required List<Emisora> misEmisoras,
required List<Emisora> todas,
}) {
bool contiene(List<Emisora> lista) => lista.any((e) => e.uuid == actual.uuid);
if (contiene(favoritos)) return favoritos;
if (contiene(misEmisoras)) return misEmisoras;
if (contiene(todas)) return todas;
Emisora? enLista(List<Emisora> lista) {
for (final e in lista) {
if (e.uuid == actual.uuid) return e;
}
return null;
}
// The FAVOURITE record is the authority on the group, never `actual`: the
// playing station is rebuilt from a MediaItem by `emisoraDesdeMediaItem`,
// which carries no group id and would always report "sin asignar".
final favorita = enLista(favoritos);
if (favorita != null) {
final grupo = favorita.grupoFavoritosId;
if (grupo != GrupoFavoritos.sinAsignarId) {
final delGrupo =
favoritos.where((e) => e.grupoFavoritosId == grupo).toList();
if (delGrupo.length > 1) return delGrupo;
}
return favoritos;
}
if (enLista(misEmisoras) != null) return misEmisoras;
if (enLista(todas) != null) return todas;
return const [];
}
+9
View File
@@ -1544,6 +1544,15 @@ class PluriWaveAudioHandler extends BaseAudioHandler
todas: await fuente.todas(),
);
final destino = emisoraVecina(actual, lista, haciaAtras: haciaAtras);
// Reported: in the car these buttons did nothing for radio. Every early
// return here is silent, so the log has to say WHICH one fired --
// an empty list (the station matched none of the three) and a station
// that is in a list of one are indistinguishable from outside.
debugPrint(
'[PluriWave][ServicioAudio] salto emisora atras=$haciaAtras '
'actual=${actual.nombre} uuid=${actual.uuid} '
'lista=${lista.length} destino=${destino?.nombre ?? "NINGUNO"}',
);
if (destino == null) return;
await playMediaItem(mediaItemParaEmisora(destino, l10n: _textos));
} catch (e) {
@@ -1,5 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/grupo_favoritos.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
/// Requested: the Android Auto playback screen must offer previous/next for
@@ -99,4 +100,76 @@ void main() {
);
});
});
group('navegación por GRUPO de favoritos (pedido por el dueño)', () {
Emisora favorita(String uuid, String grupo) => Emisora(
uuid: uuid,
nombre: uuid,
url: 'https://example.com/$uuid',
grupoFavoritosId: grupo,
);
final rock1 = favorita('rock1', 'g-rock');
final rock2 = favorita('rock2', 'g-rock');
final jazz1 = favorita('jazz1', 'g-jazz');
final suelta = favorita('suelta', GrupoFavoritos.sinAsignarId);
test('sonando una favorita de un grupo, se recorre SOLO ese grupo', () {
expect(
listaParaSaltoEmisora(
actual: rock1,
favoritos: [rock1, jazz1, rock2, suelta],
misEmisoras: const [],
todas: [rock1, jazz1, rock2, suelta],
),
[rock1, rock2],
);
});
test('el grupo se lee del registro de FAVORITOS, no de lo que suena: la '
'emisora reconstruida desde el MediaItem no lleva grupo', () {
// emisoraDesdeMediaItem no puede saber el grupo -> llega "sin asignar".
final reconstruida = Emisora(
uuid: 'rock1',
nombre: 'Rock 1',
url: 'https://example.com/rock1',
);
expect(reconstruida.grupoFavoritosId, GrupoFavoritos.sinAsignarId);
expect(
listaParaSaltoEmisora(
actual: reconstruida,
favoritos: [rock1, jazz1, rock2],
misEmisoras: const [],
todas: const [],
),
[rock1, rock2],
reason: 'si se leyera de `actual` caeríamos a todos los favoritos',
);
});
test('"sin asignar" NO es un grupo: recorre todos los favoritos', () {
expect(
listaParaSaltoEmisora(
actual: suelta,
favoritos: [rock1, suelta, jazz1],
misEmisoras: const [],
todas: const [],
),
[rock1, suelta, jazz1],
);
});
test('un grupo de UNA sola emisora cae a todos los favoritos, para no '
'dejar los dos botones muertos', () {
expect(
listaParaSaltoEmisora(
actual: jazz1,
favoritos: [rock1, rock2, jazz1],
misEmisoras: const [],
todas: const [],
),
[rock1, rock2, jazz1],
);
});
});
}