Schedule reminders logic updates

This commit is contained in:
Hafizzle 2023-11-27 23:22:43 -05:00 committed by Phillip Thelen
parent 2b6e49f6f5
commit 19d04147d2
3 changed files with 215 additions and 68 deletions

View file

@ -1,5 +1,7 @@
package com.habitrpg.android.habitica.extensions
import com.habitrpg.android.habitica.models.tasks.Days
import com.habitrpg.shared.habitica.models.tasks.Frequency
import java.time.DayOfWeek
import java.time.LocalDateTime
import java.time.ZoneId
@ -7,6 +9,7 @@ import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatterBuilder
import java.time.format.TextStyle
import java.time.temporal.ChronoUnit
import java.time.temporal.TemporalAccessor
import java.util.Date
import java.util.Locale
@ -42,3 +45,63 @@ fun formatter(): DateTimeFormatter =
.append(DateTimeFormatter.ISO_LOCAL_TIME)
.appendPattern("[XX]")
.toFormatter()
fun ZonedDateTime.matchesDailyInterval(startDate: ZonedDateTime, everyX: Int): Boolean {
val daysBetween = ChronoUnit.DAYS.between(startDate.toLocalDate(), this.toLocalDate())
return daysBetween % everyX == 0L
}
fun ZonedDateTime.matchesWeeklyInterval(startDate: ZonedDateTime, everyX: Int): Boolean {
val weeksBetween = ChronoUnit.WEEKS.between(startDate.toLocalDate(), this.toLocalDate())
return weeksBetween % everyX == 0L
}
fun ZonedDateTime.matchesMonthlyInterval(startDate: ZonedDateTime, everyX: Int, dayOfMonth: Int): Boolean {
val monthsBetween = ChronoUnit.MONTHS.between(startDate.toLocalDate(), this.toLocalDate())
return this.dayOfMonth == dayOfMonth && monthsBetween % everyX == 0L
}
fun ZonedDateTime.matchesYearlyInterval(startDate: ZonedDateTime, everyX: Int): Boolean {
val yearsBetween = ChronoUnit.YEARS.between(startDate.toLocalDate(), this.toLocalDate())
return yearsBetween % everyX == 0L
}
fun ZonedDateTime.matchesRepeatDays(repeatDays: Days?): Boolean {
repeatDays ?: return true // If no repeatDays specified, assume it matches
return when (this.dayOfWeek) {
DayOfWeek.MONDAY -> repeatDays.m
DayOfWeek.TUESDAY -> repeatDays.t
DayOfWeek.WEDNESDAY -> repeatDays.w
DayOfWeek.THURSDAY -> repeatDays.th
DayOfWeek.FRIDAY -> repeatDays.f
DayOfWeek.SATURDAY -> repeatDays.s
DayOfWeek.SUNDAY -> repeatDays.su
else -> false
}
}
// Probably shouldn't be an extension function, but it's easier to test this way
fun ZonedDateTime.isReminderDue(reminderTime: ZonedDateTime, frequency: Frequency, everyX: Int, repeatDays: Days?, dayOfMonth: Int): Boolean {
// Check if the reminder is due based on the frequency and everyX
when (frequency) {
Frequency.DAILY -> {
if (!this.matchesDailyInterval(reminderTime, everyX)) return false
}
Frequency.WEEKLY -> {
if (!this.matchesWeeklyInterval(reminderTime, everyX)) return false
}
Frequency.MONTHLY -> {
if (!this.matchesMonthlyInterval(reminderTime, everyX, dayOfMonth)) return false
}
Frequency.YEARLY -> {
if (!this.matchesYearlyInterval(reminderTime, everyX)) return false
}
}
// Check if the reminder is due based on the repeatDays
return this.matchesRepeatDays(repeatDays)
}

View file

