mirror of
https://github.com/sudoxnym/habitica-android.git
synced 2026-05-20 12:49:02 +00:00
Merge branch 'main' of https://github.com/HabitRPG/habitica-android
This commit is contained in:
commit
23dd2d6fd1
4 changed files with 209 additions and 76 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue