Add support for relative and repeating alarms

pull/1765/head
Alex Baker 2 years ago
parent 861a82baf4
commit 2d9c1638dc

File diff suppressed because it is too large Load Diff

@ -1,22 +1,37 @@
package com.todoroo.astrid.alarms
import com.natpryce.makeiteasy.MakeItEasy.with
import com.todoroo.andlib.utility.DateUtilities.now
import com.todoroo.astrid.dao.TaskDao
import com.todoroo.astrid.data.Task
import com.todoroo.astrid.data.Task.Companion.HIDE_UNTIL_DUE
import com.todoroo.astrid.data.Task.Companion.HIDE_UNTIL_DUE_TIME
import com.todoroo.astrid.data.Task.Companion.URGENCY_SPECIFIC_DAY_TIME
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.UninstallModules
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.tasks.data.Alarm
import org.tasks.data.Alarm.Companion.whenDue
import org.tasks.data.Alarm.Companion.whenOverdue
import org.tasks.data.Alarm.Companion.whenStarted
import org.tasks.data.AlarmDao
import org.tasks.date.DateTimeUtils.newDateTime
import org.tasks.date.DateTimeUtils.toDateTime
import org.tasks.injection.InjectingTestCase
import org.tasks.injection.ProductionModule
import org.tasks.jobs.AlarmEntry
import org.tasks.jobs.NotificationQueue
import org.tasks.makers.TaskMaker.COMPLETION_TIME
import org.tasks.makers.TaskMaker.DELETION_TIME
import org.tasks.makers.TaskMaker.DUE_DATE
import org.tasks.makers.TaskMaker.DUE_TIME
import org.tasks.makers.TaskMaker.HIDE_TYPE
import org.tasks.makers.TaskMaker.REMINDER_LAST
import org.tasks.makers.TaskMaker.newTask
import org.tasks.time.DateTime
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@UninstallModules(ProductionModule::class)
@ -29,24 +44,195 @@ class AlarmJobServiceTest : InjectingTestCase() {
@Test
fun scheduleAlarm() = runBlocking {
val task = newTask()
taskDao.createNew(task)
val alarmTime = DateTime(2017, 9, 24, 19, 57)
val alarm = Alarm(task.id, alarmTime.millis)
alarm.id = alarmDao.insert(alarm)
alarmService.scheduleAllAlarms()
val task = taskDao.createNew(newTask())
val alarm = insertAlarm(Alarm(task, DateTime(2017, 9, 24, 19, 57).millis))
assertEquals(listOf(AlarmEntry(alarm)), jobs.getJobs())
verify(AlarmEntry(alarm, task, DateTime(2017, 9, 24, 19, 57).millis))
}
@Test
fun ignoreStaleAlarm() = runBlocking {
val alarmTime = DateTime(2017, 9, 24, 19, 57)
val task = newTask(with(REMINDER_LAST, alarmTime.endOfMinute()))
taskDao.createNew(task)
alarmDao.insert(Alarm(task.id, alarmTime.millis))
val task = taskDao.createNew(newTask(with(REMINDER_LAST, alarmTime.endOfMinute())))
alarmDao.insert(Alarm(task, alarmTime.millis))
verify()
}
@Test
fun scheduleReminderAtDefaultDue() = runBlocking {
val now = newDateTime()
val task = taskDao.insert(newTask(with(DUE_DATE, now)))
val alarm = alarmDao.insert(whenDue(task))
verify(AlarmEntry(alarm, task, now.startOfDay().withHourOfDay(18).millis))
}
@Test
fun scheduleReminderAtDefaultDueTime() = runBlocking {
val now = newDateTime()
val task = taskDao.insert(newTask(with(DUE_TIME, now)))
val alarm = alarmDao.insert(whenDue(task))
verify(AlarmEntry(alarm, task, now.startOfMinute().millis + 1000))
}
@Test
fun scheduleReminderAtDefaultStart() = runBlocking {
val now = newDateTime()
val task = taskDao.insert(newTask(with(DUE_DATE, now), with(HIDE_TYPE, HIDE_UNTIL_DUE)))
val alarm = alarmDao.insert(whenStarted(task))
verify(AlarmEntry(alarm, task, now.startOfDay().withHourOfDay(18).millis))
}
@Test
fun scheduleReminerAtDefaultStartTime() = runBlocking {
val now = newDateTime()
val task = taskDao.insert(newTask(with(DUE_TIME, now), with(HIDE_TYPE, HIDE_UNTIL_DUE_TIME)))
val alarm = alarmDao.insert(whenStarted(task))
verify(AlarmEntry(alarm, task, now.startOfMinute().millis + 1000))
}
@Test
fun dontScheduleReminderForCompletedTask() = runBlocking {
val task = taskDao.insert(
newTask(
with(DUE_DATE, newDateTime()),
with(COMPLETION_TIME, newDateTime())
)
)
alarmDao.insert(whenDue(task))
verify()
}
@Test
fun dontScheduleReminderForDeletedTask() = runBlocking {
val task = taskDao.insert(
newTask(
with(DUE_DATE, newDateTime()),
with(DELETION_TIME, newDateTime())
)
)
alarmDao.insert(whenDue(task))
verify()
}
@Test
fun scheduleRelativeAfterDue() = runBlocking {
val now = newDateTime()
val task = taskDao.insert(newTask(with(DUE_DATE, now)))
val alarm = alarmDao.insert(Alarm(task, TimeUnit.DAYS.toMillis(1), Alarm.TYPE_REL_END))
verify(AlarmEntry(alarm, task, now.plusDays(1).startOfDay().withHourOfDay(18).millis))
}
@Test
fun scheduleRelativeAfterDueTime() = runBlocking {
val now = newDateTime()
val task = taskDao.insert(newTask(with(DUE_TIME, now)))
val alarm = alarmDao.insert(Alarm(task, TimeUnit.DAYS.toMillis(1), Alarm.TYPE_REL_END))
verify(AlarmEntry(alarm, task, now.plusDays(1).startOfMinute().millis + 1000))
}
@Test
fun scheduleRelativeAfterStart() = runBlocking {
val now = newDateTime()
val task = taskDao.insert(newTask(with(DUE_DATE, now), with(HIDE_TYPE, HIDE_UNTIL_DUE)))
val alarm = alarmDao.insert(Alarm(task, TimeUnit.DAYS.toMillis(1), Alarm.TYPE_REL_START))
verify(AlarmEntry(alarm, task, now.plusDays(1).startOfDay().withHourOfDay(18).millis))
}
@Test
fun scheduleRelativeAfterStartTime() = runBlocking {
val now = newDateTime()
val task = taskDao.insert(newTask(with(DUE_TIME, now), with(HIDE_TYPE, HIDE_UNTIL_DUE_TIME)))
val alarm = alarmDao.insert(Alarm(task, TimeUnit.DAYS.toMillis(1), Alarm.TYPE_REL_START))
verify(AlarmEntry(alarm, task, now.plusDays(1).startOfMinute().millis + 1000))
}
@Test
fun scheduleFirstRepeatReminder() = runBlocking {
val now = newDateTime()
val task = taskDao.insert(
newTask(with(DUE_TIME, now), with(REMINDER_LAST, now.plusMinutes(4)))
)
val alarm = alarmDao.insert(Alarm(task, 0, Alarm.TYPE_REL_END, 1, TimeUnit.MINUTES.toMillis(5)))
verify(AlarmEntry(alarm, task, now.plusMinutes(5).startOfMinute().millis + 1000))
}
@Test
fun scheduleSecondRepeatReminder() = runBlocking {
val now = newDateTime()
val task = taskDao.insert(
newTask(with(DUE_TIME, now), with(REMINDER_LAST, now.plusMinutes(6)))
)
val alarm = alarmDao.insert(Alarm(task, 0, Alarm.TYPE_REL_END, 2, TimeUnit.MINUTES.toMillis(5)))
verify(AlarmEntry(alarm, task, now.plusMinutes(10).startOfMinute().millis + 1000))
}
@Test
fun terminateRepeatReminder() = runBlocking {
val now = Task.createDueDate(URGENCY_SPECIFIC_DAY_TIME, now()).toDateTime()
val task = taskDao.insert(
newTask(with(DUE_TIME, now), with(REMINDER_LAST, now.plusMinutes(10)))
)
alarmDao.insert(Alarm(task, 0, Alarm.TYPE_REL_END, 2, TimeUnit.MINUTES.toMillis(5)))
verify()
}
@Test
fun dontScheduleRelativeEndWithNoEnd() = runBlocking {
val task = taskDao.insert(newTask())
alarmDao.insert(whenDue(task))
verify()
}
@Test
fun dontScheduleRelativeStartWithNoStart() = runBlocking {
val now = newDateTime()
val task = taskDao.insert(newTask(with(DUE_DATE, now)))
alarmDao.insert(whenStarted(task))
verify()
}
@Test
fun reminderOverdueEveryDay() = runBlocking {
val dueDate = Task.createDueDate(URGENCY_SPECIFIC_DAY_TIME, DateTime(2022, 1, 30, 13, 30).millis).toDateTime()
val task = taskDao.insert(newTask(with(DUE_TIME, dueDate), with(REMINDER_LAST, dueDate.plusDays(6))))
val alarm = alarmDao.insert(whenOverdue(task))
verify(AlarmEntry(alarm, task, dueDate.plusDays(7).millis))
}
@Test
fun endDailyOverdueReminder() = runBlocking {
val dueDate = Task.createDueDate(URGENCY_SPECIFIC_DAY_TIME, DateTime(2022, 1, 30, 13, 30).millis).toDateTime()
val task = taskDao.insert(newTask(with(DUE_TIME, dueDate), with(REMINDER_LAST, dueDate.plusDays(7))))
alarmDao.insert(whenOverdue(task))
verify()
}
private suspend fun insertAlarm(alarm: Alarm): Long {
alarm.id = alarmDao.insert(alarm)
return alarm.id
}
private suspend fun verify(vararg alarms: AlarmEntry) {
alarmService.scheduleAllAlarms()
assertTrue(jobs.getJobs().isEmpty())
assertEquals(alarms.toList(), jobs.getJobs())
}
}

