diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index bbf32d67..c8ca55d2 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -12,6 +12,7 @@
+
diff --git a/app/src/main/java/com/philkes/notallyx/data/model/Converters.kt b/app/src/main/java/com/philkes/notallyx/data/model/Converters.kt
index af465417..29d06509 100644
--- a/app/src/main/java/com/philkes/notallyx/data/model/Converters.kt
+++ b/app/src/main/java/com/philkes/notallyx/data/model/Converters.kt
@@ -147,6 +147,7 @@ object Converters {
put("id", reminder.id) // Store date as long timestamp
put("dateTime", reminder.dateTime.time) // Store date as long timestamp
put("repetition", reminder.repetition?.let { repetitionToJsonObject(it) })
+ put("isNotificationVisible", reminder.isNotificationVisible)
}
}
return JSONArray(objects)
@@ -159,7 +160,8 @@ object Converters {
val id = jsonObject.getLong("id")
val dateTime = Date(jsonObject.getLong("dateTime"))
val repetition = jsonObject.getSafeString("repetition")?.let { jsonToRepetition(it) }
- Reminder(id, dateTime, repetition)
+ val isNotificationVisible = jsonObject.getSafeBoolean("isNotificationVisible")
+ Reminder(id, dateTime, repetition, isNotificationVisible)
}
}
diff --git a/app/src/main/java/com/philkes/notallyx/data/model/ModelExtensions.kt b/app/src/main/java/com/philkes/notallyx/data/model/ModelExtensions.kt
index ae432523..1c30a50c 100644
--- a/app/src/main/java/com/philkes/notallyx/data/model/ModelExtensions.kt
+++ b/app/src/main/java/com/philkes/notallyx/data/model/ModelExtensions.kt
@@ -360,6 +360,31 @@ fun RepetitionTimeUnit.toCalendarField(): Int {
}
}
+fun Reminder.lastNotification(before: Date = Date()): Date? {
+ if (before.before(dateTime) || before == dateTime) {
+ return null
+ }
+ if (repetition == null) {
+ return dateTime
+ }
+
+ val calendar = dateTime.toCalendar()
+ val field = repetition!!.unit.toCalendarField()
+ val value = repetition!!.value
+
+ var last = calendar.time
+ // Increment until we are at or after 'before'
+ while (true) {
+ calendar.add(field, value)
+ if (calendar.time.after(before) || calendar.time == before) {
+ break
+ }
+ last = calendar.time
+ }
+
+ return last
+}
+
fun Reminder.nextNotification(from: Date = Date()): Date? {
if (from.before(dateTime)) {
return dateTime
@@ -367,12 +392,19 @@ fun Reminder.nextNotification(from: Date = Date()): Date? {
if (repetition == null) {
return null
}
- val timeDifferenceMillis: Long = from.time - dateTime.time
- val intervalsPassed = timeDifferenceMillis / repetition!!.toMillis()
- val unitsUntilNext = ((repetition!!.value) * (intervalsPassed + 1)).toInt()
- val reminderStart = dateTime.toCalendar()
- reminderStart.add(repetition!!.unit.toCalendarField(), unitsUntilNext)
- return reminderStart.time
+ val calendar = dateTime.toCalendar()
+ val field = repetition!!.unit.toCalendarField()
+ val value = repetition!!.value
+
+ // If from is exactly at dateTime, we want the next one
+ while (true) {
+ calendar.add(field, value)
+ if (calendar.time.after(from)) {
+ break
+ }
+ }
+
+ return calendar.time
}
fun Reminder.nextRepetition(from: Date = Date()): Date? {
@@ -398,7 +430,16 @@ fun Collection.hasAnyUpcomingNotifications(): Boolean {
}
fun Collection.findNextNotificationDate(): Date? {
- return mapNotNull { it.nextNotification() }.minByOrNull { it }
+ return mapNotNull { it.nextNotification() }.minOrNull()
+}
+
+fun Collection.findLastNotificationDate(before: Date = Date()): Date? {
+ return mapNotNull { it.lastNotification(before) }.maxOrNull()
+}
+
+fun Collection.findLastNotified(before: Date = Date()): Reminder? {
+ return filter { it.lastNotification(before) != null }
+ .maxByOrNull { it.lastNotification(before)!! }
}
fun Date.toCalendar() = Calendar.getInstance().apply { timeInMillis = this@toCalendar.time }
diff --git a/app/src/main/java/com/philkes/notallyx/data/model/Reminder.kt b/app/src/main/java/com/philkes/notallyx/data/model/Reminder.kt
index 00419468..ba151e63 100644
--- a/app/src/main/java/com/philkes/notallyx/data/model/Reminder.kt
+++ b/app/src/main/java/com/philkes/notallyx/data/model/Reminder.kt
@@ -5,7 +5,12 @@ import java.util.Date
import kotlinx.parcelize.Parcelize
@Parcelize
-data class Reminder(var id: Long, var dateTime: Date, var repetition: Repetition?) : Parcelable
+data class Reminder(
+ var id: Long,
+ var dateTime: Date,
+ var repetition: Repetition?,
+ var isNotificationVisible: Boolean = false,
+) : Parcelable
@Parcelize data class Repetition(var value: Int, var unit: RepetitionTimeUnit) : Parcelable
diff --git a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt
index 14189281..50c32dcb 100644
--- a/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt
+++ b/app/src/main/java/com/philkes/notallyx/presentation/activity/note/reminders/ReminderReceiver.kt
@@ -3,6 +3,7 @@ package com.philkes.notallyx.presentation.activity.note.reminders
import android.app.AlarmManager
import android.app.Application
import android.app.NotificationManager
+import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
@@ -13,6 +14,7 @@ import androidx.core.content.getSystemService
import com.philkes.notallyx.R
import com.philkes.notallyx.data.NotallyDatabase
import com.philkes.notallyx.data.model.Reminder
+import com.philkes.notallyx.data.model.findLastNotified
import com.philkes.notallyx.utils.canScheduleAlarms
import com.philkes.notallyx.utils.cancelReminder
import com.philkes.notallyx.utils.createChannelIfNotExists
@@ -38,98 +40,179 @@ class ReminderReceiver : BroadcastReceiver() {
return
}
val canScheduleExactAlarms = context.canScheduleAlarms()
- if (intent.action == null) {
- if (!canScheduleExactAlarms) {
- return
- }
- val reminderId = intent.getLongExtra(EXTRA_REMINDER_ID, -1L)
- val noteId = intent.getLongExtra(EXTRA_NOTE_ID, -1L)
- notify(context, noteId, reminderId)
- } else {
- when {
- canScheduleExactAlarms && intent.action == Intent.ACTION_BOOT_COMPLETED ->
- rescheduleAlarms(context)
+ goAsyncScope {
+ if (intent.action == null) {
+ if (!canScheduleExactAlarms) {
+ return@goAsyncScope
+ }
+ val reminderId = intent.getLongExtra(EXTRA_REMINDER_ID, -1L)
+ val noteId = intent.getLongExtra(EXTRA_NOTE_ID, -1L)
+ notify(context, noteId, reminderId)
+ } else {
+ when {
+ intent.action == Intent.ACTION_BOOT_COMPLETED -> {
+ if (canScheduleExactAlarms) {
+ rescheduleAlarms(context)
+ }
+ restoreRemindersNotifications(context)
+ }
- intent.action ==
- AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED -> {
- if (canScheduleExactAlarms) {
- rescheduleAlarms(context)
- } else {
- cancelAlarms(context)
+ intent.action ==
+ AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED -> {
+ if (canScheduleExactAlarms) {
+ rescheduleAlarms(context)
+ } else {
+ cancelAlarms(context)
+ }
+ }
+
+ intent.action == ACTION_NOTIFICATION_DISMISSED -> {
+ val noteId = intent.getLongExtra(EXTRA_NOTE_ID, -1L)
+ val reminderId = intent.getLongExtra(EXTRA_REMINDER_ID, -1L)
+ Log.d(
+ TAG,
+ "Notification dismissed for noteId: $noteId, reminderId: $reminderId",
+ )
+ setIsNotificationVisible(false, context, noteId, reminderId)
}
}
}
}
}
- private fun notify(context: Context, noteId: Long, reminderId: Long) {
+ private suspend fun notify(
+ context: Context,
+ noteId: Long,
+ reminderId: Long,
+ schedule: Boolean = true,
+ ) {
Log.d(TAG, "notify: noteId: $noteId reminderId: $reminderId")
- CoroutineScope(Dispatchers.IO).launch {
- val database =
- NotallyDatabase.getDatabase(context.applicationContext as Application, false).value
- val manager = context.getSystemService()!!
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- manager.createChannelIfNotExists(
- NOTIFICATION_CHANNEL_ID,
- importance = NotificationManager.IMPORTANCE_HIGH,
- )
- }
- database.getBaseNoteDao().get(noteId)?.let { note ->
- val notification =
- NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
- .setSmallIcon(R.drawable.notebook)
- .setContentTitle(note.title) // Set title from intent
- .setContentText(note.body.truncate(200)) // Set content text from intent
- .setPriority(NotificationCompat.PRIORITY_HIGH)
- .addAction(
- R.drawable.visibility,
- context.getString(R.string.open_note),
- context.getOpenNotePendingIntent(note),
- )
- .build()
- note.reminders
- .find { it.id == reminderId }
- ?.let { reminder: Reminder ->
- manager.notify(note.id.toString(), reminderId.toInt(), notification)
+ val database = getDatabase(context)
+ val manager = context.getSystemService()!!
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ manager.createChannelIfNotExists(
+ NOTIFICATION_CHANNEL_ID,
+ importance = NotificationManager.IMPORTANCE_HIGH,
+ )
+ }
+ database.getBaseNoteDao().get(noteId)?.let { note ->
+ val notification =
+ NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
+ .setSmallIcon(R.drawable.notebook)
+ .setContentTitle(note.title)
+ .setContentText(note.body.truncate(200))
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .addAction(
+ R.drawable.visibility,
+ context.getString(R.string.open_note),
+ context.getOpenNotePendingIntent(note),
+ )
+ .setDeleteIntent(getDeletePendingIntent(context, noteId, reminderId))
+ .build()
+ note.reminders
+ .find { it.id == reminderId }
+ ?.let { reminder: Reminder ->
+ setIsNotificationVisible(true, context, note.id, reminderId)
+ manager.notify(note.id.toString(), reminderId.toInt(), notification)
+ if (schedule)
context.scheduleReminder(note.id, reminder, forceRepetition = true)
+ }
+ }
+ }
+
+ private suspend fun rescheduleAlarms(context: Context) {
+ val database = getDatabase(context)
+ val now = Date()
+ val noteReminders = database.getBaseNoteDao().getAllReminders()
+ val noteRemindersWithFutureNotify =
+ noteReminders.flatMap { (noteId, reminders) ->
+ reminders
+ .filter { reminder ->
+ reminder.repetition != null || reminder.dateTime.after(now)
}
+ .map { reminder -> Pair(noteId, reminder) }
}
+ Log.d(TAG, "rescheduleAlarms: ${noteRemindersWithFutureNotify.size} alarms")
+ noteRemindersWithFutureNotify.forEach { (noteId, reminder) ->
+ context.scheduleReminder(noteId, reminder)
}
}
- private fun rescheduleAlarms(context: Context) {
- CoroutineScope(Dispatchers.IO).launch {
- val database =
- NotallyDatabase.getDatabase(context.applicationContext as Application, false).value
- val now = Date()
- val noteReminders = database.getBaseNoteDao().getAllReminders()
- val noteRemindersWithFutureNotify =
- noteReminders.flatMap { (noteId, reminders) ->
- reminders
- .filter { reminder ->
- reminder.repetition != null || reminder.dateTime.after(now)
- }
- .map { reminder -> Pair(noteId, reminder) }
- }
- Log.d(TAG, "rescheduleAlarms: ${noteRemindersWithFutureNotify.size} alarms")
- noteRemindersWithFutureNotify.forEach { (noteId, reminder) ->
- context.scheduleReminder(noteId, reminder)
+ private suspend fun cancelAlarms(context: Context) {
+ val database = getDatabase(context)
+ val noteReminders = database.getBaseNoteDao().getAllReminders()
+ val noteRemindersWithFutureNotify =
+ noteReminders.flatMap { (noteId, reminders) ->
+ reminders.map { reminder -> Pair(noteId, reminder.id) }
+ }
+ Log.d(TAG, "cancelAlarms: ${noteRemindersWithFutureNotify.size} alarms")
+ noteRemindersWithFutureNotify.forEach { (noteId, reminderId) ->
+ context.cancelReminder(noteId, reminderId)
+ }
+ }
+
+ private suspend fun setIsNotificationVisible(
+ isNotificationVisible: Boolean,
+ context: Context,
+ noteId: Long,
+ reminderId: Long,
+ ) {
+ val baseNoteDao = getDatabase(context).getBaseNoteDao()
+ val note = baseNoteDao.get(noteId) ?: return
+ val currentReminders = note.reminders.toMutableList()
+ val index = currentReminders.indexOfFirst { it.id == reminderId }
+ if (index != -1) {
+ if (currentReminders[index].isNotificationVisible != isNotificationVisible) {
+ currentReminders[index] =
+ currentReminders[index].copy(isNotificationVisible = isNotificationVisible)
+ baseNoteDao.updateReminders(noteId, currentReminders)
+ }
+ }
+ }
+
+ private fun getDeletePendingIntent(
+ context: Context,
+ noteId: Long,
+ reminderId: Long,
+ ): PendingIntent {
+ val deleteIntent =
+ Intent(context, ReminderReceiver::class.java).apply {
+ action = ACTION_NOTIFICATION_DISMISSED
+ putExtra(EXTRA_NOTE_ID, noteId)
+ putExtra(EXTRA_REMINDER_ID, reminderId)
+ }
+ val deletePendingIntent =
+ PendingIntent.getBroadcast(
+ context,
+ "$noteId-$reminderId".hashCode(),
+ deleteIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ return deletePendingIntent
+ }
+
+ private suspend fun restoreRemindersNotifications(context: Context) {
+ val baseNoteDao = getDatabase(context).getBaseNoteDao()
+ val allNotes = baseNoteDao.getAllNotes()
+ allNotes.forEach { note ->
+ val mostRecentReminder = note.reminders.findLastNotified() ?: return@forEach
+ if (mostRecentReminder.isNotificationVisible) {
+ notify(context, note.id, mostRecentReminder.id, schedule = false)
}
}
}
- private fun cancelAlarms(context: Context) {
+ private fun getDatabase(context: Context): NotallyDatabase {
+ return NotallyDatabase.getDatabase(context.applicationContext as Application, false).value
+ }
+
+ private fun goAsyncScope(codeBlock: suspend CoroutineScope.() -> Unit) {
+ val pendingResult = goAsync()
CoroutineScope(Dispatchers.IO).launch {
- val database =
- NotallyDatabase.getDatabase(context.applicationContext as Application, false).value
- val noteReminders = database.getBaseNoteDao().getAllReminders()
- val noteRemindersWithFutureNotify =
- noteReminders.flatMap { (noteId, reminders) ->
- reminders.map { reminder -> Pair(noteId, reminder.id) }
- }
- Log.d(TAG, "cancelAlarms: ${noteRemindersWithFutureNotify.size} alarms")
- noteRemindersWithFutureNotify.forEach { (noteId, reminderId) ->
- context.cancelReminder(noteId, reminderId)
+ try {
+ codeBlock()
+ } finally {
+ pendingResult.finish()
}
}
}
@@ -141,5 +224,7 @@ class ReminderReceiver : BroadcastReceiver() {
const val EXTRA_REMINDER_ID = "notallyx.intent.extra.REMINDER_ID"
const val EXTRA_NOTE_ID = "notallyx.intent.extra.NOTE_ID"
+ const val ACTION_NOTIFICATION_DISMISSED =
+ "com.philkes.notallyx.ACTION_NOTIFICATION_DISMISSED"
}
}
diff --git a/app/src/test/kotlin/com/philkes/notallyx/data/model/ModelExtensionsTest.kt b/app/src/test/kotlin/com/philkes/notallyx/data/model/ModelExtensionsTest.kt
index 1971ac6f..ea4e2a51 100644
--- a/app/src/test/kotlin/com/philkes/notallyx/data/model/ModelExtensionsTest.kt
+++ b/app/src/test/kotlin/com/philkes/notallyx/data/model/ModelExtensionsTest.kt
@@ -59,7 +59,8 @@ class ModelExtensionsTest {
{
"id": 0,
"dateTime": 1742822940000,
- "repetition": "{\"value\":1,\"unit\":\"DAYS\"}"
+ "repetition": "{\"value\":1,\"unit\":\"DAYS\"}",
+ "isNotificationVisible": false
}
],
"viewMode": "READ_ONLY"
@@ -125,6 +126,7 @@ class ModelExtensionsTest {
{
"reminders": [{
"dateTime": 1743253506957,
+ "isNotificationVisible": false,
"id": 1,
"repetition": {
"unit": "WEEKS",
diff --git a/app/src/test/kotlin/com/philkes/notallyx/data/model/ReminderTest.kt b/app/src/test/kotlin/com/philkes/notallyx/data/model/ReminderTest.kt
index 69fd07fc..5e0ad7cd 100644
--- a/app/src/test/kotlin/com/philkes/notallyx/data/model/ReminderTest.kt
+++ b/app/src/test/kotlin/com/philkes/notallyx/data/model/ReminderTest.kt
@@ -18,9 +18,9 @@ class ReminderTest {
@Test
fun testNextRepetition() {
- val repetitionStart = Calendar.getInstance().apply { set(2000, 0, 1, 0, 0, 0) }
+ val repetitionStart = calendar(2000, 0, 1)
val reminder = Reminder(0, repetitionStart.time, Repetition(1, RepetitionTimeUnit.YEARS))
- val from = Calendar.getInstance().apply { set(2004, 6, 3, 3, 1, 2) }.time
+ val from = calendar(2004, 6, 3, 3, 1, 2).time
val actual = reminder.nextRepetition(from)!!.time
@@ -29,6 +29,182 @@ class ReminderTest {
assertEquals(expected, actual)
}
+ @Test
+ fun testNextNotification() {
+ val start = calendar(2023, 0, 1, 10)
+ val reminderNoRep = Reminder(0, start.time, null)
+
+ // Before start, no rep -> returns start
+ assertEquals(start.time, reminderNoRep.nextNotification(calendar(2022, 11, 31, 10).time))
+ // After start, no rep -> returns null
+ assertEquals(null, reminderNoRep.nextNotification(calendar(2023, 0, 1, 10, 0, 1).time))
+
+ val reminderRep = Reminder(0, start.time, Repetition(1, RepetitionTimeUnit.DAYS))
+ // Before start, with rep -> returns start
+ assertEquals(start.time, reminderRep.nextNotification(calendar(2022, 11, 31, 10).time))
+ // Exactly at start, with rep -> returns next (1 day later)
+ val expectedAfterStart = start.copy().apply { add(Calendar.DAY_OF_MONTH, 1) }.time
+ assertEquals(expectedAfterStart, reminderRep.nextNotification(start.time))
+ // After start, with rep -> returns next
+ val fromAfterStart = start.copy().apply { add(Calendar.HOUR, 5) }.time
+ assertEquals(expectedAfterStart, reminderRep.nextNotification(fromAfterStart))
+ }
+
+ @Test
+ fun testLastNotification() {
+ val start = calendar(2023, 0, 1, 10)
+ val reminderNoRep = Reminder(0, start.time, null)
+
+ // Before start, no rep -> returns null
+ assertEquals(null, reminderNoRep.lastNotification(calendar(2022, 11, 31, 10).time))
+ // Exactly at start, no rep -> returns null (per code before or == start)
+ assertEquals(null, reminderNoRep.lastNotification(start.time))
+ // After start, no rep -> returns start
+ val afterStart = start.copy().apply { add(Calendar.SECOND, 1) }.time
+ assertEquals(start.time, reminderNoRep.lastNotification(afterStart))
+
+ val reminderRep = Reminder(0, start.time, Repetition(1, RepetitionTimeUnit.HOURS))
+ // Before start, with rep -> returns null
+ assertEquals(null, reminderRep.lastNotification(calendar(2023, 0, 1, 9).time))
+ // After start, with rep -> returns last
+ val afterStartRep = start.copy().apply { add(Calendar.MINUTE, 30) }.time
+ assertEquals(start.time, reminderRep.lastNotification(afterStartRep))
+
+ // Exactly on a spike (2 hours after start) -> should return the one BEFORE (1 hour after
+ // start)
+ val twoHoursAfter = start.copy().apply { add(Calendar.HOUR, 2) }.time
+ val oneHourAfter = start.copy().apply { add(Calendar.HOUR, 1) }.time
+ assertEquals(oneHourAfter, reminderRep.lastNotification(twoHoursAfter))
+
+ // Multiple intervals
+ val threeHoursAndHalf =
+ start
+ .copy()
+ .apply {
+ add(Calendar.HOUR, 3)
+ add(Calendar.MINUTE, 30)
+ }
+ .time
+ val threeHoursAfter = start.copy().apply { add(Calendar.HOUR, 3) }.time
+ assertEquals(threeHoursAfter, reminderRep.lastNotification(threeHoursAndHalf))
+ }
+
+ @Test
+ fun testFindNextNotificationDate() {
+ val nextYear = Calendar.getInstance().get(Calendar.YEAR) + 1
+ val r1 = Reminder(1, calendar(nextYear, 0, 1, 11).time, null)
+ val r2 = Reminder(2, calendar(nextYear, 0, 1, 12).time, null)
+
+ val reminders = listOf(r1, r2)
+ val nextDate = reminders.findNextNotificationDate()
+ assertEquals(r1.dateTime, nextDate)
+ }
+
+ @Test
+ fun testFindLastNotificationDate() {
+ val r1 = Reminder(1, calendar(2020, 0, 1, 10).time, null)
+ val r2 = Reminder(2, calendar(2021, 0, 1, 10).time, null)
+
+ val reminders = listOf(r1, r2)
+ val lastDate = reminders.findLastNotificationDate()
+ // Both are in the past, so lastNotification() for both should be their dateTime.
+ // findLastNotificationDate should return the MAXIMUM of them.
+ assertEquals(r2.dateTime, lastDate)
+ }
+
+ @Test
+ fun testFindLastNotified() {
+ val r1 = Reminder(1, calendar(2020, 0, 1, 10).time, null)
+ val r2 = Reminder(2, calendar(2021, 0, 1, 10).time, null)
+
+ val reminders = listOf(r1, r2)
+ val lastNotified = reminders.findLastNotified()
+ // r2 was last notified later than r1
+ assertEquals(r2, lastNotified)
+
+ // Case where before is specified
+ val before = calendar(2020, 6, 1, 10).time
+ val lastNotifiedBefore = reminders.findLastNotified(before)
+ // Only r1 was notified before 2020-06-01
+ assertEquals(r1, lastNotifiedBefore)
+ }
+
+ @Test
+ fun testRepetitionsWithDifferentUnits() {
+ val start = calendar(2023, 0, 1, 10)
+
+ // MINUTES
+ val reminderMinutes = Reminder(0, start.time, Repetition(30, RepetitionTimeUnit.MINUTES))
+ val after35Min = start.copy().apply { add(Calendar.MINUTE, 35) }.time
+ // Expected is 10:30, because 10:00 + 30m = 10:30, which is before 10:35
+ assertEquals(
+ start.copy().apply { add(Calendar.MINUTE, 30) }.time,
+ reminderMinutes.lastNotification(after35Min),
+ )
+ val after65Min = start.copy().apply { add(Calendar.MINUTE, 65) }.time
+ val expectedMin = start.copy().apply { add(Calendar.MINUTE, 60) }.time
+ assertEquals(expectedMin, reminderMinutes.lastNotification(after65Min))
+
+ // WEEKS
+ val reminderWeeks = Reminder(0, start.time, Repetition(1, RepetitionTimeUnit.WEEKS))
+ val after1Week =
+ start
+ .copy()
+ .apply {
+ add(Calendar.WEEK_OF_YEAR, 1)
+ add(Calendar.SECOND, 1)
+ }
+ .time
+ assertEquals(
+ start.copy().apply { add(Calendar.WEEK_OF_YEAR, 1) }.time,
+ reminderWeeks.lastNotification(after1Week),
+ )
+
+ // MONTHS
+ val reminderMonths = Reminder(0, start.time, Repetition(1, RepetitionTimeUnit.MONTHS))
+ val after1Month =
+ start
+ .copy()
+ .apply {
+ add(Calendar.MONTH, 1)
+ add(Calendar.SECOND, 1)
+ }
+ .time
+ assertEquals(
+ start.copy().apply { add(Calendar.MONTH, 1) }.time,
+ reminderMonths.lastNotification(after1Month),
+ )
+
+ // YEARS
+ val reminderYears = Reminder(0, start.time, Repetition(1, RepetitionTimeUnit.YEARS))
+ val after1Year =
+ start
+ .copy()
+ .apply {
+ add(Calendar.YEAR, 1)
+ add(Calendar.SECOND, 1)
+ }
+ .time
+ assertEquals(
+ start.copy().apply { add(Calendar.YEAR, 1) }.time,
+ reminderYears.lastNotification(after1Year),
+ )
+ }
+
+ private fun calendar(
+ year: Int,
+ month: Int,
+ day: Int,
+ hourOfDay: Int = 0,
+ minute: Int = 0,
+ second: Int = 0,
+ millis: Int = 0,
+ ): Calendar =
+ Calendar.getInstance().apply {
+ set(year, month, day, hourOfDay, minute, second)
+ set(Calendar.MILLISECOND, millis)
+ }
+
private fun Calendar.copy(): Calendar {
val calendar = Calendar.getInstance()
calendar.timeInMillis = timeInMillis