Files
pluriwave/lib/servicios/servicio_alarmas_android.dart
T
FreeTLab acd903d9a8
Build & Deploy PluriWave / Análisis de código (push) Successful in 41s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m39s
feat(alarm): make the ring immune to device media volume
The alarm's steady-state audio runs on the Flutter media-stream
player after the native handoff, so device volume 0 silenced it
entirely. The ring now forces STREAM_MUSIC to an audible reference:
Dart requests the override before pre-starting alarm audio (fallback
WAV included), Kotlin captures the current volume once and restores
it idempotently on every exit path (dismiss, snooze, dispose), with
a native best-effort backstop in service teardown.

The backstop is handoff-aware via PluriWaveAlarmService.flutterOwnsRing:
confirmFlutterAudio marks the handoff before triggering the native
stop, so the backstop cannot restore the volume mid-ring right as the
Flutter player takes over (that would re-silence the alarm at volume
0). The flag resets at every ring start; Flutter process death after
handoff remains a documented best-effort gap.

The alarm's perceived loudness keeps ramping 5% to the configured
volume through the player as before; normal radio playback and call
ducking never touch the override.

Work unit 2/3 of alarm-volume-ramp-restore (ring volume override).
2026-07-11 09:15:37 +02:00

422 lines
15 KiB
Dart

