The recordings live in app-private storage (<data>/app_flutter/grabaciones), which the Android sandbox forbids any other app from reading, so no ACTION_VIEW on a file:// or FileProvider URI could ever open it. On top of that, viewDirectory built an EMPTY candidate list for that path: directoryDocumentUri returned null (path outside external storage) and FileProvider.getUriForFile threw because pluriwave_file_paths.xml never covered app_flutter. The loop never ran, so both entry points -- the radio recorder and Settings -- always showed "could not open the folder". Publish the folder as a browsable storage root via RecordingsDocumentsProvider instead. The files never leave private storage; the document framework asks us for them one document at a time, and the user can browse, copy out, rename and delete straight from the file manager. The root follows a user-configured path and falls back to the default recordings directory. Its title reuses the already-translated recordingsFolderTitle, so no new literal is introduced in any of the 13 locales. Also fixes "open last recording", broken by the same missing FileProvider root, and replaces Intent.createChooser with a bare startActivity in the candidate loop: a chooser never throws when nothing can handle the intent, so the first candidate always "succeeded" and the fallback chain never ran. Device QA pending -- the provider is driven entirely by the platform's document framework, so no unit test covers it. Each candidate logs its own name under file_actions.viewDirectory for logcat triage.
504 lines
18 KiB
Dart
504 lines
18 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';
|
|
|
|
/// Action reported when a fired alarm auto-silenced unattended after the
|
|
/// 10-minute bound (Decision 3), never a user-initiated stop.
|
|
static const accionMissed = 'missed';
|
|
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Fail-safe stop result (Decision 1). `detenido` reports whether a
|
|
/// non-no-op teardown was dispatched (never silently swallowed); `alarmaId`
|
|
/// is the id that was actually ringing, for Dart-side reconciliation.
|
|
class ResultadoDetencion {
|
|
const ResultadoDetencion({
|
|
required this.detenido,
|
|
required this.estabaSonando,
|
|
this.alarmaId,
|
|
});
|
|
|
|
final bool detenido;
|
|
final bool estabaSonando;
|
|
final String? alarmaId;
|
|
|
|
/// A thrown channel error or a missing native response is treated as a
|
|
/// failure, never as an implicit success.
|
|
static const fallo = ResultadoDetencion(
|
|
detenido: false,
|
|
estabaSonando: false,
|
|
);
|
|
|
|
factory ResultadoDetencion.fromMap(Map<Object?, Object?> map) {
|
|
return ResultadoDetencion(
|
|
detenido: map['stopped'] as bool? ?? false,
|
|
estabaSonando: map['wasRinging'] as bool? ?? false,
|
|
alarmaId: map['activeAlarmId'] as String?,
|
|
);
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
/// Notification-only dismissal (RES-1): hides the fire notification for
|
|
/// [alarmaId] WITHOUT stopping native ring audio for any alarm. Used when a
|
|
/// genuinely different alarm rings while another one is still active.
|
|
Future<void> ocultarSoloNotificacion(String alarmaId);
|
|
Future<void> detenerSonidoNativo(String alarmaId);
|
|
|
|
/// Synchronous companion snapshot (Decision 1): the id of the alarm
|
|
/// currently ringing natively, or null if none is.
|
|
Future<String?> alarmaSonandoId();
|
|
|
|
/// Id-agnostic fail-safe stop: silences whatever is ringing regardless of
|
|
/// which alarm the caller thinks is active, and reports a verified result.
|
|
Future<ResultadoDetencion> detenerSonidoActivo();
|
|
Future<bool> solicitarPermisoAlarmasExactas();
|
|
Future<bool> solicitarPermisoNotificaciones();
|
|
Future<bool> solicitarPermisoPantallaCompleta();
|
|
Future<bool> solicitarExencionBateria();
|
|
|
|
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,
|
|
// Title of the storage root that RecordingsDocumentsProvider publishes
|
|
// to the system file manager, so the recordings folder is browsable
|
|
// without leaving app-private storage.
|
|
'recordingsRootTitle': l10n.recordingsFolderTitle,
|
|
'missedTitle': l10n.alarmMissedNotificationTitle,
|
|
'missedTemplate': _plantillaNombre(l10n.alarmMissedNotificationText),
|
|
});
|
|
} 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}');
|
|
}
|
|
|
|
/// Same sentinel-swap approach as [_plantillaMinutos], but for a
|
|
/// `{String} -> String` message: swaps a unique sentinel token back for the
|
|
/// literal `{name}` placeholder Kotlin fills in at fire time.
|
|
static String _plantillaNombre(String Function(Object) traducir) {
|
|
const sentinel = 'PLURIWAVE_NAME_SENTINEL';
|
|
return traducir(sentinel).replaceFirst(sentinel, '{name}');
|
|
}
|
|
|
|
@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,
|
|
// Anchored at LOCAL NOON, not midnight: the native side derives the
|
|
// calendar DAY back from this epoch in whatever timezone the device is
|
|
// in when it re-arms (boot/travel). A midnight epoch reinterpreted in
|
|
// a westward zone rolls to the previous day; noon keeps the intended
|
|
// date stable across any real-world zone shift (+-11h).
|
|
'oneShotDateMillis':
|
|
alarma.fechaUnica == null
|
|
? null
|
|
: DateTime(
|
|
alarma.fechaUnica!.year,
|
|
alarma.fechaUnica!.month,
|
|
alarma.fechaUnica!.day,
|
|
12,
|
|
).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> ocultarSoloNotificacion(String alarmaId) =>
|
|
_logAndInvokeVoid('dismissAlarmNotificationOnly', {'id': alarmaId});
|
|
|
|
@override
|
|
Future<void> detenerSonidoNativo(String alarmaId) =>
|
|
_logAndInvokeVoid('stopNativeAlarmSound', {'id': alarmaId});
|
|
|
|
@override
|
|
Future<String?> alarmaSonandoId() async {
|
|
try {
|
|
return await _channel.invokeMethod<String>('getActiveRingingAlarmId');
|
|
} catch (e) {
|
|
// Fail-toward-silence (Finding 2): a query failure must NOT be
|
|
// mistaken for "nothing is ringing" by callers like
|
|
// EstadoAlarmas._detenerSiEstaSonando, which would otherwise skip the
|
|
// stop entirely on a genuinely ringing alarm. Rethrow so the caller can
|
|
// fall back to the id-scoped legacy stop instead.
|
|
debugPrint('[PluriWave][alarmas] alarmaSonandoId ERROR $e');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<ResultadoDetencion> detenerSonidoActivo() async {
|
|
try {
|
|
final raw = await _channel.invokeMethod<Map<Object?, Object?>>(
|
|
'stopActiveAlarm',
|
|
);
|
|
if (raw == null) return ResultadoDetencion.fallo;
|
|
return ResultadoDetencion.fromMap(raw);
|
|
} catch (e) {
|
|
debugPrint('[PluriWave][alarmas] detenerSonidoActivo ERROR $e');
|
|
return ResultadoDetencion.fallo;
|
|
}
|
|
}
|
|
|
|
@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);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|