fix(alarms): harden native alarm lifecycle
Build & Deploy PluriWave / Build APK + AAB release (push) Has been cancelled
Build & Deploy PluriWave / Análisis de código (push) Has been cancelled

This commit is contained in:
Javier Bautista Fernández
2026-05-29 13:13:39 +02:00
parent 8f6124fc1a
commit 028e2d69b1
8 changed files with 254 additions and 4 deletions
@@ -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<Map<String, Any>> =
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"
@@ -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()
}
}
@@ -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"
@@ -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)
}
}
}
+40
View File
@@ -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<void> _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<void> _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<void> _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);
}
+55
View File
@@ -171,6 +171,61 @@ class ServicioAlarmas {
return nuevo;
}
Future<ConfiguracionAlarmas> sincronizarEjecucionesNativas(
Map<String, DateTime> 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<ConfiguracionAlarmas> saltarProxima(String alarmaId) async {
final config = await cargar();
final alarma = config.alarmas.firstWhere((a) => a.id == alarmaId);
@@ -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<Object?, Object?> map) {
return EjecucionAlarmaNativa(
alarmaId: map['alarmId'] as String? ?? '',
gestionadaEn: DateTime.fromMillisecondsSinceEpoch(
(map['handledAtMillis'] as num?)?.toInt() ?? 0,
),
);
}
}
abstract class PuertoAlarmasAndroid {
Stream<EventoAlarmaAndroid> get eventosAlarma;
@@ -81,6 +100,7 @@ abstract class PuertoAlarmasAndroid {
Future<void> confirmarAudioFlutter(String alarmaId);
Future<DiagnosticoAlarmasAndroid> diagnostico();
Future<EventoAlarmaAndroid?> obtenerEventoInicial();
Future<List<EjecucionAlarmaNativa>> obtenerEjecucionesNativasGestionadas();
}
class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
@@ -208,6 +228,24 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
return evento.alarmaId.isEmpty ? null : evento;
}
@override
Future<List<EjecucionAlarmaNativa>>
obtenerEjecucionesNativasGestionadas() async {
final raw = await _channel.invokeMethod<List<Object?>>(
'getHandledAlarmOccurrences',
);
if (raw == null || raw.isEmpty) return const [];
return raw
.whereType<Map<Object?, Object?>>()
.map(EjecucionAlarmaNativa.fromMap)
.where(
(evento) =>
evento.alarmaId.isNotEmpty &&
evento.gestionadaEn.millisecondsSinceEpoch > 0,
)
.toList();
}
Future<void> _logAndInvokeVoid(String method, Map<String, Object?> args) {
debugPrint('[PluriWave][alarmas] $method $args');
return _channel.invokeMethod<void>(method, args);
+55
View File
@@ -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 = <String>[];
final detenidas = <String>[];
final ocultadas = <String>[];
final ejecucionesNativas = <EjecucionAlarmaNativa>[];
final _eventos = StreamController<EventoAlarmaAndroid>.broadcast();
@override
@@ -195,6 +246,10 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
@override
Future<EventoAlarmaAndroid?> obtenerEventoInicial() async => null;
@override
Future<List<EjecucionAlarmaNativa>>
obtenerEjecucionesNativasGestionadas() async => ejecucionesNativas;
@override
Future<bool> solicitarPermisoAlarmasExactas() async => true;