Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions LESSONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,6 @@ A function may accept a flag (`wcag: Boolean = false`) and correctly wire it thr

**Auth state transitions need explicit guards**
Don't assume UI flow enforces auth invariants. Guard at the data layer: block enabling biometric if no PIN exists; clear dependent auth factors when a prerequisite is removed. Silent auth gaps (biometric enabled, PIN removed, lock screen never triggers) are worse than a visible error.

**One-shot alarms whose fire time depends on data must re-arm on every data change and every firing**
The period-prediction reminders (pre-period, ovulation, daily during period) were one-shot alarms armed only from the reminder settings screen and the boot receiver. After a reminder fired, or after the user logged a period (which moves every predicted date), no code re-armed them, so the feature quietly degraded to "works until the first firing". Every mutation of the data a prediction is computed from (log/edit/delete/undo/merge of a period) and every firing of a prediction alarm must call one shared refresh function (`ReminderScheduler.refreshPredictionReminders`) that recomputes and re-arms everything. Related: `setRepeating` is inexact and Doze-deferred since KitKat, so a "repeating" reminder that matters should be an exact one-shot that re-arms itself in the receiver, and any snooze must share PendingIntent identity with a cancellation path so deleting the alarm also cancels the snoozed firing.
9 changes: 8 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -138,15 +138,22 @@
</receiver>

<!--
BootReceiver is a separate, minimal receiver that handles only BOOT_COMPLETED.
BootReceiver is a separate, minimal receiver that reschedules every alarm.
Splitting it from ReminderReceiver ensures the alarm-notification receiver can
remain unexported while boot rescheduling still works.
MY_PACKAGE_REPLACED: alarms from the previous app version are recomputed after
an update. TIMEZONE_CHANGED / TIME_SET: alarms are armed at absolute epochs
computed from local times and must be recomputed when the zone or clock moves.
All four actions are exempt from implicit broadcast restrictions.
-->
<receiver
android:name=".notifications.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
<action android:name="android.intent.action.TIME_SET" />
</intent-filter>
</receiver>

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
package com.mapgie.goflo.notifications

import android.app.AlarmManager
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import com.mapgie.goflo.MainActivity

