feat(alarms): add native ringing service
Build & Deploy Pluriwave / Análisis de código (push) Successful in 26s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m8s

This commit is contained in:
2026-05-22 20:02:09 +02:00
parent c8fff0d977
commit 3ab138a4fa
8 changed files with 437 additions and 90 deletions
+5
View File
@@ -49,6 +49,11 @@
</intent-filter>
</service>
<service
android:name=".PluriWaveAlarmService"
android:foregroundServiceType="mediaPlayback"
android:exported="false" />
<!-- Receptor de controles de media (auriculares, notificación) -->
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
@@ -13,7 +13,16 @@ class AlarmScheduler(private val context: Context) {
private val alarmManager =
context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
fun scheduleAlarm(id: String, title: String, triggerAtMillis: Long, preNoticeAtMillis: Long) {
fun scheduleAlarm(
id: String,
title: String,
triggerAtMillis: Long,
preNoticeAtMillis: Long,
stationName: String?,
stationUrl: String?,
fallbackSound: String?,
volume: Float
) {
Log.d(
tag,
"alarm.schedule id=$id title=$title triggerAtMillis=$triggerAtMillis preNoticeAtMillis=$preNoticeAtMillis canExact=${canScheduleExactAlarms()}"
@@ -25,6 +34,10 @@ class AlarmScheduler(private val context: Context) {
action = PluriWaveAlarmReceiver.ACTION_FIRE
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, id)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, title)
putExtra(PluriWaveAlarmReceiver.EXTRA_STATION_NAME, stationName)
putExtra(PluriWaveAlarmReceiver.EXTRA_STATION_URL, stationUrl)
putExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_SOUND, fallbackSound)
putExtra(PluriWaveAlarmReceiver.EXTRA_VOLUME, volume)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
@@ -64,12 +64,25 @@ class MainActivity : AudioServiceActivity() {
val title = call.argument<String>("title") ?: "PluriWave"
val triggerAtMillis = call.argument<Long>("triggerAtMillis")
val preNoticeAtMillis = call.argument<Long>("preNoticeAtMillis") ?: 0L
val stationName = call.argument<String>("stationName")
val stationUrl = call.argument<String>("stationUrl")
val fallbackSound = call.argument<String>("fallbackSound")
val volume = call.argument<Number>("volume")?.toFloat() ?: 0.85f
Log.d(tag, "alarm.channel scheduleAlarm id=$id triggerAtMillis=$triggerAtMillis preNoticeAtMillis=$preNoticeAtMillis")
if (id == null || triggerAtMillis == null) {
Log.w(tag, "alarm.channel scheduleAlarm invalid id=$id triggerAtMillis=$triggerAtMillis")
result.error("INVALID_ALARM", "Missing alarm id or trigger time", null)
} else {
alarmScheduler.scheduleAlarm(id, title, triggerAtMillis, preNoticeAtMillis)
alarmScheduler.scheduleAlarm(
id,
title,
triggerAtMillis,
preNoticeAtMillis,
stationName,
stationUrl,
fallbackSound,
volume
)
result.success(null)
}
}
@@ -79,6 +92,7 @@ class MainActivity : AudioServiceActivity() {
if (id == null) {
result.error("INVALID_ALARM", "Missing alarm id", null)
} else {
PluriWaveAlarmService.stop(this, id)
alarmScheduler.cancelAlarm(id)
result.success(null)
}
@@ -89,10 +103,21 @@ class MainActivity : AudioServiceActivity() {
if (id == null) {
result.error("INVALID_ALARM", "Missing alarm id", null)
} else {
PluriWaveAlarmService.stop(this, id)
alarmScheduler.dismissFireNotification(id)
result.success(null)
}
}
"stopNativeAlarmSound" -> {
val id = call.argument<String>("id")
Log.d(tag, "alarm.channel stopNativeAlarmSound id=$id")
if (id == null) {
result.error("INVALID_ALARM", "Missing alarm id", null)
} else {
PluriWaveAlarmService.stop(this, id)
result.success(null)
}
}
"diagnostics" -> {
Log.d(tag, "alarm.channel diagnostics")
result.success(
@@ -22,6 +22,7 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
when (intent.action) {
ACTION_FIRE -> {
PluriWaveAlarmService.start(context, intent)
val launch = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_ALARM_ID, alarmId)
@@ -187,6 +188,10 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
const val EXTRA_ALARM_ID = "alarmId"
const val EXTRA_ALARM_TITLE = "alarmTitle"
const val EXTRA_ALARM_ACTION = "alarmAction"
const val EXTRA_STATION_NAME = "stationName"
const val EXTRA_STATION_URL = "stationUrl"
const val EXTRA_FALLBACK_SOUND = "fallbackSound"
const val EXTRA_VOLUME = "volume"
fun notificationIdForAlarm(alarmId: String): Int = 53 * alarmId.hashCode() + 7
fun fireNotificationIdForAlarm(alarmId: String): Int = 59 * alarmId.hashCode() + 9
@@ -0,0 +1,260 @@
package es.freetimelab.pluriwave
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import java.io.File
class PluriWaveAlarmService : Service() {
private var player: MediaPlayer? = null
private var wakeLock: PowerManager.WakeLock? = null
private var activeAlarmId: String? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val action = intent?.action
val requestedId = intent?.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID)
Log.d(TAG, "alarm.service onStartCommand action=$action id=$requestedId active=$activeAlarmId")
when (action) {
ACTION_STOP -> {
stopAlarm(requestedId)
return START_NOT_STICKY
}
PluriWaveAlarmReceiver.ACTION_FIRE, null -> startAlarm(intent)
else -> Log.w(TAG, "alarm.service unknown action=$action id=$requestedId")
}
return START_NOT_STICKY
}
private fun startAlarm(intent: Intent?) {
val alarmId = intent?.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID) ?: return
if (activeAlarmId != null) {
Log.w(TAG, "alarm.service ignored id=$alarmId because active=$activeAlarmId")
return
}
activeAlarmId = alarmId
val title = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE) ?: "PluriWave"
val fallbackSound = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_SOUND)
val volume = intent.getFloatExtra(PluriWaveAlarmReceiver.EXTRA_VOLUME, 0.85f).coerceIn(0f, 1f)
acquireWakeLock()
startForeground(NOTIFICATION_ID, buildNotification(alarmId, title))
startAudio(alarmId, fallbackSound, volume)
}
private fun startAudio(alarmId: String, fallbackSound: String?, volume: Float) {
player?.release()
player = null
val source = fallbackAssetPath(fallbackSound)
try {
player = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ALARM)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
)
isLooping = true
setVolume(volume, volume)
setFallbackAssetDataSource(this, fallbackSound)
setOnPreparedListener {
it.start()
Log.d(TAG, "alarm.service audio started id=$alarmId source=$source")
}
setOnErrorListener { mp, what, extra ->
Log.e(TAG, "alarm.service audio error id=$alarmId what=$what extra=$extra source=$source")
mp.reset()
true
}
prepareAsync()
}
Log.d(TAG, "alarm.service audio preparing id=$alarmId source=$source")
} catch (error: Throwable) {
Log.e(TAG, "alarm.service audio prepare failed id=$alarmId source=$source", error)
}
}
private fun stopAlarm(alarmId: String?) {
Log.d(TAG, "alarm.service stop id=$alarmId active=$activeAlarmId")
try {
player?.stop()
} catch (error: Throwable) {
Log.w(TAG, "alarm.service stop player failed", error)
}
player?.release()
player = null
activeAlarmId = null
releaseWakeLock()
if (alarmId != null) {
NotificationManagerCompat.from(this).cancel(
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(alarmId)
)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
stopSelf()
}
private fun buildNotification(alarmId: String, title: String) =
NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
.setContentTitle("Alarma PluriWave")
.setContentText(title)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setOngoing(true)
.setAutoCancel(false)
.setFullScreenIntent(openAlarmPendingIntent(alarmId, title), true)
.setContentIntent(openAlarmPendingIntent(alarmId, title))
.addAction(0, "Detener", stopPendingIntent(alarmId))
.build()
private fun openAlarmPendingIntent(alarmId: String, title: String): PendingIntent =
PendingIntent.getActivity(
this,
requestCode(alarmId, 20),
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, title)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION, PluriWaveAlarmReceiver.ACTION_FIRE)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
private fun stopPendingIntent(alarmId: String): PendingIntent =
PendingIntent.getService(
this,
requestCode(alarmId, 21),
Intent(this, PluriWaveAlarmService::class.java).apply {
action = ACTION_STOP
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) return
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"PluriWave:AlarmWakeLock"
).apply {
setReferenceCounted(false)
acquire(10 * 60 * 1000L)
}
}
private fun releaseWakeLock() {
try {
if (wakeLock?.isHeld == true) wakeLock?.release()
} catch (error: Throwable) {
Log.w(TAG, "alarm.service wakeLock release failed", error)
}
wakeLock = null
}
private fun setFallbackAssetDataSource(mediaPlayer: MediaPlayer, sound: String?) {
val path = fallbackAssetPath(sound)
try {
val descriptor = assets.openFd(path)
mediaPlayer.setDataSource(
descriptor.fileDescriptor,
descriptor.startOffset,
descriptor.length
)
descriptor.close()
} catch (error: Throwable) {
Log.w(TAG, "alarm.service asset descriptor failed path=$path; copying to cache", error)
val cached = File(cacheDir, path.substringAfterLast('/'))
assets.open(path).use { input ->
cached.outputStream().use { output -> input.copyTo(output) }
}
mediaPlayer.setDataSource(cached.absolutePath)
}
}
private fun fallbackAssetPath(sound: String?): String {
val fileName = when (sound) {
"campanaSuave" -> "alarm_campana_suave.wav"
"pulsoDigital" -> "alarm_pulso_digital.wav"
else -> "alarm_amanecer.wav"
}
return "flutter_assets/assets/audio/$fileName"
}
override fun onDestroy() {
stopAlarm(activeAlarmId)
super.onDestroy()
}
companion object {
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"
fun start(context: Context, source: Intent) {
ensureChannel(context)
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_FIRE
putExtras(source)
}
try {
ContextCompat.startForegroundService(context, intent)
Log.d(TAG, "alarm.service start requested")
} catch (error: Throwable) {
Log.e(TAG, "alarm.service start failed", error)
}
}
fun stop(context: Context, alarmId: String) {
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
action = ACTION_STOP
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
}
try {
context.startService(intent)
} catch (error: Throwable) {
Log.e(TAG, "alarm.service stop request failed id=$alarmId", error)
}
}
private fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
val channel = NotificationChannel(
CHANNEL_ID,
"Alarma musical",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Sonido de alarma musical con pantalla apagada"
enableVibration(true)
}
manager.createNotificationChannel(channel)
}
private fun requestCode(id: String, slot: Int): Int = 67 * id.hashCode() + slot
}
}
+30
View File
@@ -0,0 +1,30 @@
# Arquitectura de alarmas con pantalla apagada
## Diagnóstico
El flujo anterior hacía que Android recibiese la alarma con `AlarmManager`, pero el sonido real dependía de que se abriese `MainActivity` y de que Flutter llegase a pintar `PantallaAlarmaSonando`. Con pantalla apagada, Doze o restricciones del fabricante, ese arranque de UI puede retrasarse hasta que el usuario enciende la pantalla.
## Decisión
La alarma debe sonar desde Android nativo en cuanto llega `ACTION_FIRE`. Flutter pasa a ser la interfaz de control para detener, posponer y hacer handoff a la radio de la app, pero no el único origen del sonido.
## Flujo recomendado
1. `AlarmScheduler` programa la alarma con `setAlarmClock` y fallback exact/inexact.
2. `PluriWaveAlarmReceiver` recibe `ACTION_FIRE`.
3. El receiver arranca `PluriWaveAlarmService` como foreground service.
4. El servicio toma un `PARTIAL_WAKE_LOCK`, muestra notificación foreground y reproduce audio con `USAGE_ALARM`.
5. La UI Flutter se abre por full-screen intent si Android lo permite.
6. Al detener/posponer desde Flutter, se manda comando nativo para parar el servicio.
## Referencias
- Android alarms: https://developer.android.com/develop/background-work/services/alarms
- Foreground service restrictions: https://developer.android.com/develop/background-work/services/fgs/restrictions-bg-start
- AOSP DeskClock AlarmService: https://android.googlesource.com/platform/packages/apps/DeskClock/+/ac260c0096605526f772af7eec73d6a51dc6de32/src/com/android/deskclock/alarms/AlarmService.java
## Notas
- El audio local interno es el fallback más fiable para pantalla apagada.
- La radio remota puede fallar por red, DNS, TLS o timeout; por eso debe existir fallback interno.
- Si un fabricante bloquea incluso servicios arrancados desde alarma, habrá que guiar al usuario con permisos de batería/autostart.
+90 -88
View File
@@ -214,9 +214,9 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context).skipCurrentAlarmExecution(
alarma.nombre,
),
AppLocalizations.of(
context,
).skipCurrentAlarmExecution(alarma.nombre),
),
),
);
@@ -249,6 +249,7 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
_alarmaSonandoId = alarma.id;
try {
await alarmas.android.detenerSonidoNativo(alarma.id);
await _prearrancarAudioAlarma(alarma);
if (!mounted) return;
await Navigator.of(context).push(
@@ -286,89 +287,96 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
showDragHandle: true,
builder:
(ctx) => Consumer<EstadoRadio>(
builder: (ctx, estado, _) => SafeArea(
child: Padding(
padding: PluriLayout.sheetPadding,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(ctx).sleepTimer,
style: Theme.of(ctx).textTheme.titleLarge,
),
const SizedBox(height: PluriLayout.sectionGap),
Text(
AppLocalizations.of(ctx).sleepTimerDescription,
style: Theme.of(ctx).textTheme.bodySmall,
),
const SizedBox(height: PluriLayout.panelGap),
if (estado.timer.activo)
StreamBuilder<Duration>(
stream: estado.timer.tiempoRestanteStream,
builder: (ctx, snap) {
final restante =
snap.data ?? estado.timer.tiempoRestante;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
builder:
(ctx, estado, _) => SafeArea(
child: Padding(
padding: PluriLayout.sheetPadding,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(ctx).sleepTimer,
style: Theme.of(ctx).textTheme.titleLarge,
),
const SizedBox(height: PluriLayout.sectionGap),
Text(
AppLocalizations.of(ctx).sleepTimerDescription,
style: Theme.of(ctx).textTheme.bodySmall,
),
const SizedBox(height: PluriLayout.panelGap),
if (estado.timer.activo)
StreamBuilder<Duration>(
stream: estado.timer.tiempoRestanteStream,
builder: (ctx, snap) {
final restante =
snap.data ?? estado.timer.tiempoRestante;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
_formatearDuracionTimer(restante),
style:
Theme.of(ctx).textTheme.headlineMedium,
),
const SizedBox(
height: PluriLayout.compactGap,
),
FilledButton.tonal(
onPressed: () {
estado.cancelarTimer();
Navigator.pop(ctx);
},
child: Text(
AppLocalizations.of(ctx).cancelTimer,
),
),
],
);
},
)
else
Wrap(
spacing: PluriLayout.compactGap,
runSpacing: PluriLayout.compactGap,
children: [
Text(
_formatearDuracionTimer(restante),
style: Theme.of(ctx).textTheme.headlineMedium,
),
const SizedBox(height: PluriLayout.compactGap),
FilledButton.tonal(
onPressed: () {
estado.cancelarTimer();
for (final segundos
in estado.timerSuenoPresetsSegundos)
ActionChip(
label: Text(
_formatearDuracionTimer(
Duration(seconds: segundos),
),
),
onPressed: () {
estado.iniciarTimerDuracion(
Duration(seconds: segundos),
);
Navigator.pop(ctx);
},
),
ActionChip(
avatar: const Icon(
Icons.tune_rounded,
size: 18,
),
label: Text(
AppLocalizations.of(ctx).optionOther,
),
onPressed: () async {
final duracion =
await _pedirDuracionPersonalizada(ctx);
if (duracion == null || !ctx.mounted) return;
estado.iniciarTimerDuracion(duracion);
Navigator.pop(ctx);
},
child: Text(
AppLocalizations.of(ctx).cancelTimer,
),
),
],
);
},
)
else
Wrap(
spacing: PluriLayout.compactGap,
runSpacing: PluriLayout.compactGap,
children: [
for (final segundos
in estado.timerSuenoPresetsSegundos)
ActionChip(
label: Text(
_formatearDuracionTimer(
Duration(seconds: segundos),
),
),
onPressed: () {
estado.iniciarTimerDuracion(
Duration(seconds: segundos),
);
Navigator.pop(ctx);
},
),
ActionChip(
avatar: const Icon(Icons.tune_rounded, size: 18),
label: Text(
AppLocalizations.of(ctx).optionOther,
),
onPressed: () async {
final duracion =
await _pedirDuracionPersonalizada(ctx);
if (duracion == null || !ctx.mounted) return;
estado.iniciarTimerDuracion(duracion);
Navigator.pop(ctx);
},
),
],
),
],
],
),
),
),
),
),
),
);
}
@@ -429,9 +437,7 @@ class _TimerPersonalizadoSheetState extends State<_TimerPersonalizadoSheet> {
if (duracion <= Duration.zero) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context).durationGreaterThanZero,
),
content: Text(AppLocalizations.of(context).durationGreaterThanZero),
),
);
return;
@@ -484,18 +490,14 @@ class _TimerPersonalizadoSheetState extends State<_TimerPersonalizadoSheet> {
const SizedBox(height: PluriLayout.compactGap),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: Text(
AppLocalizations.of(context).saveQuickAccess,
),
title: Text(AppLocalizations.of(context).saveQuickAccess),
value: _guardarPreset,
onChanged: (value) => setState(() => _guardarPreset = value),
),
const SizedBox(height: PluriLayout.sectionGap),
FilledButton.icon(
icon: const Icon(Icons.bedtime_rounded),
label: Text(
AppLocalizations.of(context).startTimer,
),
label: Text(AppLocalizations.of(context).startTimer),
onPressed: _confirmar,
),
],
@@ -80,6 +80,10 @@ class ServicioAlarmasAndroid {
'triggerAtMillis': proxima.millisecondsSinceEpoch,
'preNoticeAtMillis':
proxima.subtract(const Duration(minutes: 30)).millisecondsSinceEpoch,
'stationName': alarma.emisora?.nombre,
'stationUrl': alarma.emisora?.url,
'fallbackSound': alarma.sonidoInterno.name,
'volume': alarma.volumen,
});
}
@@ -89,6 +93,9 @@ class ServicioAlarmasAndroid {
Future<void> ocultarNotificacionAlarma(String alarmaId) =>
_logAndInvokeVoid('dismissAlarmNotification', {'id': alarmaId});
Future<void> detenerSonidoNativo(String alarmaId) =>
_logAndInvokeVoid('stopNativeAlarmSound', {'id': alarmaId});
Future<DiagnosticoAlarmasAndroid> diagnostico() async {
debugPrint('[PluriWave][alarmas] diagnostico android');
final raw = await _channel.invokeMethod<Map<Object?, Object?>>(