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