// Handles Log and Snooze quick-action taps from custom alarm notifications.
Expand All @@ -32,22 +29,13 @@ class AlarmActionReceiver : BroadcastReceiver() {
ACTION_CUSTOM_SNOOZE -> {
val snoozeMinutes = intent.getIntExtra(EXTRA_SNOOZE_MINUTES, 10)
val triggerAt = System.currentTimeMillis() + snoozeMinutes * 60_000L
val snoozeIntent = Intent(context, ReminderReceiver::class.java).apply {
action = ACTION_CUSTOM_ALARM
putExtra(EXTRA_ALARM_ID, alarmId)
}
val pi = PendingIntent.getBroadcast(
// Shared identity with cancelCustomAlarm, so deleting or disabling the
// alarm also cancels a pending snoozed firing.
ReminderScheduler.setAlarm(
context,
(40000 + alarmId).toInt(),
snoozeIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
triggerAt,
ReminderScheduler.customAlarmSnoozePendingIntent(context, alarmId)
)
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && alarmManager.canScheduleExactAlarms()) {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)
} else {
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,17 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch

// Besides boot, this also covers app updates (alarms registered by the old version
// should be recomputed) and timezone/clock changes (all alarms are armed at absolute
// epochs computed from local times, so they drift when the zone or clock moves).
class BootReceiver : BroadcastReceiver() {

override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
if (intent.action != Intent.ACTION_BOOT_COMPLETED &&
intent.action != Intent.ACTION_MY_PACKAGE_REPLACED &&
intent.action != Intent.ACTION_TIMEZONE_CHANGED &&
intent.action != Intent.ACTION_TIME_CHANGED
) return

// goAsync() extends the BroadcastReceiver's active window so the OS doesn't
// kill the process before the coroutine finishes rescheduling alarms.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ class ReminderReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
ReminderScheduler.createChannel(context)
when (intent.action) {
// The three prediction reminders are one-shot alarms: after showing the
// notification, recompute and re-arm the next occurrences so the chain
// does not depend on a reboot or a settings visit.
ACTION_PREPERIOD -> showNotification(
context,
id = 1,
Expand Down Expand Up @@ -59,6 +62,17 @@ class ReminderReceiver : BroadcastReceiver() {
)
ACTION_CUSTOM_ALARM -> handleCustomAlarm(context, intent)
}

if (intent.action == ACTION_PREPERIOD || intent.action == ACTION_OVULATION || intent.action == ACTION_DAILY) {
val pendingResult = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
ReminderScheduler.refreshPredictionReminders(context)
} finally {
pendingResult.finish()
}
}
}
}

private fun handleCustomAlarm(context: Context, intent: Intent) {
Expand All @@ -71,6 +85,17 @@ class ReminderReceiver : BroadcastReceiver() {
CoroutineScope(Dispatchers.IO).launch {
try {
val alarm = app.customAlarmRepository.getById(alarmId) ?: return@launch
// A snoozed firing can arrive after the user disabled the alarm; the
// regular chain is cancelled on disable, but state can also change
// between arming and firing.
if (!alarm.isEnabled) return@launch

// Re-arm the next occurrence before evaluating today's condition so an
// exception below (or a condition miss) can never break the daily chain.
if (alarm.isRecurring) {
ReminderScheduler.scheduleCustomAlarm(context, alarm)
}

val periods = app.repository.getAllPeriods().first()
val avg = PeriodRepository.calculateAvgCycleLength(periods)
val today = LocalDate.now()
Expand Down Expand Up @@ -104,10 +129,6 @@ class ReminderReceiver : BroadcastReceiver() {
val categoryIds = app.customAlarmRepository.getCategoryIdsForAlarm(alarmId)
showCustomAlarmNotification(context, alarm, categoryIds)
}

if (alarm.isRecurring) {
ReminderScheduler.scheduleCustomAlarm(context, alarm)
}
} finally {
pendingResult.finish()
}
Expand Down
106 changes: 66 additions & 40 deletions app/src/main/java/com/mapgie/goflo/notifications/ReminderScheduler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import android.content.Intent
import android.media.AudioAttributes
import android.media.RingtoneManager
import android.os.Build
import com.mapgie.goflo.GoFloApplication
import com.mapgie.goflo.data.database.entities.CustomAlarm
import com.mapgie.goflo.data.database.entities.PeriodEntry
import com.mapgie.goflo.data.preferences.ReminderSettings
import com.mapgie.goflo.data.repository.PeriodRepository
import kotlinx.coroutines.flow.first
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
Expand All @@ -38,6 +40,37 @@ const val EXTRA_ALARM_ID = "alarm_id"

object ReminderScheduler {

// Exact alarms need no permission below Android 12 (API 31); on 12+ they need
// canScheduleExactAlarms(). Gating on SDK >= S alone silently downgraded every
// pre-12 device to inexact, Doze-deferred delivery.
private fun canUseExactAlarms(alarmManager: AlarmManager): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms()

internal fun setAlarm(context: Context, triggerAt: Long, pi: PendingIntent) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
if (canUseExactAlarms(alarmManager)) {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)
} else {
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)
}
}

/**
* Recomputes and re-arms the period-prediction reminders from current data.
*
* Prediction alarms are one-shot: each firing (and each period logged) changes the
* predicted dates, so ReminderReceiver and the period-logging screens call this to
* keep the chain alive. Without it, a pre-period or ovulation reminder fired once
* and the next cycle's reminder only existed again after a reboot or a visit to
* the reminder settings.
*/
suspend fun refreshPredictionReminders(context: Context) {
val app = context.applicationContext as GoFloApplication
val periods = app.repository.getAllPeriods().first()
val settings = app.preferencesStore.preferences.first().reminder
rescheduleAll(context, periods, settings)
}

fun createChannel(context: Context) {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

Expand Down Expand Up @@ -176,7 +209,13 @@ object ReminderScheduler {
if (settings.dailyDuringPeriodEnabled) {
val active = PeriodRepository.activePeriod(periods)
if (active != null) {
scheduleDailyRepeating(context, reminderHour, reminderMinute, useAlarm, alarmLabel, useSilent)
// One-shot exact alarm for the next occurrence; ReminderReceiver re-arms
// it on each firing while a period is active. setRepeating() is inexact
// and Doze-deferred, which made the daily reminder hours late or absent.
val now = LocalDateTime.now()
val todayAtTime = LocalDateTime.of(LocalDate.now(), LocalTime.of(reminderHour, reminderMinute))
val nextDate = if (todayAtTime.isAfter(now)) LocalDate.now() else LocalDate.now().plusDays(1)
scheduleAt(context, ACTION_DAILY, nextDate, reminderHour, reminderMinute, useAlarm, alarmLabel, useSilent)
}
}
}
Expand All @@ -192,18 +231,15 @@ object ReminderScheduler {
fun scheduleCustomAlarm(context: Context, alarm: CustomAlarm) {
if (!alarm.isEnabled) return
val triggerAt = nextTriggerMillis(alarm.hour, alarm.minute)
val pi = customAlarmPendingIntent(context, alarm.id)
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && alarmManager.canScheduleExactAlarms()) {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)
} else {
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)
}
setAlarm(context, triggerAt, customAlarmPendingIntent(context, alarm.id))
}

fun cancelCustomAlarm(context: Context, alarmId: Long) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarmManager.cancel(customAlarmPendingIntent(context, alarmId))
// A snoozed firing lives under its own PendingIntent identity: cancel it too,
// or a deleted/disabled alarm still fires once more after its snooze elapses.
alarmManager.cancel(customAlarmSnoozePendingIntent(context, alarmId))
}

