From ef9705a30e391c7dbbf90dffbe15c2e5fad776e2 Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 31 Jul 2026 18:32:42 +0200 Subject: [PATCH] feat(alarmas): add pure diagnostic mapping and autostart-guidance logic DiagnosticoAlarmasAndroid already collected six raw reliability fields but only three ever reached the UI. Add a pure-Dart mapping that turns the raw snapshot into five ordered signals with a clear ok/needs- attention state (exact alarms, notifications, full-screen intent, battery-optimization exemption, native pending-alarm count), plus a manufacturer check for vendors known to require manually enabling Autostart (Xiaomi/Redmi/POCO, Huawei, Oppo, Vivo, OnePlus, Samsung), since there is no public API to detect or grant that setting. --- lib/servicios/diagnostico_alarmas.dart | 119 ++++++++++ test/servicios/diagnostico_alarmas_test.dart | 234 +++++++++++++++++++ 2 files changed, 353 insertions(+) create mode 100644 lib/servicios/diagnostico_alarmas.dart create mode 100644 test/servicios/diagnostico_alarmas_test.dart diff --git a/lib/servicios/diagnostico_alarmas.dart b/lib/servicios/diagnostico_alarmas.dart new file mode 100644 index 0000000..4ea99f9 --- /dev/null +++ b/lib/servicios/diagnostico_alarmas.dart @@ -0,0 +1,119 @@ +import 'servicio_alarmas_android.dart'; + +/// One diagnosable Android alarm-reliability signal (fix/alarmas-fiabilidad). +/// +/// [DiagnosticoAlarmasAndroid] already collects six raw fields, but only +/// three were ever surfaced in the UI -- the two most diagnostic ones +/// (battery-optimization exemption and the native pending-alarm count) were +/// gathered and thrown away. This module maps the raw snapshot into a +/// stable, ordered list of user-facing signals with a clear ok/needs- +/// attention state, decoupled from Flutter/localization so it stays a +/// trivial pure-Dart unit to test. +enum SenalDiagnosticoAlarma { + alarmasExactas, + notificaciones, + pantallaCompleta, + optimizacionBateria, + alarmasNativasPendientes, +} + +enum EstadoSenalDiagnostico { ok, atencion } + +/// The system screen a "fix this" action should open for a given signal. +/// `ninguna` marks signals with no actionable system screen of their own +/// (`alarmasNativasPendientes` is informational -- fixing the OTHER signals +/// above is what makes it recover). +enum AccionDiagnosticoAlarma { + abrirAlarmasExactas, + abrirNotificaciones, + abrirOptimizacionBateria, + abrirPantallaCompleta, + ninguna, +} + +class ItemDiagnosticoAlarma { + const ItemDiagnosticoAlarma({ + required this.senal, + required this.estado, + required this.accion, + }); + + final SenalDiagnosticoAlarma senal; + final EstadoSenalDiagnostico estado; + final AccionDiagnosticoAlarma accion; + + bool get requiereAtencion => estado == EstadoSenalDiagnostico.atencion; +} + +/// Builds the five diagnosable signals in a FIXED, stable order so the +/// screen renders them consistently every time. +/// +/// [hayAlarmasActivas] contextualizes `alarmasNativasPendientes`: a fresh +/// install with zero alarms turned on has nothing to register with the OS, +/// so a `0` count there is only meaningful once the user actually has an +/// active alarm -- THAT combination is direct evidence the alarm never +/// reached the operating system at all, which is the single most useful +/// signal for the reported "alarm never rings" failure mode. +List construirItemsDiagnosticoAlarmas({ + required DiagnosticoAlarmasAndroid diagnostico, + required bool hayAlarmasActivas, +}) { + EstadoSenalDiagnostico desde(bool ok) => + ok ? EstadoSenalDiagnostico.ok : EstadoSenalDiagnostico.atencion; + + final alarmasNativasOk = + !hayAlarmasActivas || diagnostico.alarmasNativasPendientes > 0; + + return [ + ItemDiagnosticoAlarma( + senal: SenalDiagnosticoAlarma.alarmasExactas, + estado: desde(diagnostico.puedeProgramarExactas), + accion: AccionDiagnosticoAlarma.abrirAlarmasExactas, + ), + ItemDiagnosticoAlarma( + senal: SenalDiagnosticoAlarma.notificaciones, + estado: desde(diagnostico.notificacionesPermitidas), + accion: AccionDiagnosticoAlarma.abrirNotificaciones, + ), + ItemDiagnosticoAlarma( + senal: SenalDiagnosticoAlarma.pantallaCompleta, + estado: desde(diagnostico.puedeUsarPantallaCompleta), + accion: AccionDiagnosticoAlarma.abrirPantallaCompleta, + ), + ItemDiagnosticoAlarma( + senal: SenalDiagnosticoAlarma.optimizacionBateria, + estado: desde(diagnostico.ignoraOptimizacionBateria), + accion: AccionDiagnosticoAlarma.abrirOptimizacionBateria, + ), + ItemDiagnosticoAlarma( + senal: SenalDiagnosticoAlarma.alarmasNativasPendientes, + estado: desde(alarmasNativasOk), + accion: AccionDiagnosticoAlarma.ninguna, + ), + ]; +} + +/// Manufacturers/sub-brands known for aggressive background-process killing +/// that requires the user to manually enable "Autostart" (or the vendor's +/// own equivalent toggle) -- there is NO public Android API to detect or +/// grant this setting programmatically, so the app can only explain it. +const _fabricantesConGuiaAutostart = [ + 'xiaomi', + 'redmi', + 'poco', + 'huawei', + 'oppo', + 'vivo', + 'oneplus', + 'samsung', +]; + +/// Whether [fabricante] (`Build.MANUFACTURER`, e.g. "Xiaomi", "POCO") is a +/// known aggressive-background-killer vendor that needs the manual autostart +/// explanation. Case-insensitive substring match, since `Build.MANUFACTURER` +/// values are not fully standardized across sub-brands/regions/builds. +bool fabricanteRequiereGuiaAutostart(String fabricante) { + final normalizado = fabricante.trim().toLowerCase(); + if (normalizado.isEmpty) return false; + return _fabricantesConGuiaAutostart.any(normalizado.contains); +} diff --git a/test/servicios/diagnostico_alarmas_test.dart b/test/servicios/diagnostico_alarmas_test.dart new file mode 100644 index 0000000..cf4482d --- /dev/null +++ b/test/servicios/diagnostico_alarmas_test.dart @@ -0,0 +1,234 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/servicios/diagnostico_alarmas.dart'; +import 'package:pluriwave/servicios/servicio_alarmas_android.dart'; + +/// Builds a fully-OK snapshot by default; each test overrides only the +/// field(s) it wants to fail, so failures are exercised independently. +DiagnosticoAlarmasAndroid _diag({ + bool puedeProgramarExactas = true, + bool notificacionesPermitidas = true, + bool puedeUsarPantallaCompleta = true, + bool ignoraOptimizacionBateria = true, + int alarmasNativasPendientes = 1, + String fabricante = 'Google', + int versionSdk = 34, +}) => DiagnosticoAlarmasAndroid( + puedeProgramarExactas: puedeProgramarExactas, + notificacionesPermitidas: notificacionesPermitidas, + puedeUsarPantallaCompleta: puedeUsarPantallaCompleta, + ignoraOptimizacionBateria: ignoraOptimizacionBateria, + alarmasNativasPendientes: alarmasNativasPendientes, + fabricante: fabricante, + versionSdk: versionSdk, +); + +void main() { + group('construirItemsDiagnosticoAlarmas', () { + test( + 'caso todo OK: los 5 items quedan en estado ok y en orden estable', + () { + final items = construirItemsDiagnosticoAlarmas( + diagnostico: _diag(), + hayAlarmasActivas: true, + ); + + expect(items, hasLength(5)); + expect(items.map((item) => item.senal).toList(), const [ + SenalDiagnosticoAlarma.alarmasExactas, + SenalDiagnosticoAlarma.notificaciones, + SenalDiagnosticoAlarma.pantallaCompleta, + SenalDiagnosticoAlarma.optimizacionBateria, + SenalDiagnosticoAlarma.alarmasNativasPendientes, + ]); + expect( + items.every((item) => item.estado == EstadoSenalDiagnostico.ok), + isTrue, + reason: 'ningun item deberia requerir atencion en el caso todo OK', + ); + expect(items.any((item) => item.requiereAtencion), isFalse); + }, + ); + + test('alarmas exactas queda en atencion cuando el permiso no esta ' + 'concedido, sin afectar a los demas items (falla independiente)', () { + final items = construirItemsDiagnosticoAlarmas( + diagnostico: _diag(puedeProgramarExactas: false), + hayAlarmasActivas: true, + ); + + final exactas = items.singleWhere( + (item) => item.senal == SenalDiagnosticoAlarma.alarmasExactas, + ); + expect(exactas.estado, EstadoSenalDiagnostico.atencion); + expect(exactas.accion, AccionDiagnosticoAlarma.abrirAlarmasExactas); + expect( + items + .where( + (item) => item.senal != SenalDiagnosticoAlarma.alarmasExactas, + ) + .every((item) => item.estado == EstadoSenalDiagnostico.ok), + isTrue, + ); + }); + + test('notificaciones queda en atencion cuando no estan permitidas, sin ' + 'afectar a los demas items (falla independiente)', () { + final items = construirItemsDiagnosticoAlarmas( + diagnostico: _diag(notificacionesPermitidas: false), + hayAlarmasActivas: true, + ); + + final notificaciones = items.singleWhere( + (item) => item.senal == SenalDiagnosticoAlarma.notificaciones, + ); + expect(notificaciones.estado, EstadoSenalDiagnostico.atencion); + expect( + notificaciones.accion, + AccionDiagnosticoAlarma.abrirNotificaciones, + ); + expect( + items + .where( + (item) => item.senal != SenalDiagnosticoAlarma.notificaciones, + ) + .every((item) => item.estado == EstadoSenalDiagnostico.ok), + isTrue, + ); + }); + + test('pantalla completa queda en atencion cuando no se puede usar, sin ' + 'afectar a los demas items (falla independiente)', () { + final items = construirItemsDiagnosticoAlarmas( + diagnostico: _diag(puedeUsarPantallaCompleta: false), + hayAlarmasActivas: true, + ); + + final pantalla = items.singleWhere( + (item) => item.senal == SenalDiagnosticoAlarma.pantallaCompleta, + ); + expect(pantalla.estado, EstadoSenalDiagnostico.atencion); + expect(pantalla.accion, AccionDiagnosticoAlarma.abrirPantallaCompleta); + expect( + items + .where( + (item) => item.senal != SenalDiagnosticoAlarma.pantallaCompleta, + ) + .every((item) => item.estado == EstadoSenalDiagnostico.ok), + isTrue, + ); + }); + + test('optimizacion de bateria queda en atencion cuando la app no esta ' + 'exenta, sin afectar a los demas items (falla independiente)', () { + final items = construirItemsDiagnosticoAlarmas( + diagnostico: _diag(ignoraOptimizacionBateria: false), + hayAlarmasActivas: true, + ); + + final bateria = items.singleWhere( + (item) => item.senal == SenalDiagnosticoAlarma.optimizacionBateria, + ); + expect(bateria.estado, EstadoSenalDiagnostico.atencion); + expect(bateria.accion, AccionDiagnosticoAlarma.abrirOptimizacionBateria); + expect( + items + .where( + (item) => + item.senal != SenalDiagnosticoAlarma.optimizacionBateria, + ) + .every((item) => item.estado == EstadoSenalDiagnostico.ok), + isTrue, + ); + }); + + test('alarmas nativas pendientes queda en atencion cuando hay alarmas ' + 'activas pero ninguna llego a registrarse en el sistema (la senal ' + 'mas diagnostica del reporte: el disparo nunca llego al SO)', () { + final items = construirItemsDiagnosticoAlarmas( + diagnostico: _diag(alarmasNativasPendientes: 0), + hayAlarmasActivas: true, + ); + + final nativas = items.singleWhere( + (item) => item.senal == SenalDiagnosticoAlarma.alarmasNativasPendientes, + ); + expect(nativas.estado, EstadoSenalDiagnostico.atencion); + expect(nativas.accion, AccionDiagnosticoAlarma.ninguna); + expect( + items + .where( + (item) => + item.senal != SenalDiagnosticoAlarma.alarmasNativasPendientes, + ) + .every((item) => item.estado == EstadoSenalDiagnostico.ok), + isTrue, + ); + }); + + test('alarmas nativas pendientes queda OK cuando no hay ninguna alarma ' + 'activa, aunque el conteo nativo sea cero (nada deberia estar ' + 'registrado todavia)', () { + final items = construirItemsDiagnosticoAlarmas( + diagnostico: _diag(alarmasNativasPendientes: 0), + hayAlarmasActivas: false, + ); + + final nativas = items.singleWhere( + (item) => item.senal == SenalDiagnosticoAlarma.alarmasNativasPendientes, + ); + expect(nativas.estado, EstadoSenalDiagnostico.ok); + }); + + test('alarmas nativas pendientes queda OK cuando hay alarmas activas y al ' + 'menos una esta registrada en el sistema', () { + final items = construirItemsDiagnosticoAlarmas( + diagnostico: _diag(alarmasNativasPendientes: 3), + hayAlarmasActivas: true, + ); + + final nativas = items.singleWhere( + (item) => item.senal == SenalDiagnosticoAlarma.alarmasNativasPendientes, + ); + expect(nativas.estado, EstadoSenalDiagnostico.ok); + }); + }); + + group('fabricanteRequiereGuiaAutostart', () { + test('un fabricante desconocido no requiere guia de autostart', () { + expect(fabricanteRequiereGuiaAutostart('Google'), isFalse); + expect(fabricanteRequiereGuiaAutostart('Fairphone'), isFalse); + expect(fabricanteRequiereGuiaAutostart(''), isFalse); + }); + + test( + 'Xiaomi (y sus sub-marcas Redmi/POCO) requieren guia de autostart', + () { + expect(fabricanteRequiereGuiaAutostart('Xiaomi'), isTrue); + expect(fabricanteRequiereGuiaAutostart('Redmi'), isTrue); + expect(fabricanteRequiereGuiaAutostart('POCO'), isTrue); + }, + ); + + test('otros fabricantes conocidos por matar procesos en segundo plano ' + 'tambien requieren guia', () { + for (final fabricante in [ + 'HUAWEI', + 'OPPO', + 'vivo', + 'OnePlus', + 'samsung', + ]) { + expect( + fabricanteRequiereGuiaAutostart(fabricante), + isTrue, + reason: '$fabricante deberia requerir guia de autostart', + ); + } + }); + + test('la comparacion no distingue mayusculas/minusculas ni espacios', () { + expect(fabricanteRequiereGuiaAutostart('XIAOMI'), isTrue); + expect(fabricanteRequiereGuiaAutostart(' xiaomi '), isTrue); + }); + }); +}