From 19d04147d2a88b02236a80a7e0856c5f67d44607 Mon Sep 17 00:00:00 2001 From: Hafizzle Date: Mon, 27 Nov 2023 23:22:43 -0500 Subject: [PATCH] Schedule reminders logic updates --- .../extensions/ZonedDateExtensions.kt | 63 +++++++++ .../habitica/helpers/TaskAlarmManager.kt | 89 ++++++++---- .../android/habitica/models/tasks/Task.kt | 131 ++++++++++++------ 3 files changed, 215 insertions(+), 68 deletions(-) diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/extensions/ZonedDateExtensions.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/extensions/ZonedDateExtensions.kt index a63dd8cb3..9c4d19f64 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/extensions/ZonedDateExtensions.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/extensions/ZonedDateExtensions.kt @@ -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) +} + diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/helpers/TaskAlarmManager.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/helpers/TaskAlarmManager.kt index bb500c2dd..c1ef83615 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/helpers/TaskAlarmManager.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/helpers/TaskAlarmManager.kt @@ -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) } } } diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/models/tasks/Task.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/models/tasks/Task.kt index 091688240..fa488da1f 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/models/tasks/Task.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/models/tasks/Task.kt @@ -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? { + 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() - // 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? { if (daysOfMonth == null) { val daysOfMonth = mutableListOf()