diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2479a05..51a6655 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -49,6 +49,11 @@ + + ("title") ?: "PluriWave" val triggerAtMillis = call.argument("triggerAtMillis") val preNoticeAtMillis = call.argument("preNoticeAtMillis") ?: 0L + val stationName = call.argument("stationName") + val stationUrl = call.argument("stationUrl") + val fallbackSound = call.argument("fallbackSound") + val volume = call.argument("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("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( 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 7394356..a22d6ea 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt @@ -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 diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt new file mode 100644 index 0000000..c64e7bb --- /dev/null +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt @@ -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 + } +} diff --git a/docs/alarmas-pantalla-apagada.md b/docs/alarmas-pantalla-apagada.md new file mode 100644 index 0000000..25a7825 --- /dev/null +++ b/docs/alarmas-pantalla-apagada.md @@ -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. diff --git a/lib/app.dart b/lib/app.dart index 420fb4f..8d08c94 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -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( - 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( - 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( + 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, ), ], diff --git a/lib/servicios/servicio_alarmas_android.dart b/lib/servicios/servicio_alarmas_android.dart index 5efa275..8bf5cf4 100644 --- a/lib/servicios/servicio_alarmas_android.dart +++ b/lib/servicios/servicio_alarmas_android.dart @@ -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 ocultarNotificacionAlarma(String alarmaId) => _logAndInvokeVoid('dismissAlarmNotification', {'id': alarmaId}); + Future detenerSonidoNativo(String alarmaId) => + _logAndInvokeVoid('stopNativeAlarmSound', {'id': alarmaId}); + Future diagnostico() async { debugPrint('[PluriWave][alarmas] diagnostico android'); final raw = await _channel.invokeMethod>(