fun rescheduleCustomAlarms(context: Context, alarms: List<CustomAlarm>) {
Expand All @@ -214,12 +250,12 @@ object ReminderScheduler {
}

private fun nextTriggerMillis(hour: Int, minute: Int): Long {
val now = System.currentTimeMillis()
val today = LocalDate.now()
var millis = LocalDateTime.of(today, LocalTime.of(hour, minute))
.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
if (millis <= now) millis += 86_400_000L
return millis
// Resolve "next occurrence of HH:mm" in local time per day, not by adding
// 24h of millis: across a DST transition the flat offset lands an hour off.
val now = LocalDateTime.now()
val todayAtTime = LocalDateTime.of(LocalDate.now(), LocalTime.of(hour, minute))
val next = if (todayAtTime.isAfter(now)) todayAtTime else todayAtTime.plusDays(1)
return next.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
}

fun customAlarmPendingIntent(context: Context, alarmId: Long): PendingIntent {
Expand All @@ -234,6 +270,21 @@ object ReminderScheduler {
)
}

// Identity of the alarm AlarmActionReceiver arms when the user taps Snooze.
// Kept here so scheduling (snooze) and cancellation (delete/disable) can never
// drift apart.
fun customAlarmSnoozePendingIntent(context: Context, alarmId: Long): PendingIntent {
val intent = Intent(context, ReminderReceiver::class.java)
.setAction(ACTION_CUSTOM_ALARM)
.putExtra(EXTRA_ALARM_ID, alarmId)
return PendingIntent.getBroadcast(
context,
(40000 + alarmId).toInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}

// ── Legacy helpers ────────────────────────────────────────────────────────

private fun scheduleAt(
Expand All @@ -253,32 +304,7 @@ object ReminderScheduler {

if (triggerAt <= System.currentTimeMillis()) return

val alarm = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val pi = pendingIntent(context, action, useAlarm, alarmLabel, useSilent)

if (useAlarm && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && alarm.canScheduleExactAlarms()) {
alarm.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)
} else {
alarm.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)
}
}

private fun scheduleDailyRepeating(context: Context, hour: Int, minute: Int, useAlarm: Boolean, alarmLabel: String = "", useSilent: Boolean = false) {
val now = System.currentTimeMillis()
val today = LocalDate.now()
var triggerAt = LocalDateTime.of(today.year, today.month, today.dayOfMonth, hour, minute)
.atZone(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
if (triggerAt <= now) triggerAt += 24 * 60 * 60 * 1000L

val alarm = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarm.setRepeating(
AlarmManager.RTC_WAKEUP,
triggerAt,
AlarmManager.INTERVAL_DAY,
pendingIntent(context, ACTION_DAILY, useAlarm, alarmLabel, useSilent)
)
setAlarm(context, triggerAt, pendingIntent(context, action, useAlarm, alarmLabel, useSilent))
}

private fun cancel(context: Context, action: String) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.mapgie.goflo.notifications.ReminderScheduler
import com.mapgie.goflo.widget.GoFloWidget
import com.mapgie.goflo.data.database.entities.PeriodEntry
import com.mapgie.goflo.data.database.entities.SymptomEntry
Expand Down Expand Up @@ -77,6 +78,8 @@ class HistoryViewModel(
)
repository.deletePeriod(period)
application?.let { GoFloWidget.updateAllWidgets(it) }
// Deleting a period changes the cycle predictions the reminders are armed on.
application?.let { runCatching { ReminderScheduler.refreshPredictionReminders(it) } }
}
}

