fix(alarms): harden native alarm lifecycle
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user