@ -17,6 +17,8 @@ import com.habitrpg.common.habitica.helpers.ExceptionHandler
import com.habitrpg.shared.habitica.HLogger
import com.habitrpg.shared.habitica.LogLevel
import com.habitrpg.shared.habitica.models.tasks.TaskType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
@ -34,20 +36,41 @@ class TaskAlarmManager(
private var authenticationHandler: AuthenticationHandler
) {
private val am: AlarmManager? = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
private val upcomingReminderOccurrencesToSchedule = 3
/**
* Schedules multiple alarms for each reminder associated with a given task.
*
* This method iterates through all reminders of a task and schedules multiple upcoming alarms for each.
* It determines the upcoming reminder times based on the reminder's configuration (like frequency, repeat days, etc.)
* and schedules an alarm for each of these times.
*
* For each reminder, it updates the reminder's time to each upcoming occurrence and then calls
* `setAlarmForRemindersItem` to handle the actual alarm scheduling. This ensures that each reminder
* is scheduled accurately according to its specified rules and times.
*
* This method is particularly useful for reminders that need to be repeated at regular intervals
* (e.g., daily, weekly) or on specific days, as it schedules multiple occurrences in advance.
*
* @param task The task for which the alarms are being set. Each reminder in the task's reminder list
* is processed to schedule the upcoming alarms.
*/
private fun setAlarmsForTask(task: Task) {
task.reminders?.let {
for (reminder in it) {
var currentReminder = reminder
if (task.type == TaskType.DAILY) {
// Ensure that we set to the next available time
currentReminder = this.setTimeForDailyReminder(currentReminder, task)
CoroutineScope(Dispatchers.IO).launch {
task.reminders?.let { reminders ->
for (reminder in reminders) {
val upcomingReminders = task.getNextReminderOccurrences(reminder, upcomingReminderOccurrencesToSchedule)
upcomingReminders?.forEachIndexed { index, reminderNextOccurrenceTime ->
reminder?.time = reminderNextOccurrenceTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
setAlarmForRemindersItem(task, reminder, index)
}
}
this.setAlarmForRemindersItem(task, currentReminder)
}
}
}
private fun removeAlarmsForTask(task: Task) {
task.reminders?.let {
for (reminder in it) {
@ -81,26 +104,34 @@ class TaskAlarmManager(
setAlarmsForTask(task)
}
private fun setTimeForDailyReminder(remindersItem: RemindersItem?, task: Task): RemindersItem? {
val newTime = task.getNextReminderOccurrence(remindersItem, context)
remindersItem?.time = newTime?.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
return remindersItem
}
/**
* If reminderItem time is before now, a new reminder will not be created until the reminder passes.
* The exception to this is if the task & reminder was newly created for the same time,
* in which the alarm will be created -
* which is indicated by first nextDue being null (As the alarm is created before the API returns nextDue times)
* Schedules an alarm for a given reminder associated with a task.
*
* This method takes a task and its associated reminder item to schedule an alarm.
* It first checks if the reminder time is valid and not in the past. If the reminder time
* is valid, it prepares an intent for the alarm, uniquely identified by combining the reminder's ID
* and its scheduled time. This unique identifier ensures that each reminder occurrence is distinctly handled.
*
* If an alarm with the same identifier already exists, it is cancelled and replaced with the new one.
* This ensures that reminders are always up to date with their latest scheduled times.
*
* The alarm is scheduled to trigger at the exact time specified in the reminder. Upon triggering,
* it will send a broadcast to `TaskReceiver`, which should handle the reminder notification.
*
* @param reminderItemTask The task associated with the reminder.
* @param remindersItem The reminder item containing details like ID and the time for the reminder.
* If this is null, the method returns immediately without scheduling an alarm.
*/
private fun setAlarmForRemindersItem(reminderItemTask: Task, remindersItem: RemindersItem?) {
private fun setAlarmForRemindersItem(reminderItemTask: Task, remindersItem: RemindersItem?, occurrenceIndex: Int) {
if (remindersItem == null) return
val now = ZonedDateTime.now().withZoneSameLocal(ZoneId.systemDefault())?.toInstant()
val reminderZonedTime = remindersItem.getLocalZonedDateTimeInstant()
if (reminderZonedTime == null || reminderZonedTime.isBefore(now)) return
if (reminderZonedTime == null || reminderZonedTime.isBefore(now)) {
return
}
val intent = Intent(context, TaskReceiver::class.java)
@ -108,7 +139,9 @@ class TaskAlarmManager(
intent.putExtra(TASK_NAME_INTENT_KEY, reminderItemTask.text)
intent.putExtra(TASK_ID_INTENT_KEY, reminderItemTask.id)
val intentId = remindersItem.id?.hashCode() ?: (0 and 0xfffffff)
// Create a unique identifier based on remindersItem.id and the occurrence index
val intentId = (remindersItem.id?.hashCode() ?: 0) + occurrenceIndex
// Cancel alarm if already exists
val previousSender = PendingIntent.getBroadcast(
context,
@ -128,7 +161,10 @@ class TaskAlarmManager(
withImmutableFlag(PendingIntent.FLAG_CANCEL_CURRENT)
)
setAlarm(context, reminderZonedTime.toEpochMilli(), sender)
CoroutineScope(Dispatchers.IO).launch {
setAlarm(context, reminderZonedTime.toEpochMilli(), sender)
}
}
private fun removeAlarmForRemindersItem(remindersItem: RemindersItem) {
@ -220,13 +256,16 @@ class TaskAlarmManager(
} catch (ex: Exception) {
when (ex) {
is IllegalStateException, is SecurityException -> {
alarmManager?.setWindow(AlarmManager.RTC_WAKEUP, time, 60000, pendingIntent)
alarmManager?.setWindow(AlarmManager.RTC_WAKEUP, time, 600000, pendingIntent)
}
else -> throw ex
else -> {
throw ex
}
}
}
} else {
alarmManager?.setWindow(AlarmManager.RTC_WAKEUP, time, 60000, pendingIntent)
alarmManager?.setWindow(AlarmManager.RTC_WAKEUP, time, 600000, pendingIntent)
}
}
}

View file

@ -1,12 +1,12 @@
package com.habitrpg.android.habitica.models.tasks
import android.content.Context
import android.os.Parcel
import android.os.Parcelable
import android.text.Spanned
import com.google.gson.annotations.SerializedName
import com.habitrpg.android.habitica.R
import com.habitrpg.android.habitica.extensions.dayOfWeekString
import com.habitrpg.android.habitica.extensions.isReminderDue
import com.habitrpg.android.habitica.extensions.matchesRepeatDays
import com.habitrpg.android.habitica.extensions.parseToZonedDateTime
import com.habitrpg.android.habitica.extensions.toZonedDateTime
import com.habitrpg.android.habitica.models.BaseMainObject
@ -24,9 +24,9 @@ import io.realm.annotations.PrimaryKey
import org.json.JSONArray
import org.json.JSONException
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.temporal.ChronoUnit
import java.util.Date
open class Task : RealmObject, BaseMainObject, Parcelable, BaseTask {
@ -332,56 +332,97 @@ open class Task : RealmObject, BaseMainObject, Parcelable, BaseTask {
fun checkIfDue(): Boolean = isDue == true
/**
* When manually changing the alarm time, nextDue will provide the previously set upcoming alarm -
* however if the alarm was changed to a upcoming time today (and not before the current time) AND the previous alarm
* set for today already passed, we want to check if the alarm repeat/days includes today as well.
* If so - use today's date to schedule an alarm. If not, use nextDue.
* Calculates a list of the next upcoming reminder occurrences based on the reminder's configuration.
*
* Alarms automatically rescheduled -> nextDue used.
* A alarm that already passed today and is rescheduled for a upcoming time today -> Schedule for today (instead of using nextDue)
* This method determines the next few occurrences of a reminder, taking into account its start date,
* frequency, repetition pattern, and other settings. It generates a list of ZonedDateTime instances,
* each representing a future point in time when the reminder is due.
*
* The method iterates starting from the reminder's start date and checks each day to see if it matches
* the criteria for triggering the reminder (like matching the specified days of the week, frequency, etc.).
* Once a matching date is found, it's added to the list with the specific time of the reminder.
*
* This is useful for scheduling reminders that need to occur multiple times in the future according
* to a specific pattern (e.g., every Monday and Wednesday at 10 AM).
*
* @param remindersItem The reminder item containing the configuration for calculating occurrences.
* @param occurrences The number of upcoming reminder occurrences to calculate.
* @return A list of ZonedDateTime instances, each representing an upcoming reminder occurrence.
* Returns null if the reminder item is null or essential information for calculation is missing.
*/
fun getNextReminderOccurrence(remindersItem: RemindersItem?, context: Context): ZonedDateTime? {
if (remindersItem == null) {
return null
fun getNextReminderOccurrences(remindersItem: RemindersItem?, occurrences: Int): List<ZonedDateTime>? {
if (remindersItem == null) return null
val reminderTime = remindersItem.time?.parseToZonedDateTime() ?: return null
val startDate = this.startDate?.toInstant()?.atZone(ZoneId.systemDefault()) ?: return null
val frequency = this.frequency ?: return null
val everyX = this.everyX ?: 1
val repeatDays = this.repeat
// Determine the starting point: either the start date or the current date if start date is in the past
var currentDate = ZonedDateTime.now().withZoneSameInstant(ZoneId.systemDefault())
if (startDate.isAfter(currentDate)) {
currentDate = startDate
}
val reminderTime = remindersItem.time?.parseToZonedDateTime()
val zonedDateTimeNow = ZonedDateTime.now()
val occurrencesList = mutableListOf<ZonedDateTime>()
// Check if the reminder is scheduled to repeat today
val repeatingDays = repeat?.dayStrings(context)
val isScheduledForToday = repeatingDays?.find { day -> day == zonedDateTimeNow.dayOfWeekString() }
if (isScheduledForToday != null) {
val currentDateTime = LocalDateTime.now()
val updatedDateTime: LocalDateTime = LocalDateTime.of(
currentDateTime.year,
currentDateTime.month,
currentDateTime.dayOfMonth,
reminderTime?.hour ?: 0,
reminderTime?.minute ?: 0
)
return updatedDateTime.atZone(ZoneId.systemDefault())
while (occurrencesList.size < occurrences) {
if (currentDate.isReminderDue(reminderTime, frequency, everyX, repeatDays, currentDate.dayOfMonth)) {
occurrencesList.add(currentDate.withHour(reminderTime.hour).withMinute(reminderTime.minute))
}
// Increment currentDate based on the frequency
currentDate = when (frequency) {
Frequency.DAILY -> currentDate.plusDays(everyX.toLong())
Frequency.WEEKLY -> {
if (repeatDays?.hasAnyDaySelected() == true) {
// Find the next day of the week that matches the player's selection
var nextDueDate = currentDate
while (!nextDueDate.matchesRepeatDays(repeatDays)) {
nextDueDate = nextDueDate.plusDays(1)
}
// Calculate the number of weeks between the start date and the next due date
val weeksSinceStart = ChronoUnit.WEEKS.between(startDate.toLocalDate(), nextDueDate.toLocalDate())
// Check if the next due date falls in the correct week interval
if (weeksSinceStart % everyX != 0L) {
// If it doesn't, find the start of the next valid interval
val weeksToNextValidInterval = everyX - (weeksSinceStart % everyX)
nextDueDate = nextDueDate.plusWeeks(weeksToNextValidInterval)
while (!nextDueDate.matchesRepeatDays(repeatDays)) {
nextDueDate = nextDueDate.plusDays(1)
}
}
// Ensure the next due date is in the future
val now = ZonedDateTime.now().withZoneSameInstant(ZoneId.systemDefault())
if (nextDueDate.isBefore(now)) {
nextDueDate = nextDueDate.plusWeeks(everyX.toLong())
while (!nextDueDate.matchesRepeatDays(repeatDays)) {
nextDueDate = nextDueDate.plusDays(1)
}
}
currentDate = nextDueDate
} else {
// If no specific days are selected, increment by the number of weeks
currentDate = currentDate.plusWeeks(everyX.toLong())
}
currentDate
}
Frequency.MONTHLY -> currentDate.plusMonths(everyX.toLong()).withDayOfMonth(startDate.dayOfMonth)
Frequency.YEARLY -> currentDate.plusYears(everyX.toLong()).withDayOfMonth(startDate.dayOfMonth).withMonth(startDate.monthValue)
}
}
// If the reminder is not scheduled to repeat today, use the first upcoming nextDue date
val today = LocalDate.now()
// Filter out the dates that are in the past
val futureNextDues = nextDue?.filter { nextDueDate -> nextDueDate.toInstant().atZone(ZonedDateTime.now().zone).toLocalDate().isAfter(today) }
val earliestFutureDate = futureNextDues?.minByOrNull { it }
if (earliestFutureDate != null) {
return earliestFutureDate.toInstant()
.atZone(ZonedDateTime.now().zone)
.withHour(reminderTime?.hour ?: 0)
.withMinute(reminderTime?.minute ?: 0)
}
// If there are no upcoming nextDue dates, use the first upcoming reminder time
return reminderTime
return occurrencesList
}
fun parseMarkdown() {
parsedText = MarkdownParser.parseMarkdown(text)
parsedNotes = MarkdownParser.parseMarkdown(notes)
@ -585,6 +626,10 @@ open class Task : RealmObject, BaseMainObject, Parcelable, BaseTask {
}
}
private fun Days.hasAnyDaySelected(): Boolean {
return m || t || w || th || f || s || su
}
fun getDaysOfMonth(): List<Int>? {
if (daysOfMonth == null) {
val daysOfMonth = mutableListOf<Int>()