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 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.DayOfWeek
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
@ -7,6 +9,7 @@ import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatterBuilder import java.time.format.DateTimeFormatterBuilder
import java.time.format.TextStyle import java.time.format.TextStyle
import java.time.temporal.ChronoUnit
import java.time.temporal.TemporalAccessor import java.time.temporal.TemporalAccessor
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@ -42,3 +45,63 @@ fun formatter(): DateTimeFormatter =
.append(DateTimeFormatter.ISO_LOCAL_TIME) .append(DateTimeFormatter.ISO_LOCAL_TIME)
.appendPattern("[XX]") .appendPattern("[XX]")
.toFormatter() .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.HLogger
import com.habitrpg.shared.habitica.LogLevel import com.habitrpg.shared.habitica.LogLevel
import com.habitrpg.shared.habitica.models.tasks.TaskType import com.habitrpg.shared.habitica.models.tasks.TaskType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -34,20 +36,41 @@ class TaskAlarmManager(
private var authenticationHandler: AuthenticationHandler private var authenticationHandler: AuthenticationHandler
) { ) {
private val am: AlarmManager? = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager 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) { private fun setAlarmsForTask(task: Task) {
task.reminders?.let { CoroutineScope(Dispatchers.IO).launch {
for (reminder in it) { task.reminders?.let { reminders ->
var currentReminder = reminder for (reminder in reminders) {
if (task.type == TaskType.DAILY) { val upcomingReminders = task.getNextReminderOccurrences(reminder, upcomingReminderOccurrencesToSchedule)
// Ensure that we set to the next available time upcomingReminders?.forEachIndexed { index, reminderNextOccurrenceTime ->
currentReminder = this.setTimeForDailyReminder(currentReminder, task) reminder?.time = reminderNextOccurrenceTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
setAlarmForRemindersItem(task, reminder, index)
}
} }
this.setAlarmForRemindersItem(task, currentReminder)
} }
} }
} }
private fun removeAlarmsForTask(task: Task) { private fun removeAlarmsForTask(task: Task) {
task.reminders?.let { task.reminders?.let {
for (reminder in it) { for (reminder in it) {
@ -81,26 +104,34 @@ class TaskAlarmManager(
setAlarmsForTask(task) 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. * Schedules an alarm for a given reminder associated with a task.
* The exception to this is if the task & reminder was newly created for the same time, *
* in which the alarm will be created - * This method takes a task and its associated reminder item to schedule an alarm.
* which is indicated by first nextDue being null (As the alarm is created before the API returns nextDue times) * 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 if (remindersItem == null) return
val now = ZonedDateTime.now().withZoneSameLocal(ZoneId.systemDefault())?.toInstant() val now = ZonedDateTime.now().withZoneSameLocal(ZoneId.systemDefault())?.toInstant()
val reminderZonedTime = remindersItem.getLocalZonedDateTimeInstant() 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) val intent = Intent(context, TaskReceiver::class.java)
@ -108,7 +139,9 @@ class TaskAlarmManager(
intent.putExtra(TASK_NAME_INTENT_KEY, reminderItemTask.text) intent.putExtra(TASK_NAME_INTENT_KEY, reminderItemTask.text)
intent.putExtra(TASK_ID_INTENT_KEY, reminderItemTask.id) 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 // Cancel alarm if already exists
val previousSender = PendingIntent.getBroadcast( val previousSender = PendingIntent.getBroadcast(
context, context,
@ -128,7 +161,10 @@ class TaskAlarmManager(
withImmutableFlag(PendingIntent.FLAG_CANCEL_CURRENT) withImmutableFlag(PendingIntent.FLAG_CANCEL_CURRENT)
) )
setAlarm(context, reminderZonedTime.toEpochMilli(), sender)
CoroutineScope(Dispatchers.IO).launch {
setAlarm(context, reminderZonedTime.toEpochMilli(), sender)
}
} }
private fun removeAlarmForRemindersItem(remindersItem: RemindersItem) { private fun removeAlarmForRemindersItem(remindersItem: RemindersItem) {
@ -220,13 +256,16 @@ class TaskAlarmManager(
} catch (ex: Exception) { } catch (ex: Exception) {
when (ex) { when (ex) {
is IllegalStateException, is SecurityException -> { 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 { } 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 package com.habitrpg.android.habitica.models.tasks
import android.content.Context
import android.os.Parcel import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
import android.text.Spanned import android.text.Spanned
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import com.habitrpg.android.habitica.R 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.parseToZonedDateTime
import com.habitrpg.android.habitica.extensions.toZonedDateTime import com.habitrpg.android.habitica.extensions.toZonedDateTime
import com.habitrpg.android.habitica.models.BaseMainObject import com.habitrpg.android.habitica.models.BaseMainObject
@ -24,9 +24,9 @@ import io.realm.annotations.PrimaryKey
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONException import org.json.JSONException
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.ZonedDateTime import java.time.ZonedDateTime
import java.time.temporal.ChronoUnit
import java.util.Date import java.util.Date
open class Task : RealmObject, BaseMainObject, Parcelable, BaseTask { open class Task : RealmObject, BaseMainObject, Parcelable, BaseTask {
@ -332,56 +332,97 @@ open class Task : RealmObject, BaseMainObject, Parcelable, BaseTask {
fun checkIfDue(): Boolean = isDue == true fun checkIfDue(): Boolean = isDue == true
/** /**
* When manually changing the alarm time, nextDue will provide the previously set upcoming alarm - * Calculates a list of the next upcoming reminder occurrences based on the reminder's configuration.
* 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.
* *
* Alarms automatically rescheduled -> nextDue used. * This method determines the next few occurrences of a reminder, taking into account its start date,
* A alarm that already passed today and is rescheduled for a upcoming time today -> Schedule for today (instead of using nextDue) * 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? { fun getNextReminderOccurrences(remindersItem: RemindersItem?, occurrences: Int): List<ZonedDateTime>? {
if (remindersItem == null) { if (remindersItem == null) return 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 occurrencesList = mutableListOf<ZonedDateTime>()
val zonedDateTimeNow = ZonedDateTime.now()
// Check if the reminder is scheduled to repeat today while (occurrencesList.size < occurrences) {
val repeatingDays = repeat?.dayStrings(context) if (currentDate.isReminderDue(reminderTime, frequency, everyX, repeatDays, currentDate.dayOfMonth)) {
val isScheduledForToday = repeatingDays?.find { day -> day == zonedDateTimeNow.dayOfWeekString() } occurrencesList.add(currentDate.withHour(reminderTime.hour).withMinute(reminderTime.minute))
if (isScheduledForToday != null) { }
val currentDateTime = LocalDateTime.now()
val updatedDateTime: LocalDateTime = LocalDateTime.of( // Increment currentDate based on the frequency
currentDateTime.year, currentDate = when (frequency) {
currentDateTime.month, Frequency.DAILY -> currentDate.plusDays(everyX.toLong())
currentDateTime.dayOfMonth, Frequency.WEEKLY -> {
reminderTime?.hour ?: 0, if (repeatDays?.hasAnyDaySelected() == true) {
reminderTime?.minute ?: 0 // Find the next day of the week that matches the player's selection
) var nextDueDate = currentDate
return updatedDateTime.atZone(ZoneId.systemDefault()) 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 return occurrencesList
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
} }
fun parseMarkdown() { fun parseMarkdown() {
parsedText = MarkdownParser.parseMarkdown(text) parsedText = MarkdownParser.parseMarkdown(text)
parsedNotes = MarkdownParser.parseMarkdown(notes) 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>? { fun getDaysOfMonth(): List<Int>? {
if (daysOfMonth == null) { if (daysOfMonth == null) {
val daysOfMonth = mutableListOf<Int>() val daysOfMonth = mutableListOf<Int>()