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 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,23 @@ fun formatter(): DateTimeFormatter =
.append(DateTimeFormatter.ISO_LOCAL_TIME) .append(DateTimeFormatter.ISO_LOCAL_TIME)
.appendPattern("[XX]") .appendPattern("[XX]")
.toFormatter() .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.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.util.Log
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.habitrpg.android.habitica.data.TaskRepository import com.habitrpg.android.habitica.data.TaskRepository
import com.habitrpg.android.habitica.extensions.withImmutableFlag 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.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,24 +37,48 @@ 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) {
task.reminders?.let { fun removeAlarmsForTask(task: Task) {
for (reminder in it) { CoroutineScope(Dispatchers.IO).launch {
this.removeAlarmForRemindersItem(reminder) 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) 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. * 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
val now = ZonedDateTime.now().withZoneSameLocal(ZoneId.systemDefault())?.toInstant() val now = ZonedDateTime.now().withZoneSameLocal(ZoneId.systemDefault())?.toInstant()
val zonedTime = remindersItem?.getLocalZonedDateTimeInstant() val reminderZonedTime = remindersItem.getLocalZonedDateTimeInstant()
if (remindersItem == null ||
(reminderItemTask.type == TaskType.DAILY && zonedTime?.isBefore(now) == true && reminderItemTask.nextDue?.firstOrNull() != null) || if (reminderZonedTime == null || reminderZonedTime.isBefore(now)) {
(reminderItemTask.type == TaskType.TODO && zonedTime?.isBefore(now) == true || zonedTime == null)
) {
return return
} }
val intent = Intent(context, TaskReceiver::class.java) val intent = Intent(context, TaskReceiver::class.java)
intent.action = remindersItem.id intent.action = remindersItem.id
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,
@ -129,13 +164,16 @@ class TaskAlarmManager(
withImmutableFlag(PendingIntent.FLAG_CANCEL_CURRENT) 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) val intent = Intent(context, TaskReceiver::class.java)
intent.action = remindersItem.id 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( val sender = PendingIntent.getBroadcast(
context, context,
intentId, intentId,
@ -218,16 +256,20 @@ class TaskAlarmManager(
// For SDK >= Android 12, allows batching of reminders // For SDK >= Android 12, allows batching of reminders
try { try {
alarmManager?.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pendingIntent) alarmManager?.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pendingIntent)
Log.d("TaskAlarmManager", "setAlarm: Scheduling for $time using setAndAllowWhileIdle")
} 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,11 @@
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.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 +23,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,48 +331,111 @@ 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 reminderTime = remindersItem.time?.parseToZonedDateTime() val now = ZonedDateTime.now().withZoneSameInstant(ZoneId.systemDefault())
val zonedDateTimeNow = ZonedDateTime.now() var startDate = this.startDate?.toInstant()?.atZone(ZoneId.systemDefault()) ?: return null
return if (nextDue?.firstOrNull() != null && (!isDisplayedActive || reminderTime?.isBefore(zonedDateTimeNow) == true) val frequency = this.frequency ?: return null
) { val everyX = this.everyX ?: 1
val repeatingDays = repeat?.dayStrings(context) val repeatDays = this.repeat
val isScheduledForToday = repeatingDays?.find { day -> day == zonedDateTimeNow.dayOfWeekString() } startDate = startDate.withHour(reminderTime.hour).withMinute(reminderTime.minute)
if (isScheduledForToday != null) {
val currentDateTime = LocalDateTime.now() var dateTimeOccurenceToSchedule = ZonedDateTime.now().withZoneSameInstant(ZoneId.systemDefault())
val updatedDateTime: LocalDateTime = LocalDateTime.of(
currentDateTime.year, val occurrencesList = mutableListOf<ZonedDateTime>()
currentDateTime.month,
currentDateTime.dayOfMonth, while (occurrencesList.size < occurrences) {
reminderTime?.hour ?: 0, // Increment currentDate based on the frequency
reminderTime?.minute ?: 0 dateTimeOccurenceToSchedule = when (frequency) {
) Frequency.DAILY -> {
updatedDateTime.atZone(ZoneId.systemDefault()) dateTimeOccurenceToSchedule = if (dateTimeOccurenceToSchedule.isBefore(startDate)) {
} else { startDate
val nextDate = LocalDateTime.ofInstant(nextDue?.firstOrNull()?.toInstant(), ZoneId.systemDefault()) } else {
val updatedDateTime: LocalDateTime = LocalDateTime.of( dateTimeOccurenceToSchedule.plusDays(everyX.toLong()).withHour(reminderTime.hour).withMinute(reminderTime.minute)
nextDate.year, }
nextDate.month, dateTimeOccurenceToSchedule
nextDate.dayOfMonth, }
reminderTime?.hour ?: 0, Frequency.WEEKLY -> {
reminderTime?.minute ?: 0 // Set to start date if current date is earlier
) if (dateTimeOccurenceToSchedule.isBefore(startDate)) {
updatedDateTime.atZone(ZoneId.systemDefault()) 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() { 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>? { fun getDaysOfMonth(): List<Int>? {
if (daysOfMonth == null) { if (daysOfMonth == null) {
val daysOfMonth = mutableListOf<Int>() val daysOfMonth = mutableListOf<Int>()

View file

@ -835,6 +835,8 @@ class TaskFormActivity : BaseActivity() {
task?.id?.let { task?.id?.let {
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
taskRepository.deleteTask(it) taskRepository.deleteTask(it)
val taskCopy = task
taskCopy?.let { taskAlarmManager.removeAlarmsForTask(it) }
} }
} }
finish() finish()