From 028e2d69b1e06afd78998660515c6f71004f9c35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Bautista=20Fern=C3=A1ndez?= Date: Fri, 29 May 2026 13:13:39 +0200 Subject: [PATCH] fix(alarms): harden native alarm lifecycle --- .../freetimelab/pluriwave/AlarmScheduler.kt | 34 ++++++++++++ .../es/freetimelab/pluriwave/MainActivity.kt | 4 ++ .../pluriwave/PluriWaveAlarmReceiver.kt | 13 +++++ .../pluriwave/PluriWaveAlarmService.kt | 19 +++++-- lib/estado/estado_alarmas.dart | 40 ++++++++++++++ lib/servicios/servicio_alarmas.dart | 55 +++++++++++++++++++ lib/servicios/servicio_alarmas_android.dart | 38 +++++++++++++ test/estado/estado_alarmas_test.dart | 55 +++++++++++++++++++ 8 files changed, 254 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt index 4dfa6d0..093ce16 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt @@ -222,6 +222,7 @@ class AlarmScheduler(private val context: Context) { fun onAlarmFired(id: String) { val spec = readSpec(id) ?: return val firedAt = spec.snoozeOriginMillis ?: spec.triggerAtMillis + saveHandledOccurrence(id, firedAt) val next = spec.copy( snoozeUntilMillis = null, snoozeOriginMillis = null, @@ -288,6 +289,7 @@ class AlarmScheduler(private val context: Context) { fun cancelAlarm(id: String) { Log.d(tag, "alarm.cancel id=$id") removeScheduledAlarm(id) + removeHandledOccurrence(id) cancelPending("fire", pendingFireIntent(id, PendingIntent.FLAG_NO_CREATE)) cancelPending("show", pendingShowIntent(id, PendingIntent.FLAG_NO_CREATE)) cancelPending("preNotice", pendingPreNoticeIntent(id, PendingIntent.FLAG_NO_CREATE)) @@ -325,6 +327,18 @@ class AlarmScheduler(private val context: Context) { fun pendingAlarmCount(): Int = prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().size + fun handledOccurrences(): List> = + prefs().getStringSet(KEY_HANDLED_IDS, emptySet()).orEmpty() + .mapNotNull { id -> + val handledAt = prefs().getLong("$KEY_HANDLED_PREFIX$id", 0L) + .takeIf { it > 0L } + ?: return@mapNotNull null + mapOf( + "alarmId" to id, + "handledAtMillis" to handledAt + ) + } + private fun preserveNativeSnooze( existing: NativeAlarmSpec?, requestedTriggerAtMillis: Long, @@ -438,6 +452,24 @@ class AlarmScheduler(private val context: Context) { .apply() } + private fun saveHandledOccurrence(id: String, handledAtMillis: Long) { + val ids = prefs().getStringSet(KEY_HANDLED_IDS, emptySet()).orEmpty().toMutableSet() + ids.add(id) + prefs().edit() + .putStringSet(KEY_HANDLED_IDS, ids) + .putLong("$KEY_HANDLED_PREFIX$id", handledAtMillis) + .apply() + } + + private fun removeHandledOccurrence(id: String) { + val ids = prefs().getStringSet(KEY_HANDLED_IDS, emptySet()).orEmpty().toMutableSet() + ids.remove(id) + prefs().edit() + .putStringSet(KEY_HANDLED_IDS, ids) + .remove("$KEY_HANDLED_PREFIX$id") + .apply() + } + private fun prefs() = appContext.createDeviceProtectedStorageContext() .getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -619,6 +651,8 @@ class AlarmScheduler(private val context: Context) { private const val PREFS = "pluriwave_alarm_scheduler" private const val KEY_IDS = "scheduled_alarm_ids" private const val KEY_ALARM_PREFIX = "scheduled_alarm_" + private const val KEY_HANDLED_IDS = "handled_alarm_ids" + private const val KEY_HANDLED_PREFIX = "handled_alarm_" private const val PRE_NOTICE_MILLIS = 30 * 60 * 1000L private const val SCHEDULE_UNICA = "unica" private const val SCHEDULE_DIAS_SEMANA = "diasSemana" diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt index 5d0ac6f..86d6fd7 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt @@ -178,6 +178,10 @@ class MainActivity : AudioServiceActivity() { result.success(payload) intent?.removeExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION) } + "getHandledAlarmOccurrences" -> { + Log.d(tag, "alarm.channel getHandledAlarmOccurrences") + result.success(alarmScheduler.handledOccurrences()) + } else -> result.notImplemented() } } diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt index 4cd8196..59ddcc5 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt @@ -112,11 +112,13 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() { .setContentText(title) .setCategory(NotificationCompat.CATEGORY_ALARM) .setPriority(NotificationCompat.PRIORITY_MAX) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setOngoing(true) .setAutoCancel(false) .setContentIntent(fullScreenIntent) .setFullScreenIntent(fullScreenIntent, true) .addAction(0, "Posponer $snoozeMinutes min", snoozePendingIntent(context, alarmId, snoozeMinutes)) + .addAction(0, "Detener", stopPendingIntent(context, alarmId)) .build() try { @@ -250,6 +252,17 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) + private fun stopPendingIntent(context: Context, alarmId: String): PendingIntent = + PendingIntent.getService( + context, + requestCode(alarmId, 40), + Intent(context, PluriWaveAlarmService::class.java).apply { + action = PluriWaveAlarmService.ACTION_STOP + putExtra(EXTRA_ALARM_ID, alarmId) + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + companion object { const val TAG = "PluriWave" const val CHANNEL_ID = "pluriwave_alarm_pre_notice" diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt index 25be508..e28a53f 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt @@ -8,6 +8,7 @@ import android.content.Context import android.content.Intent import android.media.AudioAttributes import android.media.MediaPlayer +import android.net.Uri import android.os.Build import android.os.Handler import android.os.IBinder @@ -119,7 +120,11 @@ class PluriWaveAlarmService : Service() { setAudioAttributes(alarmAudioAttributes()) isLooping = false setVolume(volume, volume) - setDataSource(stationUrl) + setDataSource( + this@PluriWaveAlarmService, + Uri.parse(stationUrl), + mapOf("User-Agent" to "PluriWave/0.1.0 (native alarm)") + ) setOnPreparedListener { if (activeAlarmId != alarmId) return@setOnPreparedListener cancelStationFallback() @@ -256,6 +261,7 @@ class PluriWaveAlarmService : Service() { ) .setCategory(NotificationCompat.CATEGORY_ALARM) .setPriority(NotificationCompat.PRIORITY_MAX) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setOngoing(true) .setAutoCancel(false) .setFullScreenIntent(openAlarmPendingIntent(alarmId, title, snoozeMinutes), true) @@ -367,7 +373,7 @@ class PluriWaveAlarmService : Service() { private const val TAG = "PluriWave" private const val CHANNEL_ID = "pluriwave_alarm_native" private const val NOTIFICATION_ID = 92841 - private const val ACTION_STOP = "es.freetimelab.pluriwave.alarm.STOP_NATIVE" + const val ACTION_STOP = "es.freetimelab.pluriwave.alarm.STOP_NATIVE" const val ACTION_SNOOZE = "es.freetimelab.pluriwave.alarm.SNOOZE_NATIVE" const val EXTRA_SNOOZE_MINUTES = "snoozeMinutes" private const val STATION_START_TIMEOUT_MILLIS = 15_000L @@ -392,10 +398,15 @@ class PluriWaveAlarmService : Service() { putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId) } try { - context.stopService(intent) - Log.d(TAG, "alarm.service stop requested id=$alarmId") + context.startService(intent) + Log.d(TAG, "alarm.service stop action requested id=$alarmId") } catch (error: Throwable) { Log.e(TAG, "alarm.service stop request failed id=$alarmId", error) + try { + context.stopService(intent) + } catch (fallbackError: Throwable) { + Log.e(TAG, "alarm.service stop fallback failed id=$alarmId", fallbackError) + } } } diff --git a/lib/estado/estado_alarmas.dart b/lib/estado/estado_alarmas.dart index f969804..bb1324a 100644 --- a/lib/estado/estado_alarmas.dart +++ b/lib/estado/estado_alarmas.dart @@ -58,6 +58,7 @@ class EstadoAlarmas extends ChangeNotifier { _error = null; notifyListeners(); try { + await _sincronizarEjecucionesGestionadasPorAndroid(); final config = await servicio.recalcularTodas(); _aplicar(config); debugPrint( @@ -83,6 +84,7 @@ class EstadoAlarmas extends ChangeNotifier { _aplicar(config); try { final guardada = _alarmas.firstWhere((a) => a.id == alarma.id); + await _solicitarPermisosNecesariosParaAlarma(); debugPrint( '[PluriWave][alarmas] guardada id=${guardada.id} proxima=${guardada.proximaEjecucion?.toIso8601String()}', ); @@ -246,10 +248,48 @@ class EstadoAlarmas extends ChangeNotifier { notifyListeners(); } + Future _sincronizarEjecucionesGestionadasPorAndroid() async { + try { + final ejecuciones = await android.obtenerEjecucionesNativasGestionadas(); + if (ejecuciones.isEmpty) return; + final config = await servicio.sincronizarEjecucionesNativas({ + for (final ejecucion in ejecuciones) + ejecucion.alarmaId: ejecucion.gestionadaEn, + }); + _aplicar(config); + debugPrint( + '[PluriWave][alarmas] sincronizadas ejecuciones nativas count=${ejecuciones.length}', + ); + } catch (e) { + debugPrint('[PluriWave][alarmas] sincronizar nativas ERROR $e'); + } + } + + Future _solicitarPermisosNecesariosParaAlarma() async { + try { + final diag = await android.diagnostico(); + _diagnostico = diag; + if (!diag.puedeProgramarExactas) { + await android.solicitarPermisoAlarmasExactas(); + } + if (!diag.notificacionesPermitidas) { + await android.solicitarPermisoNotificaciones(); + } + if (!diag.puedeUsarPantallaCompleta) { + await android.solicitarPermisoPantallaCompleta(); + } + } catch (e) { + debugPrint('[PluriWave][alarmas] permisos android ERROR $e'); + } + } + Future _sincronizarTodas() async { debugPrint( '[PluriWave][alarmas] sincronizar todas count=${_alarmas.length}', ); + if (_alarmas.any((alarma) => alarma.activa)) { + await _solicitarPermisosNecesariosParaAlarma(); + } for (final alarma in _alarmas) { await android.programar(alarma); } diff --git a/lib/servicios/servicio_alarmas.dart b/lib/servicios/servicio_alarmas.dart index 03e1fd2..4a9e9d2 100644 --- a/lib/servicios/servicio_alarmas.dart +++ b/lib/servicios/servicio_alarmas.dart @@ -171,6 +171,61 @@ class ServicioAlarmas { return nuevo; } + Future sincronizarEjecucionesNativas( + Map ejecuciones, + ) async { + if (ejecuciones.isEmpty) return cargar(); + + final config = await cargar(); + final ahora = _reloj(); + var huboCambios = false; + final alarmas = + config.alarmas.map((alarma) { + final gestionadaEn = ejecuciones[alarma.id]; + if (gestionadaEn == null) return alarma; + final ultima = alarma.ultimaEjecucionGestionada; + if (ultima != null && !gestionadaEn.isAfter(ultima)) return alarma; + + final proxima = alarma.proximaProgramable; + if (proxima != null && + proxima.isAfter( + gestionadaEn.add( + ServicioProgramacionAlarmas.toleranciaDisparoInminente, + ), + )) { + return alarma; + } + + final siguiente = _programacion.calcularSiguienteDespuesDeEjecucion( + alarma: alarma, + ejecucion: gestionadaEn, + vacaciones: config.vacaciones, + excepciones: config.excepciones, + ); + huboCambios = true; + return alarma.copyWith( + activa: + alarma.tipoProgramacion == TipoProgramacionAlarma.unica + ? false + : alarma.activa, + proximaEjecucion: siguiente, + limpiarProximaEjecucion: true, + limpiarSnooze: true, + ultimaEjecucionGestionada: gestionadaEn, + actualizadaEn: ahora, + ); + }).toList(); + + if (!huboCambios) return config; + final nuevo = ConfiguracionAlarmas( + alarmas: alarmas, + vacaciones: config.vacaciones, + excepciones: config.excepciones, + ); + await _guardar(nuevo); + return nuevo; + } + Future saltarProxima(String alarmaId) async { final config = await cargar(); final alarma = config.alarmas.firstWhere((a) => a.id == alarmaId); diff --git a/lib/servicios/servicio_alarmas_android.dart b/lib/servicios/servicio_alarmas_android.dart index 0b7a951..1a10778 100644 --- a/lib/servicios/servicio_alarmas_android.dart +++ b/lib/servicios/servicio_alarmas_android.dart @@ -68,6 +68,25 @@ class DiagnosticoAlarmasAndroid { } } +class EjecucionAlarmaNativa { + const EjecucionAlarmaNativa({ + required this.alarmaId, + required this.gestionadaEn, + }); + + final String alarmaId; + final DateTime gestionadaEn; + + factory EjecucionAlarmaNativa.fromMap(Map map) { + return EjecucionAlarmaNativa( + alarmaId: map['alarmId'] as String? ?? '', + gestionadaEn: DateTime.fromMillisecondsSinceEpoch( + (map['handledAtMillis'] as num?)?.toInt() ?? 0, + ), + ); + } +} + abstract class PuertoAlarmasAndroid { Stream get eventosAlarma; @@ -81,6 +100,7 @@ abstract class PuertoAlarmasAndroid { Future confirmarAudioFlutter(String alarmaId); Future diagnostico(); Future obtenerEventoInicial(); + Future> obtenerEjecucionesNativasGestionadas(); } class ServicioAlarmasAndroid implements PuertoAlarmasAndroid { @@ -208,6 +228,24 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid { return evento.alarmaId.isEmpty ? null : evento; } + @override + Future> + obtenerEjecucionesNativasGestionadas() async { + final raw = await _channel.invokeMethod>( + 'getHandledAlarmOccurrences', + ); + if (raw == null || raw.isEmpty) return const []; + return raw + .whereType>() + .map(EjecucionAlarmaNativa.fromMap) + .where( + (evento) => + evento.alarmaId.isNotEmpty && + evento.gestionadaEn.millisecondsSinceEpoch > 0, + ) + .toList(); + } + Future _logAndInvokeVoid(String method, Map args) { debugPrint('[PluriWave][alarmas] $method $args'); return _channel.invokeMethod(method, args); diff --git a/test/estado/estado_alarmas_test.dart b/test/estado/estado_alarmas_test.dart index 39234ec..8a338c3 100644 --- a/test/estado/estado_alarmas_test.dart +++ b/test/estado/estado_alarmas_test.dart @@ -143,6 +143,56 @@ void main() { expect(estado.alarmas.single.activa, isFalse); expect(estado.alarmas.single.proximaEjecucion, isNull); }); + + test( + 'inicializar sincroniza ejecucion nativa y evita reprogramar al instante', + () async { + final android = FakePuertoAlarmasAndroid() + ..ejecucionesNativas.add( + EjecucionAlarmaNativa( + alarmaId: 'native1', + gestionadaEn: DateTime(2026, 5, 25, 7, 30), + ), + ); + final servicio = ServicioAlarmas( + reloj: () => DateTime(2026, 5, 25, 7, 30, 20), + ); + await servicio.guardarAlarma( + AlarmaMusical( + id: 'native1', + nombre: 'Nativa', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + proximaEjecucion: DateTime(2026, 5, 25, 7, 30), + ), + ); + + final estado = EstadoAlarmas( + servicio: servicio, + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + + await estado.inicializar(); + + expect( + estado.alarmas.single.ultimaEjecucionGestionada, + DateTime(2026, 5, 25, 7, 30), + ); + expect( + estado.alarmas.single.proximaEjecucion, + DateTime(2026, 5, 26, 7, 30), + ); + expect( + android.programadas.last.proximaProgramable, + DateTime(2026, 5, 26, 7, 30), + ); + }, + ); } class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { @@ -150,6 +200,7 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { final canceladas = []; final detenidas = []; final ocultadas = []; + final ejecucionesNativas = []; final _eventos = StreamController.broadcast(); @override @@ -195,6 +246,10 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { @override Future obtenerEventoInicial() async => null; + @override + Future> + obtenerEjecucionesNativasGestionadas() async => ejecucionesNativas; + @override Future solicitarPermisoAlarmasExactas() async => true;