Expand All @@ -89,6 +92,7 @@ class HistoryViewModel(
if (undo != null) {
repository.insertPeriod(undo.period, undo.symptoms.toList())
application?.let { GoFloWidget.updateAllWidgets(it) }
application?.let { runCatching { ReminderScheduler.refreshPredictionReminders(it) } }
}
_pendingDeleteIds.update { it - period.id }
}
Expand Down Expand Up @@ -119,6 +123,7 @@ class HistoryViewModel(
absorbed.endDate?.let { LocalDate.parse(it) }
)
application?.let { GoFloWidget.updateAllWidgets(it) }
application?.let { runCatching { ReminderScheduler.refreshPredictionReminders(it) } }
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.mapgie.goflo.data.database.entities.PeriodEntry
import com.mapgie.goflo.data.database.entities.TrackingCategory
import com.mapgie.goflo.data.database.entities.TrackingValue
import com.mapgie.goflo.data.preferences.AppPreferencesStore
import com.mapgie.goflo.notifications.ReminderScheduler
import com.mapgie.goflo.widget.GoFloWidget
import com.mapgie.goflo.data.repository.PeriodRepository
import kotlinx.coroutines.flow.MutableStateFlow
Expand Down Expand Up @@ -293,6 +294,10 @@ class LogPeriodViewModel(
syncSymptomsToTrackingLog(state)
syncPinnedCategoryLogs(state)
application?.let { GoFloWidget.updateAllWidgets(it) }
// Saving a period changes the cycle predictions, so the pre-period,
// ovulation, and daily reminders must be re-armed against the new dates.
// Failure must not report the (already successful) save as failed.
application?.let { runCatching { ReminderScheduler.refreshPredictionReminders(it) } }
_uiState.update { it.copy(saved = true) }
} catch (e: Exception) {
_uiState.update { it.copy(error = "Could not save entry. Please try again.") }
Expand Down Expand Up @@ -401,6 +406,7 @@ class LogPeriodViewModel(
)
repository.deletePeriod(period)
application?.let { GoFloWidget.updateAllWidgets(it) }
application?.let { runCatching { ReminderScheduler.refreshPredictionReminders(it) } }
_uiState.update { it.copy(deleted = true) }
} catch (e: Exception) {
_uiState.update { it.copy(error = "Could not delete entry. Please try again.") }
Expand Down
Loading
Loading