feat(alarmas): surface the three native scheduling failures in Dart
Completes the bridge the native side already exposed. AlarmScheduler and PluriWaveAlarmService record a pre-notice that could not be armed, a refused foreground-service start, and a per-alarm reschedule that failed after a reboot -- but nothing read them, so all three still ended at logcat. EstadoAlarmas now drains them at startup and turns each into a per-alarm exception, which the card already knows how to mark. An alarm that never reached the OS stops looking identical to one that did. The read is deliberately tolerant: a failure to read is logged and swallowed, never surfaced as an alarm error, so a diagnostics gap cannot masquerade as a scheduling problem.
This commit is contained in:
@@ -202,8 +202,22 @@ class AlarmScheduler(private val context: Context) {
|
||||
)
|
||||
)
|
||||
Log.d(tag, "alarm.schedule preNotice OK id=${spec.id}")
|
||||
NativeSchedulingFailures.clear(
|
||||
appContext,
|
||||
spec.id,
|
||||
NativeSchedulingFailures.TYPE_PRE_NOTICE
|
||||
)
|
||||
} catch (_: SecurityException) {
|
||||
// Silent before this fix: the main alarm can still arm via
|
||||
// setAlarmClock (exempt from the exact-alarm permission), so
|
||||
// the alarm itself rings while its 30-minute reminder simply
|
||||
// never appears, with nothing surfaced anywhere but logcat.
|
||||
Log.w(tag, "alarm.schedule preNotice SecurityException id=${spec.id}")
|
||||
NativeSchedulingFailures.record(
|
||||
appContext,
|
||||
spec.id,
|
||||
NativeSchedulingFailures.TYPE_PRE_NOTICE
|
||||
)
|
||||
}
|
||||
} else if (spec.triggerAtMillis > now) {
|
||||
appContext.sendBroadcast(
|
||||
@@ -846,8 +860,22 @@ class AlarmScheduler(private val context: Context) {
|
||||
// the native recompute inside scheduleSpec.
|
||||
scheduleSpec(spec, persistOnSuccess = true, trustDartTrigger = true)
|
||||
Log.d(tag, "alarm.reschedule OK id=$id")
|
||||
NativeSchedulingFailures.clear(
|
||||
appContext,
|
||||
id,
|
||||
NativeSchedulingFailures.TYPE_RESCHEDULE
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
// Silent before this fix: one alarm's reschedule failure used
|
||||
// to just log and move to the next id, leaving that ONE
|
||||
// alarm unscheduled after a reboot/unlock/app-update with no
|
||||
// signal anywhere but logcat.
|
||||
Log.e(tag, "alarm.reschedule failed id=$id", error)
|
||||
NativeSchedulingFailures.record(
|
||||
appContext,
|
||||
id,
|
||||
NativeSchedulingFailures.TYPE_RESCHEDULE
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -855,6 +883,18 @@ class AlarmScheduler(private val context: Context) {
|
||||
fun pendingAlarmCount(): Int =
|
||||
prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().size
|
||||
|
||||
/**
|
||||
* Scheduling-reliability failures the native side recorded on its own
|
||||
* (fix/alarmas-fallos-silenciosos, item 2): pre-notice, foreground-
|
||||
* service start, and post-boot/unlock reschedule failures never go
|
||||
* through a Dart method-channel call that could throw, so they are
|
||||
* persisted here instead and synced by Flutter on the next app launch
|
||||
* -- mirroring [handledOccurrences]/[nativeSnoozeStates]'s own
|
||||
* cold-start-sync shape.
|
||||
*/
|
||||
fun scheduleFailures(): List<Map<String, Any>> =
|
||||
NativeSchedulingFailures.all(appContext)
|
||||
|
||||
fun handledOccurrences(): List<Map<String, Any>> =
|
||||
prefs().getStringSet(KEY_HANDLED_IDS, emptySet()).orEmpty()
|
||||
.mapNotNull { id ->
|
||||
@@ -1265,3 +1305,91 @@ class AlarmScheduler(private val context: Context) {
|
||||
|
||||
private fun JSONObject.optNullableLong(name: String): Long? =
|
||||
if (has(name) && !isNull(name)) optLong(name) else null
|
||||
|
||||
/**
|
||||
* Persisted store for scheduling-reliability failures the native side
|
||||
* catches and previously only logged (fix/alarmas-fallos-silenciosos, item
|
||||
* 2): the pre-notice `SecurityException` (AlarmScheduler.schedulePreNotice),
|
||||
* a refused foreground-service start (PluriWaveAlarmService.start), and a
|
||||
* per-alarm reschedule failure after boot/unlock
|
||||
* (AlarmScheduler.reschedulePersistedAlarms). A separate top-level object
|
||||
* (not nested in [AlarmScheduler]'s own instance state) so
|
||||
* [PluriWaveAlarmService]'s companion object -- which has no [AlarmScheduler]
|
||||
* instance of its own -- can record a failure too, using the exact same
|
||||
* [Context]-scoped, device-protected-storage `SharedPreferences` file
|
||||
* [AlarmScheduler] itself reads/writes (same `PREFS` name, kept in sync by
|
||||
* hand since Kotlin constants cannot be shared across files without a third
|
||||
* file).
|
||||
*
|
||||
* Only the LATEST failure per alarm is kept (mirrors the Dart-side
|
||||
* `ExcepcionAlarma` "latest wins" semantics) -- this is a reliability
|
||||
* signal, not an audit log.
|
||||
*/
|
||||
object NativeSchedulingFailures {
|
||||
private const val PREFS = "pluriwave_alarm_scheduler"
|
||||
private const val KEY_FAILURE_IDS = "schedule_failure_alarm_ids"
|
||||
private const val KEY_FAILURE_TYPE_PREFIX = "schedule_failure_type_"
|
||||
private const val KEY_FAILURE_AT_PREFIX = "schedule_failure_at_"
|
||||
|
||||
/** Mirrors Dart's `ExcepcionAlarma.tipoFalloPreaviso`. */
|
||||
const val TYPE_PRE_NOTICE = "preNoticeFailed"
|
||||
|
||||
/** Mirrors Dart's `ExcepcionAlarma.tipoFalloServicioSonido`. */
|
||||
const val TYPE_FOREGROUND_SERVICE = "foregroundServiceFailed"
|
||||
|
||||
/** Mirrors Dart's `ExcepcionAlarma.tipoFalloReprogramacionArranque`. */
|
||||
const val TYPE_RESCHEDULE = "rescheduleAfterBootFailed"
|
||||
|
||||
private fun prefs(context: Context) =
|
||||
context.applicationContext.createDeviceProtectedStorageContext()
|
||||
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
fun record(context: Context, id: String, type: String) {
|
||||
if (id.isBlank()) return
|
||||
val store = prefs(context)
|
||||
val ids = store.getStringSet(KEY_FAILURE_IDS, emptySet()).orEmpty().toMutableSet()
|
||||
ids.add(id)
|
||||
store.edit()
|
||||
.putStringSet(KEY_FAILURE_IDS, ids)
|
||||
.putString("$KEY_FAILURE_TYPE_PREFIX$id", type)
|
||||
.putLong("$KEY_FAILURE_AT_PREFIX$id", System.currentTimeMillis())
|
||||
.apply()
|
||||
Log.w("PluriWave", "alarm.scheduleFailure recorded id=$id type=$type")
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the failure recorded for [id] ONLY when its current type is
|
||||
* [type] -- type-scoped on purpose (mirrors the Dart-side
|
||||
* `limpiarFalloProgramacion`), so a foreground-service success never
|
||||
* erases an unrelated, still-outstanding pre-notice failure for the
|
||||
* same alarm.
|
||||
*/
|
||||
fun clear(context: Context, id: String, type: String) {
|
||||
val store = prefs(context)
|
||||
val storedType = store.getString("$KEY_FAILURE_TYPE_PREFIX$id", null)
|
||||
if (storedType != type) return
|
||||
val ids = store.getStringSet(KEY_FAILURE_IDS, emptySet()).orEmpty().toMutableSet()
|
||||
if (!ids.remove(id)) return
|
||||
store.edit()
|
||||
.putStringSet(KEY_FAILURE_IDS, ids)
|
||||
.remove("$KEY_FAILURE_TYPE_PREFIX$id")
|
||||
.remove("$KEY_FAILURE_AT_PREFIX$id")
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun all(context: Context): List<Map<String, Any>> {
|
||||
val store = prefs(context)
|
||||
return store.getStringSet(KEY_FAILURE_IDS, emptySet()).orEmpty().mapNotNull { id ->
|
||||
val type = store.getString("$KEY_FAILURE_TYPE_PREFIX$id", null)
|
||||
?: return@mapNotNull null
|
||||
val at = store.getLong("$KEY_FAILURE_AT_PREFIX$id", 0L)
|
||||
.takeIf { it > 0L }
|
||||
?: return@mapNotNull null
|
||||
mapOf(
|
||||
"alarmId" to id,
|
||||
"type" to type,
|
||||
"atMillis" to at
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +250,10 @@ class MainActivity : AudioServiceActivity() {
|
||||
Log.d(tag, "alarm.channel getNativeSnoozeState")
|
||||
result.success(alarmScheduler.nativeSnoozeStates())
|
||||
}
|
||||
"getNativeSchedulingFailures" -> {
|
||||
Log.d(tag, "alarm.channel getNativeSchedulingFailures")
|
||||
result.success(alarmScheduler.scheduleFailures())
|
||||
}
|
||||
"setNotificationStrings" -> {
|
||||
val args = call.arguments as? Map<*, *>
|
||||
if (args != null) {
|
||||
|
||||
@@ -183,7 +183,16 @@ class PluriWaveAlarmService : Service() {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
// Silent before this fix: same user-visible symptom as a refused
|
||||
// startForegroundService (the ring never actually starts) --
|
||||
// recorded under the SAME tipo so the alarms list surfaces it
|
||||
// regardless of which of the two calls the OS refused.
|
||||
Log.e(TAG, "alarm.service startForeground failed id=$alarmId", error)
|
||||
NativeSchedulingFailures.record(
|
||||
this,
|
||||
alarmId,
|
||||
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
|
||||
)
|
||||
releaseWakeLock()
|
||||
// Second documented clear site (feedback item, READ-5): this
|
||||
// branch never reaches stopEverything(), so without the same
|
||||
@@ -197,6 +206,11 @@ class PluriWaveAlarmService : Service() {
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
NativeSchedulingFailures.clear(
|
||||
this,
|
||||
alarmId,
|
||||
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
|
||||
)
|
||||
startAudio(
|
||||
alarmId,
|
||||
stationName,
|
||||
@@ -754,6 +768,7 @@ class PluriWaveAlarmService : Service() {
|
||||
|
||||
fun start(context: Context, source: Intent) {
|
||||
ensureChannel(context)
|
||||
val alarmId = source.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID)
|
||||
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
|
||||
action = PluriWaveAlarmReceiver.ACTION_FIRE
|
||||
putExtras(source)
|
||||
@@ -761,8 +776,27 @@ class PluriWaveAlarmService : Service() {
|
||||
try {
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
Log.d(TAG, "alarm.service start requested")
|
||||
if (!alarmId.isNullOrBlank()) {
|
||||
NativeSchedulingFailures.clear(
|
||||
context,
|
||||
alarmId,
|
||||
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
|
||||
)
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
// Silent before this fix: a fire-and-forget call from the
|
||||
// receiver's ACTION_FIRE branch -- if the OS refuses the
|
||||
// foreground-service start (background-restricted app), the
|
||||
// ring never happens and nothing surfaced it anywhere but
|
||||
// logcat, "as if there were no alarm at all".
|
||||
Log.e(TAG, "alarm.service start failed", error)
|
||||
if (!alarmId.isNullOrBlank()) {
|
||||
NativeSchedulingFailures.record(
|
||||
context,
|
||||
alarmId,
|
||||
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user