This commit is contained in:
Phillip Thelen 2024-01-04 14:29:39 +01:00
commit 23dd2d6fd1
4 changed files with 209 additions and 76 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,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
}
}

View file

@ -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)
}
}
}

View file

@ -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<ZonedDateTime>? {
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<ZonedDateTime>()
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<Int>? {
if (daysOfMonth == null) {
val daysOfMonth = mutableListOf<Int>()

View file

@ -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()