import 'dart:async';
import 'dart:ui' show Locale;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/alarma_musical.dart';
class EventoAlarmaAndroid {
const EventoAlarmaAndroid({
required this.alarmaId,
required this.titulo,
required this.accion,
this.triggerAtMillis = 0,
this.occurrenceAtMillis = 0,
this.snoozeMinutes = 5,
this.snoozeUntilMillis = 0,
});
/// Action reported when the native service snoozed an alarm by itself
/// (notification "Posponer" while the app may be backgrounded/killed).
static const accionSnoozed = 'snoozed';
/// Action reported when a pending snooze was cancelled natively from the
/// countdown notification ("Detener" while the app may be killed).
static const accionSnoozeCancelled = 'snoozeCancelled';
final String alarmaId;
final String titulo;
final String accion;
final int triggerAtMillis;
final int occurrenceAtMillis;
final int snoozeMinutes;
final int snoozeUntilMillis;
factory EventoAlarmaAndroid.fromMap(Map<Object?, Object?> map) {
return EventoAlarmaAndroid(
alarmaId: map['alarmId'] as String? ?? '',
titulo: map['alarmTitle'] as String? ?? 'PluriWave',
accion: map['alarmAction'] as String? ?? '',
triggerAtMillis: (map['triggerAtMillis'] as num?)?.toInt() ?? 0,
occurrenceAtMillis: (map['occurrenceAtMillis'] as num?)?.toInt() ?? 0,
snoozeMinutes: (map['snoozeMinutes'] as num?)?.toInt() ?? 5,
snoozeUntilMillis: (map['snoozeUntilMillis'] as num?)?.toInt() ?? 0,
);
}
}
/// Active native snooze persisted by `AlarmScheduler` (Kotlin). Used on cold
/// start so Flutter (single source of truth) can import snoozes performed
/// while the engine was dead.
class EstadoSnoozeNativo {
const EstadoSnoozeNativo({
required this.alarmaId,
required this.snoozeHasta,
required this.snoozeOrigen,
});
final String alarmaId;
final DateTime snoozeHasta;
final DateTime snoozeOrigen;
factory EstadoSnoozeNativo.fromMap(Map<Object?, Object?> map) {
return EstadoSnoozeNativo(
alarmaId: map['alarmId'] as String? ?? '',
snoozeHasta: DateTime.fromMillisecondsSinceEpoch(
(map['snoozeUntilMillis'] as num?)?.toInt() ?? 0,
),
snoozeOrigen: DateTime.fromMillisecondsSinceEpoch(
(map['snoozeOriginMillis'] as num?)?.toInt() ?? 0,
),
);
}
}
class DiagnosticoAlarmasAndroid {
const DiagnosticoAlarmasAndroid({
required this.puedeProgramarExactas,
required this.notificacionesPermitidas,
required this.puedeUsarPantallaCompleta,
required this.ignoraOptimizacionBateria,
required this.alarmasNativasPendientes,
required this.fabricante,
required this.versionSdk,
});
final bool puedeProgramarExactas;
final bool notificacionesPermitidas;
final bool puedeUsarPantallaCompleta;
final bool ignoraOptimizacionBateria;
final int alarmasNativasPendientes;
final String fabricante;
final int versionSdk;
factory DiagnosticoAlarmasAndroid.fromMap(Map<Object?, Object?> map) {
return DiagnosticoAlarmasAndroid(
puedeProgramarExactas: map['canScheduleExactAlarms'] as bool? ?? true,
notificacionesPermitidas: map['notificationsEnabled'] as bool? ?? true,
puedeUsarPantallaCompleta: map['canUseFullScreenIntent'] as bool? ?? true,
ignoraOptimizacionBateria:
map['isIgnoringBatteryOptimizations'] as bool? ?? true,
alarmasNativasPendientes: map['nativePendingAlarmsCount'] as int? ?? 0,
fabricante: map['manufacturer'] as String? ?? 'Android',
versionSdk: map['sdkInt'] as int? ?? 0,
);
}
}
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;
/// Provides the UI localizations used to localize the alarm/station names
/// sent to the native scheduler.
void configurarLocalizaciones(AppLocalizations l10n);
Future<void> programar(AlarmaMusical alarma);
Future<void> cancelar(String alarmaId);
Future<void> ocultarNotificacionAlarma(String alarmaId);
Future<void> detenerSonidoNativo(String alarmaId);
Future<bool> solicitarPermisoAlarmasExactas();
Future<bool> solicitarPermisoNotificaciones();
Future<bool> solicitarPermisoPantallaCompleta();
Future<bool> solicitarExencionBateria();
Future<void> confirmarAudioFlutter(String alarmaId);
/// Forces `STREAM_MUSIC` to the fixed audible reference level for the
/// duration of an alarm ring so the alarm cannot be silenced by a device
/// media volume of 0 (Requirement: Ring-scoped device-volume override).
/// [fraccion] is reserved for future tuning; the current native
/// implementation always targets the device max regardless of its value.
Future<void> forzarVolumenMediaParaAlarma(double fraccion);
/// Restores `STREAM_MUSIC` to the value captured by
/// [forzarVolumenMediaParaAlarma]. Idempotent: safe to call even when no
/// override is active or it was already restored.
Future<void> restaurarVolumenMedia();
Future<DiagnosticoAlarmasAndroid> diagnostico();
Future<EventoAlarmaAndroid?> obtenerEventoInicial();
Future<List<EjecucionAlarmaNativa>> obtenerEjecucionesNativasGestionadas();
Future<List<EstadoSnoozeNativo>> obtenerEstadoSnoozeNativo();
}
class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
ServicioAlarmasAndroid({
MethodChannel channel = const MethodChannel('pluriwave/alarm_scheduler'),
}) : _channel = channel {
_instalarHandler();
}
final MethodChannel _channel;
// Instance state (S3-R2): each bridge owns its controller and l10n so
// independent instances never share events through globals.
final _eventosController = StreamController<EventoAlarmaAndroid>.broadcast();
AppLocalizations? _l10n;
AppLocalizations get _textos {
final actual = _l10n;
if (actual != null) return actual;
return lookupAppLocalizations(const Locale('es'));
}
@override
void configurarLocalizaciones(AppLocalizations l10n) {
_l10n = l10n;
// Push every localized notification/channel/chooser string to the native
// side so it can build localized notifications even when the Flutter engine
// is dead (alarm fired from a killed app). Fire-and-forget; runs once per
// locale change (callers guard against per-rebuild churn).
unawaited(_enviarTextosNotificacion(l10n));
}
Future<void> _enviarTextosNotificacion(AppLocalizations l10n) async {
try {
await _channel.invokeMethod<void>('setNotificationStrings', {
'ringTitle': l10n.alarmRingingNotificationTitle,
'snoozeLabel': l10n.snoozeAction,
'stopLabel': l10n.stopAlarmAction,
'skipLabel': l10n.skipNextAction,
'snoozeAgainLabel': l10n.snoozeAgainAction,
'fireChannelName': l10n.alarmFireChannelName,
'fireChannelDescription': l10n.alarmFireChannelDescription,
'preNoticeChannelName': l10n.alarmPreNoticeChannelName,
'preNoticeChannelDescription': l10n.alarmPreNoticeChannelDescription,
'preNoticeTemplate': _plantillaMinutos(l10n.preNoticeCountdown),
'snoozeCountdownTemplate': _plantillaMinutos(l10n.snoozeCountdown),
'openFolderTitle': l10n.openFolderChooserTitle,
'openRecordingTitle': l10n.openRecordingChooserTitle,
});
} catch (e) {
debugPrint('[PluriWave][alarmas] setNotificationStrings ERROR $e');
}
}
/// Turns a localized `{int} -> String` message into a template with a literal
/// `{minutes}` placeholder for Kotlin to fill at fire time: it calls the
/// message with a unique sentinel and swaps the sentinel back for `{minutes}`.
static String _plantillaMinutos(String Function(int) traducir) {
const sentinel = 42424242;
return traducir(sentinel).replaceFirst(sentinel.toString(), '{minutes}');
}
@override
Stream<EventoAlarmaAndroid> get eventosAlarma => _eventosController.stream;
@override
Future<void> programar(AlarmaMusical alarma) async {
final proxima = alarma.proximaProgramable;
if (proxima == null || !alarma.activa) {
debugPrint(
'[PluriWave][alarmas] cancelar por inactiva/sin proxima id=${alarma.id} activa=${alarma.activa} proxima=$proxima',
);
await cancelar(alarma.id);
return;
}
debugPrint(
'[PluriWave][alarmas] programar id=${alarma.id} nombre=${alarma.nombre} proxima=${proxima.toIso8601String()} preaviso=${proxima.subtract(const Duration(minutes: 30)).toIso8601String()}',
);
final programada = await _channel.invokeMethod<bool>('scheduleAlarm', {
'id': alarma.id,
'title': localizedAlarmName(_textos, alarma.nombre),
'triggerAtMillis': proxima.millisecondsSinceEpoch,
'preNoticeAtMillis':
alarma.snoozeHasta == null
? proxima
.subtract(const Duration(minutes: 30))
.millisecondsSinceEpoch
: 0,
'hour': alarma.hora,
'minute': alarma.minuto,
'scheduleType': alarma.tipoProgramacion.name,
'weekdays': alarma.diasSemana,
'oneShotDateMillis': alarma.fechaUnica?.millisecondsSinceEpoch,
'snoozeUntilMillis': alarma.snoozeHasta?.millisecondsSinceEpoch,
'snoozeOriginMillis': alarma.snoozeOrigen?.millisecondsSinceEpoch,
'snoozeMinutes': alarma.snoozeMinutos,
'lastHandledAtMillis':
alarma.ultimaEjecucionGestionada?.millisecondsSinceEpoch,
'soundOnVacation': alarma.sonarEnVacaciones,
'stationName':
alarma.emisora == null
? null
: localizedStationName(_textos, alarma.emisora!.nombre),
'stationUrl': alarma.emisora?.url,
'fallbackStationName':
alarma.emisoraFallback == null
? null
: localizedStationName(_textos, alarma.emisoraFallback!.nombre),
'fallbackStationUrl': alarma.emisoraFallback?.url,
'fallbackSound': alarma.sonidoInterno.name,
'volume': alarma.volumen,
'fadeInSegundos': alarma.fadeInSegundos,
});
if (programada != true) {
throw StateError(_textos.androidExactAlarmScheduleError);
}
}
@override
Future<void> cancelar(String alarmaId) =>
_logAndInvokeVoid('cancelAlarm', {'id': alarmaId});
@override
Future<void> ocultarNotificacionAlarma(String alarmaId) =>
_logAndInvokeVoid('dismissAlarmNotification', {'id': alarmaId});
@override
Future<void> detenerSonidoNativo(String alarmaId) =>
_logAndInvokeVoid('stopNativeAlarmSound', {'id': alarmaId});
@override
Future<void> confirmarAudioFlutter(String alarmaId) =>
_logAndInvokeVoid('confirmFlutterAudio', {'id': alarmaId});
@override
Future<void> forzarVolumenMediaParaAlarma(double fraccion) =>
_logAndInvokeVoid('overrideMediaVolumeForRing', {'fraction': fraccion});
@override
Future<void> restaurarVolumenMedia() =>
_logAndInvokeVoid('restoreMediaVolume', {});
@override
Future<bool> solicitarPermisoAlarmasExactas() async {
final abierto = await _channel.invokeMethod<bool>(
'requestExactAlarmPermission',
);
return abierto ?? false;
}
@override
Future<bool> solicitarPermisoNotificaciones() async {
final abierto = await _channel.invokeMethod<bool>(
'requestPostNotificationsPermission',
);
return abierto ?? false;
}
@override
Future<bool> solicitarPermisoPantallaCompleta() async {
final abierto = await _channel.invokeMethod<bool>(
'requestFullScreenIntentPermission',
);
return abierto ?? false;
}
@override
Future<bool> solicitarExencionBateria() async {
final abierto = await _channel.invokeMethod<bool>(
'requestIgnoreBatteryOptimizations',
);
return abierto ?? false;
}
@override
Future<DiagnosticoAlarmasAndroid> diagnostico() async {
debugPrint('[PluriWave][alarmas] diagnostico android');
final raw = await _channel.invokeMethod<Map<Object?, Object?>>(
'diagnostics',
);
final diag = DiagnosticoAlarmasAndroid.fromMap(raw ?? const {});
debugPrint(
'[PluriWave][alarmas] diagnostico exactas=${diag.puedeProgramarExactas} notificaciones=${diag.notificacionesPermitidas} sdk=${diag.versionSdk} fabricante=${diag.fabricante}',
);
return diag;
}
@override
Future<EventoAlarmaAndroid?> obtenerEventoInicial() async {
final raw = await _channel.invokeMethod<Map<Object?, Object?>>(
'getInitialAlarmIntent',
);
if (raw == null || raw.isEmpty) return null;
final evento = EventoAlarmaAndroid.fromMap(raw);
debugPrint(
'[PluriWave][alarmas] evento inicial id=${evento.alarmaId} accion=${evento.accion}',
);
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();
}
@override
Future<List<EstadoSnoozeNativo>> obtenerEstadoSnoozeNativo() async {
final raw = await _channel.invokeMethod<List<Object?>>(
'getNativeSnoozeState',
);
if (raw == null || raw.isEmpty) return const [];
return raw
.whereType<Map<Object?, Object?>>()
.map(EstadoSnoozeNativo.fromMap)
.where(
(estado) =>
estado.alarmaId.isNotEmpty &&
estado.snoozeHasta.millisecondsSinceEpoch > 0,
)
.toList();
}
Future<void> _logAndInvokeVoid(String method, Map<String, Object?> args) {
debugPrint('[PluriWave][alarmas] $method $args');
return _channel.invokeMethod<void>(method, args);
}
// Installed once per instance from the constructor. Creating a second
// instance over the SAME channel re-binds the platform handler to the
// newest instance (production has exactly one instance per channel).
void _instalarHandler() {
_channel.setMethodCallHandler((call) async {
if (call.method != 'alarmFired') return;
final args = call.arguments;
if (args is Map) {
final evento = EventoAlarmaAndroid.fromMap(args);
if (evento.alarmaId.isNotEmpty) {
debugPrint(
'[PluriWave][alarmas] evento nativo id=${evento.alarmaId} accion=${evento.accion}',
);
_eventosController.add(evento);
}
}
});
}
}