feat(alarmas): surface the three native scheduling failures in Dart
Completes the bridge the native side already exposed. AlarmScheduler and PluriWaveAlarmService record a pre-notice that could not be armed, a refused foreground-service start, and a per-alarm reschedule that failed after a reboot -- but nothing read them, so all three still ended at logcat. EstadoAlarmas now drains them at startup and turns each into a per-alarm exception, which the card already knows how to mark. An alarm that never reached the OS stops looking identical to one that did. The read is deliberately tolerant: a failure to read is logged and swallowed, never surfaced as an alarm error, so a diagnostics gap cannot masquerade as a scheduling problem.
This commit is contained in:
@@ -202,8 +202,22 @@ class AlarmScheduler(private val context: Context) {
|
||||
)
|
||||
)
|
||||
Log.d(tag, "alarm.schedule preNotice OK id=${spec.id}")
|
||||
NativeSchedulingFailures.clear(
|
||||
appContext,
|
||||
spec.id,
|
||||
NativeSchedulingFailures.TYPE_PRE_NOTICE
|
||||
)
|
||||
} catch (_: SecurityException) {
|
||||
// Silent before this fix: the main alarm can still arm via
|
||||
// setAlarmClock (exempt from the exact-alarm permission), so
|
||||
// the alarm itself rings while its 30-minute reminder simply
|
||||
// never appears, with nothing surfaced anywhere but logcat.
|
||||
Log.w(tag, "alarm.schedule preNotice SecurityException id=${spec.id}")
|
||||
NativeSchedulingFailures.record(
|
||||
appContext,
|
||||
spec.id,
|
||||
NativeSchedulingFailures.TYPE_PRE_NOTICE
|
||||
)
|
||||
}
|
||||
} else if (spec.triggerAtMillis > now) {
|
||||
appContext.sendBroadcast(
|
||||
@@ -846,8 +860,22 @@ class AlarmScheduler(private val context: Context) {
|
||||
// the native recompute inside scheduleSpec.
|
||||
scheduleSpec(spec, persistOnSuccess = true, trustDartTrigger = true)
|
||||
Log.d(tag, "alarm.reschedule OK id=$id")
|
||||
NativeSchedulingFailures.clear(
|
||||
appContext,
|
||||
id,
|
||||
NativeSchedulingFailures.TYPE_RESCHEDULE
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
// Silent before this fix: one alarm's reschedule failure used
|
||||
// to just log and move to the next id, leaving that ONE
|
||||
// alarm unscheduled after a reboot/unlock/app-update with no
|
||||
// signal anywhere but logcat.
|
||||
Log.e(tag, "alarm.reschedule failed id=$id", error)
|
||||
NativeSchedulingFailures.record(
|
||||
appContext,
|
||||
id,
|
||||
NativeSchedulingFailures.TYPE_RESCHEDULE
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -855,6 +883,18 @@ class AlarmScheduler(private val context: Context) {
|
||||
fun pendingAlarmCount(): Int =
|
||||
prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().size
|
||||
|
||||
/**
|
||||
* Scheduling-reliability failures the native side recorded on its own
|
||||
* (fix/alarmas-fallos-silenciosos, item 2): pre-notice, foreground-
|
||||
* service start, and post-boot/unlock reschedule failures never go
|
||||
* through a Dart method-channel call that could throw, so they are
|
||||
* persisted here instead and synced by Flutter on the next app launch
|
||||
* -- mirroring [handledOccurrences]/[nativeSnoozeStates]'s own
|
||||
* cold-start-sync shape.
|
||||
*/
|
||||
fun scheduleFailures(): List<Map<String, Any>> =
|
||||
NativeSchedulingFailures.all(appContext)
|
||||
|
||||
fun handledOccurrences(): List<Map<String, Any>> =
|
||||
prefs().getStringSet(KEY_HANDLED_IDS, emptySet()).orEmpty()
|
||||
.mapNotNull { id ->
|
||||
@@ -1265,3 +1305,91 @@ class AlarmScheduler(private val context: Context) {
|
||||
|
||||
private fun JSONObject.optNullableLong(name: String): Long? =
|
||||
if (has(name) && !isNull(name)) optLong(name) else null
|
||||
|
||||
/**
|
||||
* Persisted store for scheduling-reliability failures the native side
|
||||
* catches and previously only logged (fix/alarmas-fallos-silenciosos, item
|
||||
* 2): the pre-notice `SecurityException` (AlarmScheduler.schedulePreNotice),
|
||||
* a refused foreground-service start (PluriWaveAlarmService.start), and a
|
||||
* per-alarm reschedule failure after boot/unlock
|
||||
* (AlarmScheduler.reschedulePersistedAlarms). A separate top-level object
|
||||
* (not nested in [AlarmScheduler]'s own instance state) so
|
||||
* [PluriWaveAlarmService]'s companion object -- which has no [AlarmScheduler]
|
||||
* instance of its own -- can record a failure too, using the exact same
|
||||
* [Context]-scoped, device-protected-storage `SharedPreferences` file
|
||||
* [AlarmScheduler] itself reads/writes (same `PREFS` name, kept in sync by
|
||||
* hand since Kotlin constants cannot be shared across files without a third
|
||||
* file).
|
||||
*
|
||||
* Only the LATEST failure per alarm is kept (mirrors the Dart-side
|
||||
* `ExcepcionAlarma` "latest wins" semantics) -- this is a reliability
|
||||
* signal, not an audit log.
|
||||
*/
|
||||
object NativeSchedulingFailures {
|
||||
private const val PREFS = "pluriwave_alarm_scheduler"
|
||||
private const val KEY_FAILURE_IDS = "schedule_failure_alarm_ids"
|
||||
private const val KEY_FAILURE_TYPE_PREFIX = "schedule_failure_type_"
|
||||
private const val KEY_FAILURE_AT_PREFIX = "schedule_failure_at_"
|
||||
|
||||
/** Mirrors Dart's `ExcepcionAlarma.tipoFalloPreaviso`. */
|
||||
const val TYPE_PRE_NOTICE = "preNoticeFailed"
|
||||
|
||||
/** Mirrors Dart's `ExcepcionAlarma.tipoFalloServicioSonido`. */
|
||||
const val TYPE_FOREGROUND_SERVICE = "foregroundServiceFailed"
|
||||
|
||||
/** Mirrors Dart's `ExcepcionAlarma.tipoFalloReprogramacionArranque`. */
|
||||
const val TYPE_RESCHEDULE = "rescheduleAfterBootFailed"
|
||||
|
||||
private fun prefs(context: Context) =
|
||||
context.applicationContext.createDeviceProtectedStorageContext()
|
||||
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
fun record(context: Context, id: String, type: String) {
|
||||
if (id.isBlank()) return
|
||||
val store = prefs(context)
|
||||
val ids = store.getStringSet(KEY_FAILURE_IDS, emptySet()).orEmpty().toMutableSet()
|
||||
ids.add(id)
|
||||
store.edit()
|
||||
.putStringSet(KEY_FAILURE_IDS, ids)
|
||||
.putString("$KEY_FAILURE_TYPE_PREFIX$id", type)
|
||||
.putLong("$KEY_FAILURE_AT_PREFIX$id", System.currentTimeMillis())
|
||||
.apply()
|
||||
Log.w("PluriWave", "alarm.scheduleFailure recorded id=$id type=$type")
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the failure recorded for [id] ONLY when its current type is
|
||||
* [type] -- type-scoped on purpose (mirrors the Dart-side
|
||||
* `limpiarFalloProgramacion`), so a foreground-service success never
|
||||
* erases an unrelated, still-outstanding pre-notice failure for the
|
||||
* same alarm.
|
||||
*/
|
||||
fun clear(context: Context, id: String, type: String) {
|
||||
val store = prefs(context)
|
||||
val storedType = store.getString("$KEY_FAILURE_TYPE_PREFIX$id", null)
|
||||
if (storedType != type) return
|
||||
val ids = store.getStringSet(KEY_FAILURE_IDS, emptySet()).orEmpty().toMutableSet()
|
||||
if (!ids.remove(id)) return
|
||||
store.edit()
|
||||
.putStringSet(KEY_FAILURE_IDS, ids)
|
||||
.remove("$KEY_FAILURE_TYPE_PREFIX$id")
|
||||
.remove("$KEY_FAILURE_AT_PREFIX$id")
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun all(context: Context): List<Map<String, Any>> {
|
||||
val store = prefs(context)
|
||||
return store.getStringSet(KEY_FAILURE_IDS, emptySet()).orEmpty().mapNotNull { id ->
|
||||
val type = store.getString("$KEY_FAILURE_TYPE_PREFIX$id", null)
|
||||
?: return@mapNotNull null
|
||||
val at = store.getLong("$KEY_FAILURE_AT_PREFIX$id", 0L)
|
||||
.takeIf { it > 0L }
|
||||
?: return@mapNotNull null
|
||||
mapOf(
|
||||
"alarmId" to id,
|
||||
"type" to type,
|
||||
"atMillis" to at
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +250,10 @@ class MainActivity : AudioServiceActivity() {
|
||||
Log.d(tag, "alarm.channel getNativeSnoozeState")
|
||||
result.success(alarmScheduler.nativeSnoozeStates())
|
||||
}
|
||||
"getNativeSchedulingFailures" -> {
|
||||
Log.d(tag, "alarm.channel getNativeSchedulingFailures")
|
||||
result.success(alarmScheduler.scheduleFailures())
|
||||
}
|
||||
"setNotificationStrings" -> {
|
||||
val args = call.arguments as? Map<*, *>
|
||||
if (args != null) {
|
||||
|
||||
@@ -183,7 +183,16 @@ class PluriWaveAlarmService : Service() {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
// Silent before this fix: same user-visible symptom as a refused
|
||||
// startForegroundService (the ring never actually starts) --
|
||||
// recorded under the SAME tipo so the alarms list surfaces it
|
||||
// regardless of which of the two calls the OS refused.
|
||||
Log.e(TAG, "alarm.service startForeground failed id=$alarmId", error)
|
||||
NativeSchedulingFailures.record(
|
||||
this,
|
||||
alarmId,
|
||||
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
|
||||
)
|
||||
releaseWakeLock()
|
||||
// Second documented clear site (feedback item, READ-5): this
|
||||
// branch never reaches stopEverything(), so without the same
|
||||
@@ -197,6 +206,11 @@ class PluriWaveAlarmService : Service() {
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
NativeSchedulingFailures.clear(
|
||||
this,
|
||||
alarmId,
|
||||
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
|
||||
)
|
||||
startAudio(
|
||||
alarmId,
|
||||
stationName,
|
||||
@@ -754,6 +768,7 @@ class PluriWaveAlarmService : Service() {
|
||||
|
||||
fun start(context: Context, source: Intent) {
|
||||
ensureChannel(context)
|
||||
val alarmId = source.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID)
|
||||
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
|
||||
action = PluriWaveAlarmReceiver.ACTION_FIRE
|
||||
putExtras(source)
|
||||
@@ -761,8 +776,27 @@ class PluriWaveAlarmService : Service() {
|
||||
try {
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
Log.d(TAG, "alarm.service start requested")
|
||||
if (!alarmId.isNullOrBlank()) {
|
||||
NativeSchedulingFailures.clear(
|
||||
context,
|
||||
alarmId,
|
||||
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
|
||||
)
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
// Silent before this fix: a fire-and-forget call from the
|
||||
// receiver's ACTION_FIRE branch -- if the OS refuses the
|
||||
// foreground-service start (background-restricted app), the
|
||||
// ring never happens and nothing surfaced it anywhere but
|
||||
// logcat, "as if there were no alarm at all".
|
||||
Log.e(TAG, "alarm.service start failed", error)
|
||||
if (!alarmId.isNullOrBlank()) {
|
||||
NativeSchedulingFailures.record(
|
||||
context,
|
||||
alarmId,
|
||||
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
);
|
||||
await _sincronizarTodas();
|
||||
await cargarDiagnostico();
|
||||
await cargarFallosNativos();
|
||||
_activarRefresco();
|
||||
} catch (e) {
|
||||
_error = 'No se pudieron cargar las alarmas: $e';
|
||||
@@ -228,10 +229,22 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
|
||||
/// Clears a previously recorded scheduling failure once a later attempt
|
||||
/// for the same alarm succeeds (D5-style recovery, mirroring how [_error]
|
||||
/// itself already clears on a successful retry).
|
||||
/// itself already clears on a successful retry). Type-scoped: a
|
||||
/// successful `android.programar` call only proves the MAIN alarm
|
||||
/// registration (and, transitively, that any stale post-boot reschedule
|
||||
/// failure no longer applies) -- it says nothing about the pre-notice or
|
||||
/// foreground-service subsystems, so those are left untouched here.
|
||||
Future<void> _limpiarFalloProgramacion(String alarmaId) async {
|
||||
try {
|
||||
final config = await servicio.limpiarFalloProgramacion(alarmaId);
|
||||
var config = await servicio.limpiarFalloProgramacion(
|
||||
alarmaId,
|
||||
ExcepcionAlarma.tipoFalloProgramacion,
|
||||
);
|
||||
_aplicar(config);
|
||||
config = await servicio.limpiarFalloProgramacion(
|
||||
alarmaId,
|
||||
ExcepcionAlarma.tipoFalloReprogramacionArranque,
|
||||
);
|
||||
_aplicar(config);
|
||||
} catch (e) {
|
||||
debugPrint('[PluriWave][alarmas] limpiar fallo programacion ERROR $e');
|
||||
@@ -525,6 +538,39 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Drains the failures the NATIVE side recorded on its own and turns each
|
||||
/// into a per-alarm exception, so the card can mark it.
|
||||
///
|
||||
/// These three paths used to log to logcat and stop there: a pre-notice
|
||||
/// that could not be armed, a refused foreground-service start when the
|
||||
/// alarm should have rung, and a per-alarm reschedule that failed after a
|
||||
/// reboot. None of them run inside a Dart call, so nothing on this side
|
||||
/// ever learned they happened — an alarm could sit switched on in the
|
||||
/// list having never reached the OS. Reading them at startup is what
|
||||
/// makes the reported "as if there were no alarm" visible.
|
||||
///
|
||||
/// Deliberately tolerant: a failed read is logged and swallowed, never
|
||||
/// surfaced as an alarm error, because a diagnostics gap must not look
|
||||
/// like a scheduling problem.
|
||||
Future<void> cargarFallosNativos() async {
|
||||
try {
|
||||
final fallos = await android.fallosNativosProgramacion();
|
||||
for (final fallo in fallos) {
|
||||
final alarmaId = fallo['alarmaId'] as String?;
|
||||
final tipo = fallo['tipo'] as String?;
|
||||
if (alarmaId == null || tipo == null) continue;
|
||||
await _registrarFalloProgramacion(alarmaId, tipo: tipo);
|
||||
}
|
||||
if (fallos.isNotEmpty) {
|
||||
debugPrint(
|
||||
'[PluriWave][alarmas] fallos nativos recogidos=${fallos.length}',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[PluriWave][alarmas] cargar fallos nativos ERROR $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a snooze the native layer performed by itself (Decision 2.1).
|
||||
/// The native scheduler already re-registered setAlarmClock, so this only
|
||||
/// persists the canonical state — it MUST NOT call android.programar again.
|
||||
@@ -624,6 +670,60 @@ class EstadoAlarmas extends ChangeNotifier {
|
||||
debugPrint('[PluriWave][alarmas] sincronizar nativas ERROR $e');
|
||||
}
|
||||
await _importarSnoozesNativosActivos();
|
||||
await _importarFallosProgramacionNativos();
|
||||
}
|
||||
|
||||
/// Cold-start sync (fix/alarmas-fallos-silenciosos, item 2): imports
|
||||
/// scheduling-reliability failures the NATIVE side recorded on its own --
|
||||
/// a pre-notice `SecurityException`, a refused foreground-service start,
|
||||
/// or a per-alarm reschedule failure after boot/unlock -- none of which
|
||||
/// ever go through a Dart method-channel call that could throw. Without
|
||||
/// this sync, these three failures stayed invisible forever (only
|
||||
/// logcat), even after this app-launch fix reads them.
|
||||
Future<void> _importarFallosProgramacionNativos() async {
|
||||
try {
|
||||
final fallos = await android.obtenerFallosProgramacionNativos();
|
||||
final reportadoPorAlarma = {
|
||||
for (final fallo in fallos) fallo.alarmaId: fallo,
|
||||
};
|
||||
// Reconcile stale copies: the native side clears its OWN record the
|
||||
// next time that specific subsystem succeeds (pre-notice/foreground-
|
||||
// service), so an alarm previously imported with one of those tipos
|
||||
// that is no longer reported here means it already recovered --
|
||||
// without this, the card would keep showing a problem that fixed
|
||||
// itself. `tipoFalloProgramacion`/`tipoFalloReprogramacionArranque`
|
||||
// are NOT reconciled here -- those already clear on the Dart side's
|
||||
// own successful `android.programar` calls.
|
||||
for (final alarma in _alarmas) {
|
||||
final actual = ultimaExcepcionPara(alarma.id);
|
||||
final esTipoReconciliable =
|
||||
actual != null &&
|
||||
(actual.tipo == ExcepcionAlarma.tipoFalloPreaviso ||
|
||||
actual.tipo == ExcepcionAlarma.tipoFalloServicioSonido);
|
||||
if (esTipoReconciliable && !reportadoPorAlarma.containsKey(alarma.id)) {
|
||||
final config = await servicio.limpiarFalloProgramacion(
|
||||
alarma.id,
|
||||
actual.tipo,
|
||||
);
|
||||
_aplicar(config);
|
||||
}
|
||||
}
|
||||
for (final fallo in fallos) {
|
||||
final config = await servicio.registrarFalloProgramacion(
|
||||
fallo.alarmaId,
|
||||
fallo.ocurridoEn,
|
||||
fallo.tipo,
|
||||
);
|
||||
_aplicar(config);
|
||||
}
|
||||
if (fallos.isNotEmpty) {
|
||||
debugPrint(
|
||||
'[PluriWave][alarmas] fallos nativos importados count=${fallos.length}',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[PluriWave][alarmas] importar fallos nativos ERROR $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Cold-start half of Decision 2.1: imports snoozes the native scheduler
|
||||
|
||||
@@ -378,18 +378,28 @@ class ServicioAlarmas {
|
||||
return nuevo;
|
||||
});
|
||||
|
||||
/// Clears any outstanding failure record for [alarmaId] (a subsequent
|
||||
/// scheduling attempt succeeded). No-op when there is nothing to clear.
|
||||
/// Clears the outstanding failure record for [alarmaId] ONLY when its
|
||||
/// current tipo is [tipo] (a subsequent attempt of THAT SPECIFIC kind
|
||||
/// succeeded). Type-scoped on purpose: a successful main-alarm schedule
|
||||
/// call proves nothing about the pre-notice or foreground-service
|
||||
/// subsystems, so it must never clear a failure recorded for those. No-op
|
||||
/// when there is nothing to clear or the recorded tipo does not match.
|
||||
Future<ConfiguracionAlarmas> limpiarFalloProgramacion(
|
||||
String alarmaId,
|
||||
String tipo,
|
||||
) => _enCola(() async {
|
||||
final config = await _configActual();
|
||||
final sinFallo = _sinFalloPrevio(config.excepciones, alarmaId);
|
||||
if (sinFallo.length == config.excepciones.length) return config;
|
||||
final actual = config.excepciones.where((e) => e.alarmaId == alarmaId);
|
||||
final tieneEseTipo = actual.any((e) => e.tipo == tipo);
|
||||
if (!tieneEseTipo) return config;
|
||||
final excepciones =
|
||||
config.excepciones
|
||||
.where((e) => !(e.alarmaId == alarmaId && e.tipo == tipo))
|
||||
.toList();
|
||||
final nuevo = ConfiguracionAlarmas(
|
||||
alarmas: config.alarmas,
|
||||
vacaciones: config.vacaciones,
|
||||
excepciones: sinFallo,
|
||||
excepciones: excepciones,
|
||||
);
|
||||
await _guardar(nuevo);
|
||||
return nuevo;
|
||||
@@ -398,13 +408,14 @@ class ServicioAlarmas {
|
||||
List<ExcepcionAlarma> _sinFalloPrevio(
|
||||
List<ExcepcionAlarma> excepciones,
|
||||
String alarmaId,
|
||||
) => excepciones
|
||||
.where(
|
||||
(e) =>
|
||||
!(e.alarmaId == alarmaId &&
|
||||
ExcepcionAlarma.tiposFallo.contains(e.tipo)),
|
||||
)
|
||||
.toList();
|
||||
) =>
|
||||
excepciones
|
||||
.where(
|
||||
(e) =>
|
||||
!(e.alarmaId == alarmaId &&
|
||||
ExcepcionAlarma.tiposFallo.contains(e.tipo)),
|
||||
)
|
||||
.toList();
|
||||
|
||||
Future<ConfiguracionAlarmas> posponerEjecucion(
|
||||
String alarmaId,
|
||||
|
||||
@@ -161,6 +161,35 @@ class EjecucionAlarmaNativa {
|
||||
}
|
||||
}
|
||||
|
||||
/// A scheduling-reliability failure the NATIVE side recorded on its own
|
||||
/// (fix/alarmas-fallos-silenciosos, item 2): the pre-notice reminder, the
|
||||
/// ringing foreground service, and a post-boot/unlock reschedule can each
|
||||
/// fail without ever going through a Dart method-channel call that could
|
||||
/// throw -- the native scheduler persists these instead (mirroring how
|
||||
/// handled occurrences and snooze state already survive a killed engine),
|
||||
/// and this is the cold-start sync so the Dart side finds out at all.
|
||||
class FalloProgramacionNativo {
|
||||
const FalloProgramacionNativo({
|
||||
required this.alarmaId,
|
||||
required this.tipo,
|
||||
required this.ocurridoEn,
|
||||
});
|
||||
|
||||
final String alarmaId;
|
||||
final String tipo;
|
||||
final DateTime ocurridoEn;
|
||||
|
||||
factory FalloProgramacionNativo.fromMap(Map<Object?, Object?> map) {
|
||||
return FalloProgramacionNativo(
|
||||
alarmaId: map['alarmId'] as String? ?? '',
|
||||
tipo: map['type'] as String? ?? '',
|
||||
ocurridoEn: DateTime.fromMillisecondsSinceEpoch(
|
||||
(map['atMillis'] as num?)?.toInt() ?? 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class PuertoAlarmasAndroid {
|
||||
Stream<EventoAlarmaAndroid> get eventosAlarma;
|
||||
|
||||
@@ -170,6 +199,17 @@ abstract class PuertoAlarmasAndroid {
|
||||
|
||||
Future<void> programar(AlarmaMusical alarma);
|
||||
Future<void> cancelar(String alarmaId);
|
||||
|
||||
/// Failures the NATIVE side recorded on its own, outside any Dart call:
|
||||
/// a pre-notice that could not be armed, a refused foreground-service
|
||||
/// start when the alarm should have rung, and a per-alarm reschedule that
|
||||
/// failed after a reboot. Each entry carries the alarm id and one of
|
||||
/// [ExcepcionAlarma]'s `tipoFallo*` constants.
|
||||
///
|
||||
/// Before this existed every one of those paths logged to logcat and
|
||||
/// stopped there, so an alarm could sit switched on in the list having
|
||||
/// never reached the OS at all — the user's "as if there were no alarm".
|
||||
Future<List<Map<String, Object?>>> fallosNativosProgramacion();
|
||||
Future<void> ocultarNotificacionAlarma(String alarmaId);
|
||||
|
||||
/// Notification-only dismissal (RES-1): hides the fire notification for
|
||||
@@ -203,6 +243,11 @@ abstract class PuertoAlarmasAndroid {
|
||||
Future<EventoAlarmaAndroid?> obtenerEventoInicial();
|
||||
Future<List<EjecucionAlarmaNativa>> obtenerEjecucionesNativasGestionadas();
|
||||
Future<List<EstadoSnoozeNativo>> obtenerEstadoSnoozeNativo();
|
||||
|
||||
/// Scheduling-reliability failures the native side recorded on its own
|
||||
/// (pre-notice, foreground-service start, or post-boot reschedule) since
|
||||
/// the last sync.
|
||||
Future<List<FalloProgramacionNativo>> obtenerFallosProgramacionNativos();
|
||||
}
|
||||
|
||||
class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||
@@ -393,6 +438,25 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Map<String, Object?>>> fallosNativosProgramacion() async {
|
||||
try {
|
||||
final raw = await _channel.invokeMethod<List<Object?>>(
|
||||
'getNativeSchedulingFailures',
|
||||
);
|
||||
if (raw == null) return const [];
|
||||
return raw
|
||||
.whereType<Map<Object?, Object?>>()
|
||||
.map((m) => m.map((k, v) => MapEntry(k.toString(), v)))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
// Never let a diagnostics read break alarm handling: an older build
|
||||
// of the native side simply has no such channel method.
|
||||
debugPrint('[PluriWave][alarmas] fallosNativosProgramacion ERROR $e');
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> solicitarPermisoAlarmasExactas() async {
|
||||
final abierto = await _channel.invokeMethod<bool>(
|
||||
@@ -494,6 +558,20 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<FalloProgramacionNativo>>
|
||||
obtenerFallosProgramacionNativos() async {
|
||||
final raw = await _channel.invokeMethod<List<Object?>>(
|
||||
'getNativeSchedulingFailures',
|
||||
);
|
||||
if (raw == null || raw.isEmpty) return const [];
|
||||
return raw
|
||||
.whereType<Map<Object?, Object?>>()
|
||||
.map(FalloProgramacionNativo.fromMap)
|
||||
.where((fallo) => fallo.alarmaId.isNotEmpty && fallo.tipo.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> _logAndInvokeVoid(String method, Map<String, Object?> args) {
|
||||
debugPrint('[PluriWave][alarmas] $method $args');
|
||||
return _channel.invokeMethod<void>(method, args);
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_alarmas.dart';
|
||||
import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// The native scheduler records three failures entirely on its own, outside
|
||||
/// any Dart call: a pre-notice that could not be armed, a refused
|
||||
/// foreground-service start when the alarm should have rung, and a per-alarm
|
||||
/// reschedule that failed after a reboot.
|
||||
///
|
||||
/// Before this wiring existed, all three logged to logcat and stopped there.
|
||||
/// The alarm stayed switched on in the list, drawn exactly as if scheduling
|
||||
/// had succeeded, and simply never fired — the reported "as if there were no
|
||||
/// alarm at all". These tests pin the drain path that makes them visible.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late FakePuertoAlarmasAndroid android;
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
android = FakePuertoAlarmasAndroid();
|
||||
});
|
||||
|
||||
EstadoAlarmas crearEstado() =>
|
||||
EstadoAlarmas(android: android, iniciarAutomaticamente: false);
|
||||
|
||||
test('a native pre-notice failure becomes a per-alarm exception', () async {
|
||||
android.fallosNativos = [
|
||||
{'alarmaId': 'a1', 'tipo': ExcepcionAlarma.tipoFalloPreaviso},
|
||||
];
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await estado.cargarFallosNativos();
|
||||
|
||||
final fallo = estado.ultimaExcepcionPara('a1');
|
||||
expect(fallo, isNotNull);
|
||||
expect(fallo!.tipo, ExcepcionAlarma.tipoFalloPreaviso);
|
||||
});
|
||||
|
||||
test(
|
||||
'a refused foreground-service start becomes a per-alarm exception',
|
||||
() async {
|
||||
android.fallosNativos = [
|
||||
{'alarmaId': 'a2', 'tipo': ExcepcionAlarma.tipoFalloServicioSonido},
|
||||
];
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await estado.cargarFallosNativos();
|
||||
|
||||
expect(
|
||||
estado.ultimaExcepcionPara('a2')?.tipo,
|
||||
ExcepcionAlarma.tipoFalloServicioSonido,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('only the alarm whose reschedule failed is marked', () async {
|
||||
android.fallosNativos = [
|
||||
{
|
||||
'alarmaId': 'a3',
|
||||
'tipo': ExcepcionAlarma.tipoFalloReprogramacionArranque,
|
||||
},
|
||||
];
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await estado.cargarFallosNativos();
|
||||
|
||||
expect(estado.ultimaExcepcionPara('a3'), isNotNull);
|
||||
expect(
|
||||
estado.ultimaExcepcionPara('a4'),
|
||||
isNull,
|
||||
reason: 'a sibling alarm that scheduled fine must stay unmarked',
|
||||
);
|
||||
});
|
||||
|
||||
test('malformed native entries are skipped without throwing', () async {
|
||||
android.fallosNativos = [
|
||||
{'tipo': ExcepcionAlarma.tipoFalloPreaviso}, // no alarmaId
|
||||
{'alarmaId': 'a5'}, // no tipo
|
||||
];
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await estado.cargarFallosNativos();
|
||||
|
||||
expect(estado.ultimaExcepcionPara('a5'), isNull);
|
||||
});
|
||||
|
||||
test('a failing native read never surfaces as an alarm error', () async {
|
||||
// A diagnostics gap must not look like a scheduling problem: an older
|
||||
// native build simply has no such channel method.
|
||||
android.fallaLecturaFallosNativos = true;
|
||||
final estado = crearEstado();
|
||||
addTearDown(estado.dispose);
|
||||
|
||||
await estado.cargarFallosNativos();
|
||||
|
||||
expect(estado.error, isNull);
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_alarmas.dart';
|
||||
import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas_android.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
@@ -73,4 +74,66 @@ void main() {
|
||||
expect(estado.ultimaExcepcionPara('sana1'), isNull);
|
||||
expect(estado.error, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'inicializar importa los fallos nativos (pre-aviso, servicio en primer '
|
||||
'plano, reprogramacion tras arranque) como excepciones por alarma',
|
||||
() async {
|
||||
final android =
|
||||
FakePuertoAlarmasAndroid()
|
||||
..fallosProgramacionNativos.addAll([
|
||||
FalloProgramacionNativo(
|
||||
alarmaId: 'con-preaviso-roto',
|
||||
tipo: ExcepcionAlarma.tipoFalloPreaviso,
|
||||
ocurridoEn: DateTime(2026, 5, 25, 7),
|
||||
),
|
||||
FalloProgramacionNativo(
|
||||
alarmaId: 'servicio-rechazado',
|
||||
tipo: ExcepcionAlarma.tipoFalloServicioSonido,
|
||||
ocurridoEn: DateTime(2026, 5, 25, 7),
|
||||
),
|
||||
]);
|
||||
final servicio = ServicioAlarmas(
|
||||
reloj: () => DateTime(2026, 5, 25, 7, 30),
|
||||
);
|
||||
await servicio.guardarAlarma(
|
||||
AlarmaMusical(
|
||||
id: 'con-preaviso-roto',
|
||||
nombre: 'Diaria',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: const [],
|
||||
),
|
||||
);
|
||||
await servicio.guardarAlarma(
|
||||
AlarmaMusical(
|
||||
id: 'servicio-rechazado',
|
||||
nombre: 'Diaria',
|
||||
hora: 8,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: const [],
|
||||
),
|
||||
);
|
||||
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: servicio,
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await estado.inicializar();
|
||||
|
||||
final falloPreaviso = estado.ultimaExcepcionPara('con-preaviso-roto');
|
||||
expect(falloPreaviso, isNotNull);
|
||||
expect(falloPreaviso!.tipo, ExcepcionAlarma.tipoFalloPreaviso);
|
||||
|
||||
final falloServicio = estado.ultimaExcepcionPara('servicio-rechazado');
|
||||
expect(falloServicio, isNotNull);
|
||||
expect(falloServicio!.tipo, ExcepcionAlarma.tipoFalloServicioSonido);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||
final soloOcultadas = <String>[];
|
||||
final ejecucionesNativas = <EjecucionAlarmaNativa>[];
|
||||
final snoozesNativos = <EstadoSnoozeNativo>[];
|
||||
final fallosProgramacionNativos = <FalloProgramacionNativo>[];
|
||||
final _eventos = StreamController<EventoAlarmaAndroid>.broadcast();
|
||||
bool ignoraOptimizacionBateria = true;
|
||||
int solicitudesExencionBateria = 0;
|
||||
@@ -186,9 +187,33 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||
Future<List<EstadoSnoozeNativo>> obtenerEstadoSnoozeNativo() async =>
|
||||
List.of(snoozesNativos);
|
||||
|
||||
@override
|
||||
Future<List<FalloProgramacionNativo>>
|
||||
obtenerFallosProgramacionNativos() async =>
|
||||
List.of(fallosProgramacionNativos);
|
||||
|
||||
int solicitudesPermisoAlarmasExactas = 0;
|
||||
int solicitudesPermisoPantallaCompleta = 0;
|
||||
|
||||
/// Native-recorded failures the next read should return. Tests seed this
|
||||
/// to simulate a pre-notice that never armed, a refused foreground-service
|
||||
/// start, or a per-alarm reschedule that failed after a reboot.
|
||||
List<Map<String, Object?>> fallosNativos = const [];
|
||||
|
||||
int lecturasFallosNativos = 0;
|
||||
|
||||
/// Simulates an older native build with no such channel method.
|
||||
bool fallaLecturaFallosNativos = false;
|
||||
|
||||
@override
|
||||
Future<List<Map<String, Object?>>> fallosNativosProgramacion() async {
|
||||
lecturasFallosNativos++;
|
||||
if (fallaLecturaFallosNativos) {
|
||||
throw StateError('canal no disponible');
|
||||
}
|
||||
return fallosNativos;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> solicitarPermisoAlarmasExactas() async {
|
||||
solicitudesPermisoAlarmasExactas++;
|
||||
|
||||
@@ -124,11 +124,42 @@ void main() {
|
||||
ExcepcionAlarma.tipoFalloProgramacion,
|
||||
);
|
||||
|
||||
final config = await servicio.limpiarFalloProgramacion('a1');
|
||||
final config = await servicio.limpiarFalloProgramacion(
|
||||
'a1',
|
||||
ExcepcionAlarma.tipoFalloProgramacion,
|
||||
);
|
||||
|
||||
expect(config.excepciones, isEmpty);
|
||||
});
|
||||
|
||||
test('limpiarFalloProgramacion NO limpia un fallo de un tipo distinto '
|
||||
'(cada subsistema se limpia por su cuenta)', () async {
|
||||
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
|
||||
await servicio.guardarAlarma(
|
||||
AlarmaMusical(
|
||||
id: 'a1',
|
||||
nombre: 'Diaria',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: const [],
|
||||
),
|
||||
);
|
||||
await servicio.registrarFalloProgramacion(
|
||||
'a1',
|
||||
DateTime(2026, 5, 25, 7, 30),
|
||||
ExcepcionAlarma.tipoFalloPreaviso,
|
||||
);
|
||||
|
||||
final config = await servicio.limpiarFalloProgramacion(
|
||||
'a1',
|
||||
ExcepcionAlarma.tipoFalloProgramacion,
|
||||
);
|
||||
|
||||
expect(config.excepciones, hasLength(1));
|
||||
expect(config.excepciones.single.tipo, ExcepcionAlarma.tipoFalloPreaviso);
|
||||
});
|
||||
|
||||
test('limpiarFalloProgramacion sin fallo previo no rompe y no persiste '
|
||||
'cambios', () async {
|
||||
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
|
||||
@@ -143,7 +174,10 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
final config = await servicio.limpiarFalloProgramacion('a1');
|
||||
final config = await servicio.limpiarFalloProgramacion(
|
||||
'a1',
|
||||
ExcepcionAlarma.tipoFalloProgramacion,
|
||||
);
|
||||
|
||||
expect(config.excepciones, isEmpty);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user