@ -3,7 +3,6 @@ package com.todoroo.astrid.reminders
import com.natpryce.makeiteasy.MakeItEasy.with
import com.todoroo.andlib.utility.DateUtilities
import com.todoroo.astrid.data.Task
import com.todoroo.astrid.data.Task.Companion.HIDE_UNTIL_DUE
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.UninstallModules
import org.junit.Assert.assertEquals
@ -11,19 +10,14 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.tasks.Freeze.Companion.freezeClock
import org.tasks.R
import org.tasks.data.TaskDao
import org.tasks.date.DateTimeUtils.newDateTime
import org.tasks.injection.InjectingTestCase
import org.tasks.injection.ProductionModule
import org.tasks.jobs.NotificationQueue
import org.tasks.jobs.ReminderEntry
import org.tasks.makers.TaskMaker.COMPLETION_TIME
import org.tasks.makers.TaskMaker.CREATION_TIME
import org.tasks.makers.TaskMaker.DELETION_TIME
import org.tasks.makers.TaskMaker.DUE_DATE
import org.tasks.makers.TaskMaker.DUE_TIME
import org.tasks.makers.TaskMaker.HIDE_TYPE
import org.tasks.makers.TaskMaker.ID
import org.tasks.makers.TaskMaker.RANDOM_REMINDER_PERIOD
import org.tasks.makers.TaskMaker.REMINDERS
@ -33,7 +27,6 @@ import org.tasks.makers.TaskMaker.newTask
import org.tasks.preferences.Preferences
import org.tasks.reminders.Random
import org.tasks.time.DateTime
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@UninstallModules(ProductionModule::class)
@ -51,152 +44,7 @@ class ReminderServiceTest : InjectingTestCase() {
super.setUp()
random = RandomStub()
preferences.clear()
service = ReminderService(preferences, jobs, random, taskDao)
}
@Test
fun dontScheduleStartDateReminderWhenFlagNotSet() {
service.scheduleAlarm(
newTask(
with(ID, 1L),
with(HIDE_TYPE, Task.HIDE_UNTIL_DUE),
with(DUE_TIME, newDateTime())
)
)
assertTrue(jobs.isEmpty())
}
@Test
fun dontScheduleDueDateReminderWhenFlagNotSet() {
service.scheduleAlarm(newTask(with(ID, 1L), with(DUE_TIME, newDateTime())))
assertTrue(jobs.isEmpty())
}
@Test
fun dontScheduleDueDateReminderWhenTimeNotSet() {
service.scheduleAlarm(newTask(with(ID, 1L), with(REMINDERS, Task.NOTIFY_AT_DEADLINE)))
assertTrue(jobs.isEmpty())
}
@Test
fun schedulePastStartDate() {
freezeClock {
val dueDate = newDateTime().minusDays(1)
val task = newTask(
with(ID, 1L),
with(DUE_TIME, dueDate),
with(HIDE_TYPE, HIDE_UNTIL_DUE),
with(REMINDERS, Task.NOTIFY_AT_START)
)
service.scheduleAlarm(task)
verify(
ReminderEntry(
1,
dueDate.startOfDay().withHourOfDay(18).millis,
ReminderService.TYPE_START
)
)
}
}
@Test
fun scheduleFutureStartDate() {
val dueDate = newDateTime().plusDays(1)
val task = newTask(
with(ID, 1L),
with(DUE_TIME, dueDate),
with(HIDE_TYPE, HIDE_UNTIL_DUE),
with(REMINDERS, Task.NOTIFY_AT_START)
)
service.scheduleAlarm(task)
verify(
ReminderEntry(
1,
dueDate.startOfDay().withHourOfDay(18).millis,
ReminderService.TYPE_START
)
)
}
@Test
fun schedulePastDueDate() {
val task = newTask(
with(ID, 1L),
with(DUE_TIME, newDateTime().minusDays(1)),
with(REMINDERS, Task.NOTIFY_AT_DEADLINE))
service.scheduleAlarm(task)
verify(ReminderEntry(1, task.dueDate, ReminderService.TYPE_DUE))
}
@Test
fun scheduleFutureDueDate() {
val task = newTask(
with(ID, 1L),
with(DUE_TIME, newDateTime().plusDays(1)),
with(REMINDERS, Task.NOTIFY_AT_DEADLINE))
service.scheduleAlarm(task)
verify(ReminderEntry(1, task.dueDate, ReminderService.TYPE_DUE))
}
@Test
fun scheduleReminderAtDefaultDueTime() {
val now = newDateTime()
val task = newTask(with(ID, 1L), with(DUE_DATE, now), with(REMINDERS, Task.NOTIFY_AT_DEADLINE))
service.scheduleAlarm(task)
verify(ReminderEntry(1, now.startOfDay().withHourOfDay(18).millis, ReminderService.TYPE_DUE))
}
@Test
fun dontScheduleReminderForCompletedTask() {
val task = newTask(
with(ID, 1L),
with(DUE_TIME, newDateTime().plusDays(1)),
with(COMPLETION_TIME, newDateTime()),
with(REMINDERS, Task.NOTIFY_AT_DEADLINE))
service.scheduleAlarm(task)
assertTrue(jobs.isEmpty())
}
@Test
fun dontScheduleReminderForDeletedTask() {
val task = newTask(
with(ID, 1L),
with(DUE_TIME, newDateTime().plusDays(1)),
with(DELETION_TIME, newDateTime()),
with(REMINDERS, Task.NOTIFY_AT_DEADLINE))
service.scheduleAlarm(task)
assertTrue(jobs.isEmpty())
}
@Test
fun dontScheduleDueDateReminderWhenAlreadyReminded() {
val now = newDateTime()
val task = newTask(
with(ID, 1L),
with(DUE_TIME, now),
with(REMINDER_LAST, now.plusSeconds(1)),
with(REMINDERS, Task.NOTIFY_AT_DEADLINE))
service.scheduleAlarm(task)
assertTrue(jobs.isEmpty())
service = ReminderService(jobs, random, taskDao)
}
@Test
@ -205,12 +53,12 @@ class ReminderServiceTest : InjectingTestCase() {
with(ID, 1L),
with(DUE_TIME, newDateTime()),
with(SNOOZE_TIME, newDateTime().minusMinutes(5)),
with(REMINDER_LAST, newDateTime().minusMinutes(4)),
with(REMINDERS, Task.NOTIFY_AT_DEADLINE))
with(REMINDER_LAST, newDateTime().minusMinutes(4))
)
service.scheduleAlarm(task)
verify(ReminderEntry(1, task.dueDate, ReminderService.TYPE_DUE))
assertTrue(jobs.isEmpty())
}
@Test
@ -282,84 +130,6 @@ class ReminderServiceTest : InjectingTestCase() {
}
}
@Test
fun scheduleOverdueNoLastReminder() {
val task = newTask(
with(ID, 1L),
with(DUE_TIME, DateTime(2017, 9, 22, 15, 30)),
with(REMINDER_LAST, null as DateTime?),
with(REMINDERS, Task.NOTIFY_AFTER_DEADLINE))
service.scheduleAlarm(task)
verify(ReminderEntry(1L, DateTime(2017, 9, 23, 15, 30, 1, 0).millis, ReminderService.TYPE_OVERDUE))
}
@Test
fun scheduleOverduePastLastReminder() {
val task = newTask(
with(ID, 1L),
with(DUE_TIME, DateTime(2017, 9, 22, 15, 30)),
with(REMINDER_LAST, DateTime(2017, 9, 24, 12, 0)),
with(REMINDERS, Task.NOTIFY_AFTER_DEADLINE))
service.scheduleAlarm(task)
verify(ReminderEntry(1L, DateTime(2017, 9, 24, 15, 30, 1, 0).millis, ReminderService.TYPE_OVERDUE))
}
@Test
fun scheduleOverdueBeforeLastReminder() {
val task = newTask(
with(ID, 1L),
with(DUE_TIME, DateTime(2017, 9, 22, 12, 30)),
with(REMINDER_LAST, DateTime(2017, 9, 24, 15, 0)),
with(REMINDERS, Task.NOTIFY_AFTER_DEADLINE))
service.scheduleAlarm(task)
verify(ReminderEntry(1L, DateTime(2017, 9, 25, 12, 30, 1, 0).millis, ReminderService.TYPE_OVERDUE))
}
@Test
fun scheduleOverdueWithNoDueTime() {
preferences.setInt(R.string.p_rmd_time, TimeUnit.HOURS.toMillis(15).toInt())
val task = newTask(
with(ID, 1L),
with(DUE_DATE, DateTime(2017, 9, 22)),
with(REMINDER_LAST, DateTime(2017, 9, 23, 12, 17, 59, 999)),
with(REMINDERS, Task.NOTIFY_AFTER_DEADLINE))
service.scheduleAlarm(task)
verify(ReminderEntry(1L, DateTime(2017, 9, 23, 15, 0, 0, 0).millis, ReminderService.TYPE_OVERDUE))
}
@Test
fun scheduleSubsequentOverdueReminder() {
val task = newTask(
with(ID, 1L),
with(DUE_TIME, DateTime(2017, 9, 22, 15, 30)),
with(REMINDER_LAST, DateTime(2017, 9, 23, 15, 30, 59, 999)),
with(REMINDERS, Task.NOTIFY_AFTER_DEADLINE))
service.scheduleAlarm(task)
verify(ReminderEntry(1L, DateTime(2017, 9, 24, 15, 30, 1, 0).millis, ReminderService.TYPE_OVERDUE))
}
@Test
fun scheduleOverdueAfterLastReminder() {
val task = newTask(
with(ID, 1L),
with(DUE_TIME, DateTime(2017, 9, 22, 15, 30)),
with(REMINDER_LAST, DateTime(2017, 9, 23, 12, 17, 59, 999)),
with(REMINDERS, Task.NOTIFY_AFTER_DEADLINE))
service.scheduleAlarm(task)
verify(ReminderEntry(1L, DateTime(2017, 9, 23, 15, 30, 1, 0).millis, ReminderService.TYPE_OVERDUE))
}
@Test
fun snoozeOverridesAll() {

@ -1,39 +1,66 @@
package org.tasks.ui.editviewmodel
import com.todoroo.astrid.data.Task
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.UninstallModules
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.*
import org.junit.Test
import org.tasks.data.Alarm
import org.tasks.data.Alarm.Companion.whenOverdue
import org.tasks.injection.ProductionModule
import org.tasks.makers.TaskMaker.newTask
import org.tasks.time.DateTimeUtils.currentTimeMillis
@UninstallModules(ProductionModule::class)
@HiltAndroidTest
class ReminderTests : BaseTaskEditViewModelTest() {
@Test
fun whenStartReminder() = runBlocking {
val task = newTask()
task.defaultReminders(Task.NOTIFY_AT_START)
setup(task)
viewModel.hideUntil = Task.createDueDate(Task.URGENCY_SPECIFIC_DAY_TIME, currentTimeMillis())
save()
assertEquals(
listOf(Alarm(1, 0, Alarm.TYPE_REL_START).apply { id = 1 }),
alarmDao.getAlarms(task.id)
)
}
@Test
fun whenDueReminder() = runBlocking {
val task = newTask()
task.defaultReminders(Task.NOTIFY_AT_DEADLINE)
setup(task)
viewModel.whenDue = true
viewModel.dueDate = Task.createDueDate(Task.URGENCY_SPECIFIC_DAY_TIME, currentTimeMillis())
save()
assertTrue(taskDao.fetch(task.id)!!.isNotifyAtDeadline)
assertEquals(
listOf(Alarm(1, 0, Alarm.TYPE_REL_END).apply { id = 1 }),
alarmDao.getAlarms(task.id)
)
}
@Test
fun whenOverDueReminder() = runBlocking {
val task = newTask()
task.defaultReminders(Task.NOTIFY_AFTER_DEADLINE)
setup(task)
viewModel.whenOverdue = true
viewModel.dueDate = Task.createDueDate(Task.URGENCY_SPECIFIC_DAY_TIME, currentTimeMillis())
save()
assertTrue(taskDao.fetch(task.id)!!.isNotifyAfterDeadline)
assertEquals(
listOf(whenOverdue(1).apply { id = 1 }),
alarmDao.getAlarms(task.id)
)
}
@Test

@ -73,7 +73,7 @@ object TaskMaker {
}
val reminderFlags = lookup.valueOf(REMINDERS, -1)
if (reminderFlags >= 0) {
task.reminderFlags = reminderFlags
task.ringFlags = reminderFlags
}
val reminderLast = lookup.valueOf(REMINDER_LAST, null as DateTime?)
if (reminderLast != null) {

@ -5,10 +5,17 @@
*/
package com.todoroo.astrid.alarms
import com.todoroo.astrid.data.Task
import org.tasks.data.Alarm
import org.tasks.data.Alarm.Companion.TYPE_DATE_TIME
import org.tasks.data.Alarm.Companion.TYPE_REL_END
import org.tasks.data.Alarm.Companion.TYPE_REL_START
import org.tasks.data.AlarmDao
import org.tasks.data.TaskDao
import org.tasks.jobs.AlarmEntry
import org.tasks.jobs.NotificationQueue
import org.tasks.preferences.Preferences
import org.tasks.time.DateTimeUtils.withMillisOfDay
import javax.inject.Inject
import javax.inject.Singleton
@ -20,7 +27,10 @@ import javax.inject.Singleton
@Singleton
class AlarmService @Inject constructor(
private val alarmDao: AlarmDao,
private val jobs: NotificationQueue) {
private val jobs: NotificationQueue,
private val taskDao: TaskDao,
private val preferences: Preferences,
) {
suspend fun getAlarms(taskId: Long): List<Alarm> = alarmDao.getAlarms(taskId)
@ -30,9 +40,15 @@ class AlarmService @Inject constructor(
* @return true if data was changed
*/
suspend fun synchronizeAlarms(taskId: Long, alarms: MutableSet<Alarm>): Boolean {
val task = taskDao.fetch(taskId) ?: return false
var changed = false
for (existing in alarmDao.getAlarms(taskId)) {
if (!alarms.removeIf { it.time == existing.time }) {
if (!alarms.removeIf {
it.type == existing.type &&
it.time == existing.time &&
it.repeat == existing.repeat &&
it.interval == existing.interval
}) {
jobs.cancelAlarm(existing.id)
alarmDao.delete(existing)
changed = true
@ -44,7 +60,7 @@ class AlarmService @Inject constructor(
changed = true
}
if (changed) {
scheduleAlarms(taskId)
scheduleAlarms(task)
}
return changed
}
@ -53,7 +69,13 @@ class AlarmService @Inject constructor(
alarmDao.getActiveAlarms(taskId)
suspend fun scheduleAllAlarms() {
alarmDao.getActiveAlarms().forEach(::scheduleAlarm)
alarmDao
.getActiveAlarms()
.groupBy { it.task }
.forEach { (taskId, alarms) ->
val task = taskDao.fetch(taskId) ?: return@forEach
alarms.forEach { scheduleAlarm(task, it) }
}
}
suspend fun cancelAlarms(taskId: Long) {
@ -63,25 +85,55 @@ class AlarmService @Inject constructor(
}
/** Schedules alarms for a single task */
private suspend fun scheduleAlarms(taskId: Long) {
getActiveAlarmsForTask(taskId).forEach(::scheduleAlarm)
suspend fun scheduleAlarms(task: Task) {
getActiveAlarmsForTask(task.id).forEach { scheduleAlarm(task, it) }
}
/** Schedules alarms for a single task */
private fun scheduleAlarm(alarm: Alarm?) {
private fun scheduleAlarm(task: Task, alarm: Alarm?) {
if (alarm == null) {
return
}
val alarmEntry = AlarmEntry(alarm)
val time = alarmEntry.time
if (time == 0L || time == NO_ALARM) {
jobs.cancelAlarm(alarmEntry.id)
} else {
jobs.add(alarmEntry)
val trigger = when (alarm.type) {
TYPE_DATE_TIME ->
alarm.time
TYPE_REL_START ->
when {
task.hasStartTime() ->
task.hideUntil + alarm.time
task.hasStartDate() ->
task.hideUntil.withMillisOfDay(preferences.defaultDueTime) + alarm.time
else ->
NO_ALARM
}
TYPE_REL_END ->
when {
task.hasDueTime() ->
task.dueDate + alarm.time
task.hasDueDate() ->
task.dueDate.withMillisOfDay(preferences.defaultDueTime) + alarm.time
else ->
NO_ALARM
}
else -> NO_ALARM
}
jobs.cancelAlarm(alarm.id)
when {
trigger <= NO_ALARM ->
{}
trigger > task.reminderLast ->
jobs.add(AlarmEntry(alarm.id, alarm.task, trigger))
alarm.repeat > 0 -> {
val past = (task.reminderLast - trigger) / alarm.interval
val next = trigger + (past + 1) * alarm.interval
if (past < alarm.repeat && next > task.reminderLast) {
jobs.add(AlarmEntry(alarm.id, alarm.task, next))
}
}
}
}
companion object {
private const val NO_ALARM = Long.MAX_VALUE
private const val NO_ALARM = 0L
}
}

@ -61,7 +61,8 @@ import org.tasks.notifications.NotificationDao
Principal::class,
PrincipalAccess::class
],
version = 80)
version = 81
)
abstract class Database : RoomDatabase() {
abstract fun notificationDao(): NotificationDao
abstract val tagDataDao: TagDataDao

@ -6,6 +6,7 @@
package com.todoroo.astrid.dao
import com.todoroo.andlib.utility.DateUtilities.now
import com.todoroo.astrid.alarms.AlarmService
import com.todoroo.astrid.api.Filter
import com.todoroo.astrid.data.Task
import com.todoroo.astrid.reminders.ReminderService
@ -34,7 +35,9 @@ class TaskDao @Inject constructor(
private val notificationManager: NotificationManager,
private val geofenceApi: GeofenceApi,
private val timerPlugin: TimerPlugin,
private val syncAdapters: SyncAdapters) {
private val syncAdapters: SyncAdapters,
private val alarmService: AlarmService,
) {
suspend fun fetch(id: Long): Task? = taskDao.fetch(id)
@ -135,6 +138,7 @@ class TaskDao @Inject constructor(
geofenceApi.update(task.id)
}
reminderService.scheduleAlarm(task)
alarmService.scheduleAlarms(task)
refreshScheduler.scheduleRefresh(task)
if (!task.isSuppressRefresh()) {
localBroadcastManager.broadcastRefresh()

@ -5,7 +5,12 @@ import android.os.Parcel
import android.os.Parcelable
import androidx.annotation.IntDef
import androidx.core.os.ParcelCompat
import androidx.room.*
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.Index
import androidx.room.PrimaryKey
import com.google.gson.annotations.SerializedName
import com.todoroo.andlib.data.Table
import com.todoroo.andlib.sql.Field
import com.todoroo.andlib.utility.DateUtilities
@ -17,7 +22,6 @@ import org.tasks.date.DateTimeUtils.toDateTime
import org.tasks.time.DateTime
import org.tasks.time.DateTimeUtils.startOfDay
import timber.log.Timber
import java.util.*
@Entity(
tableName = Task.TABLE_NAME,
@ -77,7 +81,8 @@ class Task : Parcelable {
/** Flags for when to send reminders */
@ColumnInfo(name = "notificationFlags")
var reminderFlags = 0
@SerializedName("ringFlags", alternate = ["reminderFlags"])
var ringFlags = 0
/** Reminder period, in milliseconds. 0 means disabled */
@ColumnInfo(name = "notifications")
@ -136,7 +141,7 @@ class Task : Parcelable {
modificationDate = parcel.readLong()
notes = parcel.readString()
recurrence = parcel.readString()
reminderFlags = parcel.readInt()
ringFlags = parcel.readInt()
reminderLast = parcel.readLong()
reminderPeriod = parcel.readLong()
reminderSnooze = parcel.readLong()
@ -243,22 +248,26 @@ class Task : Parcelable {
}
val isNotifyModeNonstop: Boolean
get() = isReminderFlagSet(NOTIFY_MODE_NONSTOP)
get() = isRingSet(NOTIFY_MODE_NONSTOP)
val isNotifyModeFive: Boolean
get() = isReminderFlagSet(NOTIFY_MODE_FIVE)
get() = isRingSet(NOTIFY_MODE_FIVE)
val isNotifyAfterDeadline: Boolean
get() = isReminderFlagSet(NOTIFY_AFTER_DEADLINE)
get() = isReminderSet(NOTIFY_AFTER_DEADLINE)
val isNotifyAtStart: Boolean
get() = isReminderFlagSet(NOTIFY_AT_START)
get() = isReminderSet(NOTIFY_AT_START)
val isNotifyAtDeadline: Boolean
get() = isReminderFlagSet(NOTIFY_AT_DEADLINE)
get() = isReminderSet(NOTIFY_AT_DEADLINE)
private fun isReminderFlagSet(flag: Int): Boolean {
return reminderFlags and flag > 0
private fun isReminderSet(flag: Int): Boolean {
return ((transitoryData?.get(TRANS_REMINDERS) as? Int) ?: 0) and flag > 0
}
private fun isRingSet(flag: Int): Boolean {
return ringFlags and flag > 0
}
val isNew: Boolean
@ -282,7 +291,7 @@ class Task : Parcelable {
dest.writeLong(modificationDate)
dest.writeString(notes)
dest.writeString(recurrence)
dest.writeInt(reminderFlags)
dest.writeInt(ringFlags)
dest.writeLong(reminderLast)
dest.writeLong(reminderPeriod)
dest.writeLong(reminderSnooze)
@ -313,7 +322,7 @@ class Task : Parcelable {
&& notes == task.notes
&& estimatedSeconds == task.estimatedSeconds
&& elapsedSeconds == task.elapsedSeconds
&& reminderFlags == task.reminderFlags
&& ringFlags == task.ringFlags
&& reminderPeriod == task.reminderPeriod
&& recurrence == task.recurrence
&& repeatUntil == task.repeatUntil
@ -372,6 +381,10 @@ class Task : Parcelable {
fun isSuppressRefresh() = checkTransitory(TRANS_SUPPRESS_REFRESH)
fun defaultReminders(flags: Int) {
putTransitory(TRANS_REMINDERS, flags)
}
@Synchronized
fun putTransitory(key: String, value: Any) {
if (transitoryData == null) {
@ -425,7 +438,7 @@ class Task : Parcelable {
if (estimatedSeconds != other.estimatedSeconds) return false
if (elapsedSeconds != other.elapsedSeconds) return false
if (timerStart != other.timerStart) return false
if (reminderFlags != other.reminderFlags) return false
if (ringFlags != other.ringFlags) return false
if (reminderPeriod != other.reminderPeriod) return false
if (reminderLast != other.reminderLast) return false
if (reminderSnooze != other.reminderSnooze) return false
@ -454,7 +467,7 @@ class Task : Parcelable {
result = 31 * result + estimatedSeconds
result = 31 * result + elapsedSeconds
result = 31 * result + timerStart.hashCode()
result = 31 * result + reminderFlags
result = 31 * result + ringFlags
result = 31 * result + reminderPeriod.hashCode()
result = 31 * result + reminderLast.hashCode()
result = 31 * result + reminderSnooze.hashCode()
@ -469,7 +482,7 @@ class Task : Parcelable {
}
override fun toString(): String {
return "Task(id=$id, title=$title, priority=$priority, dueDate=$dueDate, hideUntil=$hideUntil, creationDate=$creationDate, modificationDate=$modificationDate, completionDate=$completionDate, deletionDate=$deletionDate, notes=$notes, estimatedSeconds=$estimatedSeconds, elapsedSeconds=$elapsedSeconds, timerStart=$timerStart, reminderFlags=$reminderFlags, reminderPeriod=$reminderPeriod, reminderLast=$reminderLast, reminderSnooze=$reminderSnooze, recurrence=$recurrence, repeatUntil=$repeatUntil, calendarURI=$calendarURI, remoteId='$remoteId', isCollapsed=$isCollapsed, parent=$parent, transitoryData=$transitoryData)"
return "Task(id=$id, title=$title, priority=$priority, dueDate=$dueDate, hideUntil=$hideUntil, creationDate=$creationDate, modificationDate=$modificationDate, completionDate=$completionDate, deletionDate=$deletionDate, notes=$notes, estimatedSeconds=$estimatedSeconds, elapsedSeconds=$elapsedSeconds, timerStart=$timerStart, ringFlags=$ringFlags, reminderPeriod=$reminderPeriod, reminderLast=$reminderLast, reminderSnooze=$reminderSnooze, recurrence=$recurrence, repeatUntil=$repeatUntil, calendarURI=$calendarURI, remoteId='$remoteId', isCollapsed=$isCollapsed, parent=$parent, transitoryData=$transitoryData)"
}
@Retention(AnnotationRetention.SOURCE)
@ -558,6 +571,7 @@ class Task : Parcelable {
const val URGENCY_IN_TWO_WEEKS = 5
private const val TRANS_SUPPRESS_REFRESH = "suppress-refresh"
const val TRANS_REMINDERS = "reminders"
private val INVALID_COUNT = ";?COUNT=-1".toRegex()

@ -10,25 +10,22 @@ import com.todoroo.astrid.data.Task
import org.tasks.data.TaskDao
import org.tasks.jobs.NotificationQueue
import org.tasks.jobs.ReminderEntry
import org.tasks.preferences.Preferences
import org.tasks.reminders.Random
import org.tasks.time.DateTime
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ReminderService internal constructor(
private val preferences: Preferences,
private val jobs: NotificationQueue,
private val random: Random,
private val taskDao: TaskDao) {
private val taskDao: TaskDao,
) {
@Inject
internal constructor(
preferences: Preferences,
notificationQueue: NotificationQueue,
taskDao: TaskDao
) : this(preferences, notificationQueue, Random(), taskDao)
) : this(notificationQueue, Random(), taskDao)
suspend fun scheduleAlarm(id: Long) = scheduleAllAlarms(listOf(id))
@ -66,31 +63,12 @@ class ReminderService internal constructor(
// random reminders
val whenRandom = calculateNextRandomReminder(task)
val whenStartDate = calculateStartDateReminder(task)
// notifications at due date
val whenDueDate = calculateNextDueDateReminder(task)
// notifications after due date
val whenOverdue = calculateNextOverdueReminder(task)
// snooze trumps all
if (whenSnooze != NO_ALARM) {
return ReminderEntry(taskId, whenSnooze, TYPE_SNOOZE)
} else if (
whenRandom < whenDueDate &&
whenRandom < whenOverdue &&
whenRandom < whenStartDate
) {
return ReminderEntry(taskId, whenRandom, TYPE_RANDOM)
} else if (whenStartDate < whenDueDate) {
return ReminderEntry(taskId, whenStartDate, TYPE_START)
} else if (whenDueDate < whenOverdue) {
return ReminderEntry(taskId, whenDueDate, TYPE_DUE)
} else if (whenOverdue != NO_ALARM) {
return ReminderEntry(taskId, whenOverdue, TYPE_OVERDUE)
return when {
whenSnooze != NO_ALARM -> ReminderEntry(taskId, whenSnooze, TYPE_SNOOZE)
whenRandom != NO_ALARM -> ReminderEntry(taskId, whenRandom, TYPE_RANDOM)
else -> null
}
return null
}
private fun calculateNextSnoozeReminder(task: Task): Long {
@ -99,64 +77,6 @@ class ReminderService internal constructor(
} else NO_ALARM
}
private fun calculateNextOverdueReminder(task: Task): Long {
// Uses getNowValue() instead of DateUtilities.now()
if (task.hasDueDate() && task.isNotifyAfterDeadline) {
var overdueDate = DateTime(task.dueDate).plusDays(1)
if (!task.hasDueTime()) {
overdueDate = overdueDate.withMillisOfDay(preferences.defaultDueTime)
}
val lastReminder = DateTime(task.reminderLast)
if (overdueDate.isAfter(lastReminder)) {
return overdueDate.millis
}
overdueDate = lastReminder.withMillisOfDay(overdueDate.millisOfDay)
return if (overdueDate.isAfter(lastReminder)) overdueDate.millis else overdueDate.plusDays(1).millis
}
return NO_ALARM
}
private fun calculateStartDateReminder(task: Task): Long {
if (task.hasStartDate() && task.isNotifyAtStart) {
val startDate = task.hideUntil
val startDateAlarm = if (task.hasStartTime()) {
startDate
} else {
DateTime(startDate).withMillisOfDay(preferences.defaultDueTime).millis
}
if (task.reminderLast < startDateAlarm) {
return startDateAlarm
}
}
return NO_ALARM
}
/**
* Calculate the next alarm time for due date reminders.
*
*
* This alarm always returns the due date, and is triggered if the last reminder time occurred
* before the due date. This means it is possible to return due dates in the past.
*
*
* If the date was indicated to not have a due time, we read from preferences and assign a
* time.
*/
private fun calculateNextDueDateReminder(task: Task): Long {
if (task.hasDueDate() && task.isNotifyAtDeadline) {
val dueDate = task.dueDate
val dueDateAlarm = if (task.hasDueTime()) {
dueDate
} else {
DateTime(dueDate).withMillisOfDay(preferences.defaultDueTime).millis
}
if (task.reminderLast < dueDateAlarm) {
return dueDateAlarm
}
}
return NO_ALARM
}
/**
* Calculate the next alarm time for random reminders.
*

@ -16,6 +16,7 @@ import net.fortuna.ical4j.model.Date
import net.fortuna.ical4j.model.Recur
import net.fortuna.ical4j.model.WeekDay
import org.tasks.LocalBroadcastManager
import org.tasks.data.Alarm
import org.tasks.date.DateTimeUtils.newDateTime
import org.tasks.repeats.RecurrenceUtils.newRecur
import org.tasks.time.DateTime
@ -116,7 +117,11 @@ class RepeatTaskHelper @Inject constructor(
}
alarmService.getAlarms(taskId)
.takeIf { it.isNotEmpty() }
?.onEach { it.time += newDueDate - oldDueDate }
?.onEach {
if (it.type == Alarm.TYPE_DATE_TIME) {
it.time += newDueDate - oldDueDate
}
}
?.let { alarmService.synchronizeAlarms(taskId, it.toMutableSet()) }
}

@ -17,12 +17,26 @@ import com.todoroo.astrid.helper.UUIDHelper
import com.todoroo.astrid.utility.TitleParser.parse
import org.tasks.R
import org.tasks.Strings.isNullOrEmpty
import org.tasks.data.*
import org.tasks.data.Alarm
import org.tasks.data.Alarm.Companion.whenDue
import org.tasks.data.Alarm.Companion.whenOverdue
import org.tasks.data.Alarm.Companion.whenStarted
import org.tasks.data.AlarmDao
import org.tasks.data.CaldavDao
import org.tasks.data.CaldavTask
import org.tasks.data.Geofence
import org.tasks.data.GoogleTask
import org.tasks.data.GoogleTaskDao
import org.tasks.data.LocationDao
import org.tasks.data.Place
import org.tasks.data.Tag
import org.tasks.data.TagDao
import org.tasks.data.TagData
import org.tasks.data.TagDataDao
import org.tasks.preferences.DefaultFilterProvider
import org.tasks.preferences.Preferences
import org.tasks.time.DateTimeUtils.startOfDay
import timber.log.Timber
import java.util.*
import javax.inject.Inject
class TaskCreator @Inject constructor(
@ -34,7 +48,9 @@ class TaskCreator @Inject constructor(
private val googleTaskDao: GoogleTaskDao,
private val defaultFilterProvider: DefaultFilterProvider,
private val caldavDao: CaldavDao,
private val locationDao: LocationDao) {
private val locationDao: LocationDao,
private val alarmDao: AlarmDao,
) {
suspend fun basicQuickAddTask(title: String): Task {
val task = createWithValues(title.trim { it <= ' ' })
@ -71,6 +87,7 @@ class TaskCreator @Inject constructor(
}
}
taskDao.save(task, null)
alarmDao.insert(task.getDefaultAlarms())
return task
}
@ -161,10 +178,25 @@ class TaskCreator @Inject constructor(
private fun setDefaultReminders(preferences: Preferences, task: Task) {
task.reminderPeriod = (DateUtilities.ONE_HOUR
* preferences.getIntegerFromString(R.string.p_rmd_default_random_hours, 0))
task.reminderFlags = preferences.defaultReminders or preferences.defaultRingMode
task.defaultReminders(preferences.defaultReminders)
task.ringFlags = preferences.defaultRingMode
}
private fun Any?.substitute(): String? =
(this as? String)?.let { PermaSql.replacePlaceholdersForNewTask(it) }
fun Task.getDefaultAlarms(): List<Alarm> = ArrayList<Alarm>().apply {
if (hasStartDate() && isNotifyAtStart) {
add(whenStarted(id))
}
if (hasDueDate()) {
if (isNotifyAtDeadline) {
add(whenDue(id))
}
if (isNotifyAfterDeadline) {
add(whenOverdue(id))
}
}
}
}
}

@ -16,13 +16,27 @@ import org.tasks.caldav.iCalendar
import org.tasks.caldav.iCalendar.Companion.fromVtodo
import org.tasks.caldav.iCalendar.Companion.order
import org.tasks.caldav.iCalendar.Companion.parent
import org.tasks.data.*
import org.tasks.data.CaldavDao
import org.tasks.data.CaldavTask
import org.tasks.data.CaldavTaskContainer
import org.tasks.data.FilterDao
import org.tasks.data.GoogleTaskAccount
import org.tasks.data.GoogleTaskDao
import org.tasks.data.GoogleTaskListDao
import org.tasks.data.Location
import org.tasks.data.LocationDao
import org.tasks.data.Tag
import org.tasks.data.TagDao
import org.tasks.data.TagData
import org.tasks.data.TagDataDao
import org.tasks.data.TaskAttachmentDao
import org.tasks.data.UpgraderDao
import org.tasks.data.UserActivityDao
import org.tasks.preferences.DefaultFilterProvider
import org.tasks.preferences.Preferences
import org.tasks.widget.AppWidgetManager
import org.tasks.widget.WidgetPreferences
import java.io.File
import java.util.*
import javax.inject.Inject
class Upgrader @Inject constructor(
@ -322,7 +336,7 @@ class Upgrader @Inject constructor(
private const val V5_3_0 = 491
private const val V6_0_beta_1 = 522
private const val V6_0_beta_2 = 523
private const val V6_4 = 546
const val V6_4 = 546
private const val V6_7 = 585
private const val V6_8_1 = 607
private const val V6_9 = 608
@ -337,6 +351,7 @@ class Upgrader @Inject constructor(
const val V9_7_3 = 90704
const val V10_0_2 = 100012
const val V11_13 = 111300
const val V12_3 = 120300
@JvmStatic
fun getAndroidColor(context: Context, index: Int): Int {

@ -15,18 +15,23 @@ import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.annotation.StringRes
import com.todoroo.andlib.utility.DateUtilities
import dagger.hilt.android.AndroidEntryPoint
import org.tasks.R
import org.tasks.activities.DateAndTimePickerActivity
import org.tasks.data.Alarm
import org.tasks.data.Alarm.Companion.TYPE_DATE_TIME
import org.tasks.data.Alarm.Companion.TYPE_RANDOM
import org.tasks.data.Alarm.Companion.TYPE_REL_END
import org.tasks.data.Alarm.Companion.TYPE_REL_START
import org.tasks.data.Alarm.Companion.whenDue
import org.tasks.data.Alarm.Companion.whenOverdue
import org.tasks.data.Alarm.Companion.whenStarted
import org.tasks.databinding.ControlSetRemindersBinding
import org.tasks.date.DateTimeUtils
import org.tasks.dialogs.DialogBuilder
import org.tasks.dialogs.MyTimePickerDialog
import org.tasks.locale.Locale
import org.tasks.reminders.AlarmToString
import org.tasks.ui.TaskEditControlFragment
import java.util.*
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@ -38,9 +43,9 @@ import javax.inject.Inject
@AndroidEntryPoint
class ReminderControlSet : TaskEditControlFragment() {
@Inject lateinit var activity: Activity
@Inject lateinit var locale: Locale
@Inject lateinit var dialogBuilder: DialogBuilder
@Inject lateinit var alarmToString: AlarmToString
private lateinit var alertContainer: LinearLayout
private lateinit var mode: TextView
@ -53,15 +58,6 @@ class ReminderControlSet : TaskEditControlFragment() {
viewModel.ringFiveTimes!! -> setRingMode(1)
else -> setRingMode(0)
}
if (viewModel.whenStart!!) {
addStart()
}
if (viewModel.whenDue!!) {
addDue()
}
if (viewModel.whenOverdue!!) {
addOverdue()
}
if (viewModel.reminderPeriod!! > 0) {
addRandomReminder(viewModel.reminderPeriod!!)
}
@ -101,11 +97,16 @@ class ReminderControlSet : TaskEditControlFragment() {
private fun addAlarm(selected: String) {
when (selected) {
getString(R.string.when_started) -> addStart()
getString(R.string.when_due) -> addDue()
getString(R.string.when_overdue) -> addOverdue()
getString(R.string.randomly) -> addRandomReminder(TimeUnit.DAYS.toMillis(14))
getString(R.string.pick_a_date_and_time) -> addNewAlarm()
getString(R.string.when_started) ->
addAlarmRow(whenStarted(viewModel.task?.id ?: 0))
getString(R.string.when_due) ->
addAlarmRow(whenDue(viewModel.task?.id ?: 0))
getString(R.string.when_overdue) ->
addAlarmRow(whenOverdue(viewModel.task?.id ?: 0))
getString(R.string.randomly) ->
addRandomReminder(TimeUnit.DAYS.toMillis(14))
getString(R.string.pick_a_date_and_time) ->
addNewAlarm()
}
}
@ -142,7 +143,7 @@ class ReminderControlSet : TaskEditControlFragment() {
if (requestCode == REQUEST_NEW_ALARM) {
if (resultCode == Activity.RESULT_OK) {
val timestamp = data!!.getLongExtra(MyTimePickerDialog.EXTRA_TIMESTAMP, 0L)
if (viewModel.selectedAlarms?.any { timestamp == it.time } == false) {
if (viewModel.selectedAlarms?.any { it.type == TYPE_DATE_TIME && timestamp == it.time } == false) {
val alarm = Alarm(viewModel.task?.id ?: 0, timestamp)
viewModel.selectedAlarms?.add(alarm)
addAlarmRow(alarm)
@ -154,8 +155,13 @@ class ReminderControlSet : TaskEditControlFragment() {
}
private fun addAlarmRow(alarm: Alarm) {
addAlarmRow(DateUtilities.getLongDateStringWithTime(alarm.time, locale.locale)) {
viewModel.selectedAlarms?.removeIf { it.time == alarm.time }
addAlarmRow(alarm) {
viewModel.selectedAlarms?.removeIf {
it.type == alarm.type &&
it.time == alarm.time &&
it.repeat == alarm.repeat &&
it.interval == alarm.interval
}
}
}
@ -166,16 +172,17 @@ class ReminderControlSet : TaskEditControlFragment() {
startActivityForResult(intent, REQUEST_NEW_ALARM)
}
private fun addAlarmRow(text: String, onRemove: View.OnClickListener): View {
private fun addAlarmRow(alarm: Alarm, onRemove: View.OnClickListener): View {
val alertItem = requireActivity().layoutInflater.inflate(R.layout.alarm_edit_row, null)
alertContainer.addView(alertItem)
addAlarmRow(alertItem, text, onRemove)
addAlarmRow(alertItem, alarm, onRemove)
return alertItem
}
private fun addAlarmRow(alertItem: View, text: String, onRemove: View.OnClickListener?) {
private fun addAlarmRow(alertItem: View, alarm: Alarm, onRemove: View.OnClickListener?) {
val display = alertItem.findViewById<TextView>(R.id.alarm_string)
display.text = text
viewModel.selectedAlarms?.add(alarm)
display.text = alarmToString.toString(alarm)
alertItem
.findViewById<View>(R.id.clear)
.setOnClickListener { v: View? ->
@ -187,13 +194,13 @@ class ReminderControlSet : TaskEditControlFragment() {
private val options: List<String>
get() {
val options: MutableList<String> = ArrayList()
if (viewModel.whenStart != true) {
if (viewModel.selectedAlarms?.find { it.type == TYPE_REL_START && it.time == 0L } == null) {
options.add(getString(R.string.when_started))
}
if (viewModel.whenDue != true) {
if (viewModel.selectedAlarms?.find { it.type == TYPE_REL_END && it.time == 0L } == null) {
options.add(getString(R.string.when_due))
}
if (viewModel.whenOverdue != true) {
if (viewModel.selectedAlarms?.find { it.type == TYPE_REL_END && it.time == TimeUnit.HOURS.toMillis(24) } == null) {
options.add(getString(R.string.when_overdue))
}
if (randomControlSet == null) {
@ -203,29 +210,8 @@ class ReminderControlSet : TaskEditControlFragment() {
return options
}
private fun addStart() {
viewModel.whenStart = true
addAlarmRow(getString(R.string.when_started)) {
viewModel.whenStart = false
}
}
private fun addDue() {
viewModel.whenDue = true
addAlarmRow(getString(R.string.when_due)) {
viewModel.whenDue = false
}
}
private fun addOverdue() {
viewModel.whenOverdue = true
addAlarmRow(getString(R.string.when_overdue)) {
viewModel.whenOverdue = false
}
}
private fun addRandomReminder(reminderPeriod: Long) {
val alarmRow = addAlarmRow(getString(R.string.randomly_once) + " ") {
val alarmRow = addAlarmRow(Alarm(viewModel.task?.id ?: 0, 0, TYPE_RANDOM)) {
viewModel.reminderPeriod = 0
randomControlSet = null
}

@ -7,13 +7,30 @@ import android.os.Handler
import com.google.gson.Gson
import com.google.gson.JsonObject
import com.todoroo.astrid.dao.TaskDao
import com.todoroo.astrid.data.Task
import com.todoroo.astrid.service.TaskCreator.Companion.getDefaultAlarms
import com.todoroo.astrid.service.TaskMover
import com.todoroo.astrid.service.Upgrader
import com.todoroo.astrid.service.Upgrader.Companion.V12_3
import com.todoroo.astrid.service.Upgrader.Companion.V6_4
import com.todoroo.astrid.service.Upgrader.Companion.getAndroidColor
import org.tasks.LocalBroadcastManager
import org.tasks.R
import org.tasks.data.*
import org.tasks.data.AlarmDao
import org.tasks.data.CaldavDao
import org.tasks.data.FilterDao
import org.tasks.data.Geofence
import org.tasks.data.GoogleTaskDao
import org.tasks.data.GoogleTaskListDao
import org.tasks.data.LocationDao
import org.tasks.data.Place.Companion.newPlace
import org.tasks.data.Tag
import org.tasks.data.TagDao
import org.tasks.data.TagData
import org.tasks.data.TagDataDao
import org.tasks.data.TaskAttachmentDao
import org.tasks.data.TaskListMetadataDao
import org.tasks.data.UserActivityDao
import org.tasks.preferences.Preferences
import timber.log.Timber
import java.io.FileNotFoundException
@ -136,9 +153,19 @@ class TasksJsonImporter @Inject constructor(
alarm.task = taskId
alarmDao.insert(alarm)
}
if (version < V12_3) {
task.defaultReminders(task.ringFlags)
alarmDao.insert(task.getDefaultAlarms())
task.ringFlags = when {
task.isNotifyModeFive -> Task.NOTIFY_MODE_FIVE
task.isNotifyModeNonstop -> Task.NOTIFY_MODE_NONSTOP
else -> 0
}
taskDao.save(task)
}
for (comment in backup.comments) {
comment.targetId = taskUuid
if (version < 546) {
if (version < V6_4) {
comment.convertPictureUri()
}
userActivityDao.createNew(comment)
@ -177,7 +204,7 @@ class TasksJsonImporter @Inject constructor(
}
backup.attachments?.forEach { attachment ->
attachment.taskId = taskUuid
if (version < 546) {
if (version < V6_4) {
attachment.convertPathUri()
}
taskAttachmentDao.insert(attachment)

@ -6,6 +6,8 @@ import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey
import org.tasks.time.DateTimeUtils.printTimestamp
import java.util.concurrent.TimeUnit
@Entity(tableName = "alarms")
class Alarm : Parcelable {
@ -21,6 +23,15 @@ class Alarm : Parcelable {
@ColumnInfo(name = "time")
var time: Long = 0
@ColumnInfo(name = "type", defaultValue = "0")
var type: Int = 0
@ColumnInfo(name = "repeat", defaultValue = "0")
var repeat: Int = 0
@ColumnInfo(name = "interval", defaultValue = "0")
var interval: Long = 0
constructor()
@Ignore
@ -28,25 +39,47 @@ class Alarm : Parcelable {
id = parcel.readLong()
task = parcel.readLong()
time = parcel.readLong()
type = parcel.readInt()
repeat = parcel.readInt()
interval = parcel.readLong()
}
@Ignore
constructor(task: Long, time: Long) {
constructor(task: Long, time: Long, type: Int = 0, repeat: Int = 0, interval: Long = 0) {
this.task = task
this.time = time
this.type = type
this.repeat = repeat
this.interval = interval
}
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeLong(id)
parcel.writeLong(task)
parcel.writeLong(time)
parcel.writeInt(type)
parcel.writeInt(repeat)
parcel.writeLong(interval)
}
override fun describeContents() = 0
override fun toString(): String {
return "Alarm(id=$id, task=$task, time=$time)"
return "Alarm(id=$id, task=$task, time=${printTimestamp(time)}, type=$type, repeat=$repeat, interval=$interval)"
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Alarm) return false
if (javaClass != other?.javaClass) return false
other as Alarm
if (id != other.id) return false
if (task != other.task) return false
if (time != other.time) return false
if (type != other.type) return false
if (repeat != other.repeat) return false
if (interval != other.interval) return false
return true
}
@ -55,20 +88,30 @@ class Alarm : Parcelable {
var result = id.hashCode()
result = 31 * result + task.hashCode()
result = 31 * result + time.hashCode()
result = 31 * result + type
result = 31 * result + repeat
result = 31 * result + interval.hashCode()
return result
}
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeLong(id)
parcel.writeLong(task)
parcel.writeLong(time)
}
companion object {
const val TYPE_DATE_TIME = 0
const val TYPE_REL_START = 1
const val TYPE_REL_END = 2
const val TYPE_RANDOM = 3
override fun describeContents() = 0
fun whenStarted(task: Long) = Alarm(task, 0, TYPE_REL_START)
fun whenDue(task: Long) = Alarm(task, 0, TYPE_REL_END)
fun whenOverdue(task: Long) =
Alarm(task, TimeUnit.DAYS.toMillis(1), TYPE_REL_END, 6, TimeUnit.DAYS.toMillis(1))
companion object CREATOR : Parcelable.Creator<Alarm> {
override fun createFromParcel(parcel: Parcel) = Alarm(parcel)
@JvmField
val CREATOR = object : Parcelable.Creator<Alarm> {
override fun createFromParcel(parcel: Parcel) = Alarm(parcel)
override fun newArray(size: Int): Array<Alarm?> = arrayOfNulls(size)
override fun newArray(size: Int): Array<Alarm?> = arrayOfNulls(size)
}
}
}

@ -8,17 +8,26 @@ import com.todoroo.astrid.data.Task
@Dao
interface AlarmDao {
@Query("SELECT alarms.* FROM alarms INNER JOIN tasks ON tasks._id = alarms.task "
+ "WHERE tasks.completed = 0 AND tasks.deleted = 0 AND tasks.lastNotified < alarms.time "
+ "ORDER BY time ASC")
@Query("""
SELECT alarms.*
FROM alarms
INNER JOIN tasks ON tasks._id = alarms.task
WHERE tasks.completed = 0
AND tasks.deleted = 0
""")
suspend fun getActiveAlarms(): List<Alarm>
@Query("SELECT alarms.* FROM alarms INNER JOIN tasks ON tasks._id = alarms.task "
+ "WHERE tasks._id = :taskId AND tasks.completed = 0 AND tasks.deleted = 0 AND tasks.lastNotified < alarms.time "
+ "ORDER BY time ASC")
@Query("""
SELECT alarms.*
FROM alarms
INNER JOIN tasks ON tasks._id = alarms.task
WHERE tasks._id = :taskId
AND tasks.completed = 0
AND tasks.deleted = 0
""")
suspend fun getActiveAlarms(taskId: Long): List<Alarm>
@Query("SELECT * FROM alarms WHERE task = :taskId ORDER BY time ASC")
@Query("SELECT * FROM alarms WHERE task = :taskId")
suspend fun getAlarms(taskId: Long): List<Alarm>
@Delete

@ -4,8 +4,14 @@ import android.database.sqlite.SQLiteException
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.todoroo.astrid.api.FilterListItem.NO_ORDER
import com.todoroo.astrid.data.Task.Companion.NOTIFY_AFTER_DEADLINE
import com.todoroo.astrid.data.Task.Companion.NOTIFY_AT_DEADLINE
import com.todoroo.astrid.data.Task.Companion.NOTIFY_AT_START
import org.tasks.data.Alarm.Companion.TYPE_REL_END
import org.tasks.data.Alarm.Companion.TYPE_REL_START
import org.tasks.data.CaldavAccount.Companion.SERVER_UNKNOWN
import timber.log.Timber
import java.util.concurrent.TimeUnit.HOURS
object Migrations {
private val MIGRATION_35_36: Migration = object : Migration(35, 36) {
@ -404,6 +410,26 @@ object Migrations {
}
}
private val MIGRATION_80_81 = object : Migration(80, 81) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE `alarms` ADD COLUMN `type` INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE `alarms` ADD COLUMN `repeat` INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE `alarms` ADD COLUMN `interval` INTEGER NOT NULL DEFAULT 0")
database.execSQL(
"INSERT INTO `alarms` (`task`, `time`, `type`) SELECT `_id`, 0, $TYPE_REL_START FROM `tasks` WHERE `hideUntil` > 0 AND `notificationFlags` | $NOTIFY_AT_START"
)
database.execSQL(
"INSERT INTO `alarms` (`task`, `time`, `type`) SELECT `_id`, 0, $TYPE_REL_END FROM `tasks` WHERE `dueDate` > 0 AND `notificationFlags` | $NOTIFY_AT_DEADLINE"
)
database.execSQL(
"INSERT INTO `alarms` (`task`, `time`, `type`, `repeat`, `interval`) SELECT `_id`, ${HOURS.toMillis(24)}, $TYPE_REL_END, 6, ${HOURS.toMillis(24)} FROM `tasks` WHERE `dueDate` > 0 AND `notificationFlags` | $NOTIFY_AFTER_DEADLINE"
)
database.execSQL(
"UPDATE `tasks` SET `notificationFlags` = `notificationFlags` & ~$NOTIFY_AT_START & ~$NOTIFY_AT_DEADLINE & ~$NOTIFY_AFTER_DEADLINE"
)
}
}
val MIGRATIONS = arrayOf(
MIGRATION_35_36,
MIGRATION_36_37,
@ -441,6 +467,7 @@ object Migrations {
MIGRATION_77_78,
MIGRATION_78_79,
MIGRATION_79_80,
MIGRATION_80_81,
)
private fun noop(from: Int, to: Int): Migration = object : Migration(from, to) {

@ -1,8 +1,10 @@
package org.tasks.jobs;
import static org.tasks.time.DateTimeUtils.currentTimeMillis;
import static org.tasks.time.DateTimeUtils.printTimestamp;
import com.todoroo.astrid.reminders.ReminderService;
import org.tasks.notifications.Notification;
public class AlarmEntry implements NotificationQueueEntry {
@ -11,10 +13,6 @@ public class AlarmEntry implements NotificationQueueEntry {
private final long taskId;
private final long time;
public AlarmEntry(org.tasks.data.Alarm alarm) {
this(alarm.getId(), alarm.getTask(), alarm.getTime());
}
public AlarmEntry(long alarmId, long taskId, Long time) {
this.alarmId = alarmId;
this.taskId = taskId;
@ -70,6 +68,6 @@ public class AlarmEntry implements NotificationQueueEntry {
@Override
public String toString() {
return "AlarmEntry{" + "alarmId=" + alarmId + ", taskId=" + taskId + ", time=" + time + '}';
return "AlarmEntry{" + "alarmId=" + alarmId + ", taskId=" + taskId + ", time=" + printTimestamp(time) + '}';
}
}

@ -11,8 +11,14 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NotificationQueue @Inject constructor(private val preferences: Preferences, private val workManager: WorkManager) {
private val jobs = TreeMultimap.create(Ordering.natural<Long>(), Comparator { l: NotificationQueueEntry, r: NotificationQueueEntry -> Ints.compare(l.hashCode(), r.hashCode()) })
class NotificationQueue @Inject constructor(
private val preferences: Preferences,
private val workManager: WorkManager
) {
private val jobs =
TreeMultimap.create<Long, NotificationQueueEntry>(Ordering.natural()) { l, r ->
Ints.compare(l.hashCode(), r.hashCode())
}
@Synchronized
fun <T : NotificationQueueEntry> add(entry: T) = add(listOf(entry))

@ -1,6 +1,7 @@
package org.tasks.jobs;
import static org.tasks.time.DateTimeUtils.currentTimeMillis;
import static org.tasks.time.DateTimeUtils.printTimestamp;
import org.tasks.notifications.Notification;
@ -65,6 +66,6 @@ public class ReminderEntry implements NotificationQueueEntry {
@Override
public String toString() {
return "ReminderEntry{" + "taskId=" + taskId + ", time=" + time + ", type=" + type + '}';
return "ReminderEntry{" + "taskId=" + taskId + ", time=" + printTimestamp(time) + ", type=" + type + '}';
}
}

@ -0,0 +1,76 @@
package org.tasks.reminders
import android.content.Context
import com.todoroo.andlib.utility.DateUtilities
import dagger.hilt.android.qualifiers.ApplicationContext
import org.tasks.R
import org.tasks.data.Alarm
import org.tasks.locale.Locale
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import kotlin.math.absoluteValue
class AlarmToString @Inject constructor(
@ApplicationContext context: Context,
var locale: Locale,
) {
private val resources = context.resources
fun toString(alarm: Alarm): String {
val reminder = when (alarm.type) {
Alarm.TYPE_REL_START ->
if (alarm.time == 0L) {
resources.getString(R.string.when_started)
} else {
val res = if (alarm.time < 0) {
R.string.alarm_before_start
} else {
R.string.alarm_after_start
}
resources.getString(res, getDurationString(alarm.time))
}
Alarm.TYPE_REL_END ->
if (alarm.time == 0L) {
resources.getString(R.string.when_due)
} else {
val res = if (alarm.time < 0) {
R.string.alarm_before_due
} else {
R.string.alarm_after_due
}
resources.getString(res, getDurationString(alarm.time))
}
Alarm.TYPE_RANDOM ->
resources.getString(R.string.randomly_once) + " "
else ->
DateUtilities.getLongDateStringWithTime(alarm.time, locale.locale)
}
return if (alarm.repeat > 0) {
val frequencyPlural = getDurationString(alarm.interval)
val count = alarm.repeat
val countString = resources.getQuantityString(R.plurals.repeat_times, count)
reminder + "\n" + resources.getString(R.string.repeats_plural_number_of_times, frequencyPlural, count, countString)
} else {
reminder
}
}
private fun getDurationString(duration: Long): String {
val seconds = duration.absoluteValue
val day = TimeUnit.MILLISECONDS.toDays(seconds)
val hours = TimeUnit.MILLISECONDS.toHours(seconds) - day * 24
val minute =
TimeUnit.MILLISECONDS.toMinutes(seconds) - TimeUnit.MILLISECONDS.toHours(seconds) * 60
val result = ArrayList<String>()
if (day > 0) {
result.add(resources.getQuantityString(R.plurals.repeat_n_days, day.toInt(), day.toInt()))
}
if (hours > 0) {
result.add(resources.getQuantityString(R.plurals.repeat_n_hours, hours.toInt(), hours.toInt()))
}
if (minute > 0) {
result.add(resources.getQuantityString(R.plurals.repeat_n_minutes, minute.toInt(), minute.toInt()))
}
return result.joinToString(" ")
}
}

@ -24,6 +24,7 @@ object DateTimeUtils {
MILLIS_PROVIDER = SYSTEM_MILLIS_PROVIDER
}
@JvmStatic
fun printTimestamp(timestamp: Long): String =
if (BuildConfig.DEBUG) Date(timestamp).toString() else timestamp.toString()
@ -42,4 +43,7 @@ object DateTimeUtils {
fun Long.millisOfDay(): Int = if (this > 0) toDateTime().millisOfDay else 0
fun Long.toDate(): net.fortuna.ical4j.model.Date? = this.toDateTime().toDate()
fun Long.withMillisOfDay(millisOfDay: Int): Long =
if (this > 0) toDateTime().withMillisOfDay(millisOfDay).millis else 0
}

@ -35,6 +35,11 @@ import org.tasks.R
import org.tasks.Strings
import org.tasks.calendars.CalendarEventProvider
import org.tasks.data.Alarm
import org.tasks.data.Alarm.Companion.TYPE_REL_END
import org.tasks.data.Alarm.Companion.TYPE_REL_START
import org.tasks.data.Alarm.Companion.whenDue
import org.tasks.data.Alarm.Companion.whenOverdue
import org.tasks.data.Alarm.Companion.whenStarted
import org.tasks.data.CaldavDao
import org.tasks.data.CaldavTask
import org.tasks.data.GoogleTask
@ -74,7 +79,8 @@ class TaskEditViewModel @Inject constructor(
private val googleTaskDao: GoogleTaskDao,
private val caldavDao: CaldavDao,
private val taskCompleter: TaskCompleter,
private val alarmService: AlarmService) : ViewModel() {
private val alarmService: AlarmService
) : ViewModel() {
val cleared = MutableLiveData<Event<Boolean>>()
@ -90,7 +96,22 @@ class TaskEditViewModel @Inject constructor(
originalList = list
originalLocation = location
originalTags = tags.toImmutableList()
originalAlarms = alarms.toList().toImmutableSet()
originalAlarms =
if (isNew) {
ArrayList<Alarm>().apply {
if (task.isNotifyAtStart) {
add(whenStarted(0))
}
if (task.isNotifyAtDeadline) {
add(whenDue(0))
}
if (task.isNotifyAfterDeadline) {
add(whenOverdue(0))
}
}
} else {
alarms
}.toImmutableSet()
if (isNew && permissionChecker.canAccessCalendars()) {
originalCalendar = preferences.defaultCalendar
}
@ -250,17 +271,8 @@ class TaskEditViewModel @Inject constructor(
var selectedAlarms: HashSet<Alarm>? = null
var whenStart: Boolean? = null
get() = field ?: (task?.reminderFlags?.and(Task.NOTIFY_AT_START) ?: 0 > 0)
var whenDue: Boolean? = null
get() = field ?: (task?.reminderFlags?.and(Task.NOTIFY_AT_DEADLINE) ?: 0 > 0)
var whenOverdue: Boolean? = null
get() = field ?: (task?.reminderFlags?.and(Task.NOTIFY_AFTER_DEADLINE) ?: 0 > 0)
var ringNonstop: Boolean? = null
get() = field ?: (task?.reminderFlags?.and(Task.NOTIFY_MODE_NONSTOP) ?: 0 > 0)
get() = field ?: (task?.ringFlags?.and(Task.NOTIFY_MODE_NONSTOP) ?: 0 > 0)
set(value) {
field = value
if (value == true) {
@ -269,7 +281,7 @@ class TaskEditViewModel @Inject constructor(
}
var ringFiveTimes:Boolean? = null
get() = field ?: (task?.reminderFlags?.and(Task.NOTIFY_MODE_FIVE) ?: 0 > 0)
get() = field ?: (task?.ringFlags?.and(Task.NOTIFY_MODE_FIVE) ?: 0 > 0)
set(value) {
field = value
if (value == true) {
@ -304,7 +316,7 @@ class TaskEditViewModel @Inject constructor(
originalTags?.toHashSet() != selectedTags?.toHashSet() ||
newSubtasks.isNotEmpty() ||
it.reminderPeriod != reminderPeriod ||
it.reminderFlags != getReminderFlags() ||
it.ringFlags != getRingFlags() ||
originalAlarms != selectedAlarms
} ?: false
@ -329,7 +341,7 @@ class TaskEditViewModel @Inject constructor(
it.repeatUntil = repeatUntil!!
it.elapsedSeconds = elapsedSeconds!!
it.estimatedSeconds = estimatedSeconds!!
it.reminderFlags = getReminderFlags()
it.ringFlags = getRingFlags()
it.reminderPeriod = reminderPeriod!!
applyCalendarChanges()
@ -398,13 +410,23 @@ class TaskEditViewModel @Inject constructor(
}
}
if (selectedAlarms != originalAlarms) {
alarmService.synchronizeAlarms(it.id, selectedAlarms!!)
it.modificationDate = now()
if (!it.hasStartDate()) {
selectedAlarms?.removeIf { a -> a.type == TYPE_REL_START }
}
if (!it.hasDueDate()) {
selectedAlarms?.removeIf { a -> a.type == TYPE_REL_END }
}
taskDao.save(it, null)
if (
selectedAlarms != originalAlarms ||
(isNew && selectedAlarms?.isNotEmpty() == true)
) {
alarmService.synchronizeAlarms(it.id, selectedAlarms!!)
it.modificationDate = now()
}
if (it.isCompleted != completed!!) {
taskCompleter.setComplete(it, completed!!)
}
@ -431,17 +453,8 @@ class TaskEditViewModel @Inject constructor(
}
}
private fun getReminderFlags(): Int {
private fun getRingFlags(): Int {
var value = 0
if (whenStart == true) {
value = value or Task.NOTIFY_AT_START
}
if (whenDue == true) {
value = value or Task.NOTIFY_AT_DEADLINE
}
if (whenOverdue == true) {
value = value or Task.NOTIFY_AFTER_DEADLINE
}
value = value and (Task.NOTIFY_MODE_FIVE or Task.NOTIFY_MODE_NONSTOP).inv()
if (ringNonstop == true) {
value = value or Task.NOTIFY_MODE_NONSTOP

@ -716,4 +716,8 @@ File %1$s contained %2$s.\n\n
<string name="completed_tasks_at_bottom">Move completed tasks to bottom</string>
<string name="completed_tasks_sort">Sort by completion date</string>
<string name="snackbar_tasks_completed">%d tasks completed</string>
<string name="alarm_before_start">%s before start</string>
<string name="alarm_after_start">%s after start</string>
<string name="alarm_before_due">%s before due</string>
<string name="alarm_after_due">%s after due</string>
</resources>

Loading…
Cancel
Save