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..a525ee706 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,23 @@ fun formatter(): DateTimeFormatter = .append(DateTimeFormatter.ISO_LOCAL_TIME) .appendPattern("[XX]") .toFormatter() + + +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 + } +} + + + + 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 909c1f83d..6bc16793e 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 @@ -5,6 +5,7 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Build +import android.util.Log import androidx.preference.PreferenceManager import com.habitrpg.android.habitica.data.TaskRepository import com.habitrpg.android.habitica.extensions.withImmutableFlag @@ -17,6 +18,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,24 +37,48 @@ 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) { - this.removeAlarmForRemindersItem(reminder) + + fun removeAlarmsForTask(task: Task) { + CoroutineScope(Dispatchers.IO).launch { + task.reminders?.let { reminders -> + // Remove not only the immediate reminder, but also the next however many (upcomingReminderOccurrencesToSchedule) reminders + reminders.forEachIndexed { index, reminder -> + removeAlarmForRemindersItem(reminder, index) + } } } } @@ -81,35 +108,43 @@ class TaskAlarmManager( setAlarmsForTask(task) } - private fun setTimeForDailyReminder(remindersItem: RemindersItem?, task: Task): RemindersItem? { - val newTime = task.getNextReminderOccurrence(remindersItem, context) - remindersItem?.time = newTime?.withZoneSameLocal(ZoneId.systemDefault())?.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 zonedTime = remindersItem?.getLocalZonedDateTimeInstant() - if (remindersItem == null || - (reminderItemTask.type == TaskType.DAILY && zonedTime?.isBefore(now) == true && reminderItemTask.nextDue?.firstOrNull() != null) || - (reminderItemTask.type == TaskType.TODO && zonedTime?.isBefore(now) == true || zonedTime == null) - ) { + val reminderZonedTime = remindersItem.getLocalZonedDateTimeInstant() + + if (reminderZonedTime == null || reminderZonedTime.isBefore(now)) { return } + val intent = Intent(context, TaskReceiver::class.java) intent.action = remindersItem.id 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, @@ -129,13 +164,16 @@ class TaskAlarmManager( withImmutableFlag(PendingIntent.FLAG_CANCEL_CURRENT) ) - setAlarm(context, zonedTime.toEpochMilli(), sender) + + CoroutineScope(Dispatchers.IO).launch { + setAlarm(context, reminderZonedTime.toEpochMilli(), sender) + } } - private fun removeAlarmForRemindersItem(remindersItem: RemindersItem) { + private fun removeAlarmForRemindersItem(remindersItem: RemindersItem, occurrenceIndex: Int? = null) { val intent = Intent(context, TaskReceiver::class.java) intent.action = remindersItem.id - val intentId = remindersItem.id?.hashCode() ?: (0 and 0xfffffff) + val intentId = if (occurrenceIndex != null) (remindersItem.id?.hashCode() ?: (0 and 0xfffffff)) + occurrenceIndex else (remindersItem.id?.hashCode() ?: (0 and 0xfffffff)) val sender = PendingIntent.getBroadcast( context, intentId, @@ -218,16 +256,20 @@ class TaskAlarmManager( // For SDK >= Android 12, allows batching of reminders try { alarmManager?.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pendingIntent) + Log.d("TaskAlarmManager", "setAlarm: Scheduling for $time using setAndAllowWhileIdle") } 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 edbe560a0..2c61252e1 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,11 @@ 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.matchesRepeatDays import com.habitrpg.android.habitica.extensions.parseToZonedDateTime import com.habitrpg.android.habitica.extensions.toZonedDateTime import com.habitrpg.android.habitica.models.BaseMainObject @@ -24,9 +23,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,48 +331,111 @@ 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 - } - val reminderTime = remindersItem.time?.parseToZonedDateTime() - val zonedDateTimeNow = ZonedDateTime.now() - return if (nextDue?.firstOrNull() != null && (!isDisplayedActive || reminderTime?.isBefore(zonedDateTimeNow) == true) - ) { - 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 - ) - updatedDateTime.atZone(ZoneId.systemDefault()) - } else { - val nextDate = LocalDateTime.ofInstant(nextDue?.firstOrNull()?.toInstant(), ZoneId.systemDefault()) - val updatedDateTime: LocalDateTime = LocalDateTime.of( - nextDate.year, - nextDate.month, - nextDate.dayOfMonth, - reminderTime?.hour ?: 0, - reminderTime?.minute ?: 0 - ) - updatedDateTime.atZone(ZoneId.systemDefault()) + fun getNextReminderOccurrences(remindersItem: RemindersItem?, occurrences: Int): List? { + if (remindersItem == null) return null + + val reminderTime = remindersItem.time?.parseToZonedDateTime() ?: return null + val now = ZonedDateTime.now().withZoneSameInstant(ZoneId.systemDefault()) + var startDate = this.startDate?.toInstant()?.atZone(ZoneId.systemDefault()) ?: return null + val frequency = this.frequency ?: return null + val everyX = this.everyX ?: 1 + val repeatDays = this.repeat + startDate = startDate.withHour(reminderTime.hour).withMinute(reminderTime.minute) + + var dateTimeOccurenceToSchedule = ZonedDateTime.now().withZoneSameInstant(ZoneId.systemDefault()) + + val occurrencesList = mutableListOf() + + while (occurrencesList.size < occurrences) { + // Increment currentDate based on the frequency + dateTimeOccurenceToSchedule = when (frequency) { + Frequency.DAILY -> { + dateTimeOccurenceToSchedule = if (dateTimeOccurenceToSchedule.isBefore(startDate)) { + startDate + } else { + dateTimeOccurenceToSchedule.plusDays(everyX.toLong()).withHour(reminderTime.hour).withMinute(reminderTime.minute) + } + dateTimeOccurenceToSchedule + } + Frequency.WEEKLY -> { + // Set to start date if current date is earlier + if (dateTimeOccurenceToSchedule.isBefore(startDate)) { + dateTimeOccurenceToSchedule = startDate + } else if (repeatDays?.hasAnyDaySelected() == true) { + + var nextDueDate = dateTimeOccurenceToSchedule.withHour(reminderTime.hour).withMinute(reminderTime.minute) + // If the next due date already happened for today, increment it by one day. Otherwise, it will be scheduled for today. + if (nextDueDate.isBefore(now) && occurrencesList.size == 0) { + nextDueDate = nextDueDate.plusDays(1) + } + + // If the reminder being scheduled is not the first iteration of the reminder, increment it by one day + if (occurrencesList.size > 0) { + nextDueDate = nextDueDate.plusDays(1) + } + + while (!nextDueDate.matchesRepeatDays(repeatDays)) { + nextDueDate = nextDueDate.plusDays(1).withHour(reminderTime.hour).withMinute(reminderTime.minute) + } + // Calculate weeks since start and adjust for the correct interval + val weeksSinceStart = ChronoUnit.WEEKS.between(startDate.toLocalDate(), nextDueDate.toLocalDate()) + if (weeksSinceStart % everyX != 0L) { + val weeksToNextValidInterval = everyX - (weeksSinceStart % everyX) + nextDueDate = nextDueDate.plusWeeks(weeksToNextValidInterval) + // Find the exact next due day within the valid interval + while (!nextDueDate.matchesRepeatDays(repeatDays)) { + nextDueDate = nextDueDate.plusDays(1).withHour(reminderTime.hour).withMinute(reminderTime.minute) + } + } + + dateTimeOccurenceToSchedule = nextDueDate + } + // Set time to the reminder time + dateTimeOccurenceToSchedule = dateTimeOccurenceToSchedule.withHour(reminderTime.hour).withMinute(reminderTime.minute) + dateTimeOccurenceToSchedule + } + + Frequency.MONTHLY -> { + dateTimeOccurenceToSchedule = if (dateTimeOccurenceToSchedule.isBefore(startDate)) { + startDate + } else { + dateTimeOccurenceToSchedule.plusMonths(everyX.toLong()).withDayOfMonth(startDate.dayOfMonth).withHour(reminderTime.hour).withMinute(reminderTime.minute) + } + dateTimeOccurenceToSchedule + } + Frequency.YEARLY -> { + dateTimeOccurenceToSchedule = if (dateTimeOccurenceToSchedule.isBefore(startDate)) { + startDate + } else { + dateTimeOccurenceToSchedule.plusYears(everyX.toLong()).withDayOfMonth(startDate.dayOfMonth).withMonth(startDate.monthValue).withHour(reminderTime.hour).withMinute(reminderTime.minute) + } + dateTimeOccurenceToSchedule + } } - } else { - return reminderTime + + occurrencesList.add(dateTimeOccurenceToSchedule) } + + + return occurrencesList } fun parseMarkdown() { @@ -579,6 +641,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() diff --git a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/activities/TaskFormActivity.kt b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/activities/TaskFormActivity.kt index 45dfb5e09..fb0daf80c 100644 --- a/Habitica/src/main/java/com/habitrpg/android/habitica/ui/activities/TaskFormActivity.kt +++ b/Habitica/src/main/java/com/habitrpg/android/habitica/ui/activities/TaskFormActivity.kt @@ -835,6 +835,8 @@ class TaskFormActivity : BaseActivity() { task?.id?.let { lifecycleScope.launch(Dispatchers.Main) { taskRepository.deleteTask(it) + val taskCopy = task + taskCopy?.let { taskAlarmManager.removeAlarmsForTask(it) } } } finish()