Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<uses-permission android:name="android.permission.RECORD_AUDIO" />

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />

<uses-permission android:name="android.permission.USE_BIOMETRIC" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -360,19 +360,51 @@ 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
}
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? {
Expand All @@ -398,7 +430,16 @@ fun Collection<Reminder>.hasAnyUpcomingNotifications(): Boolean {
}

fun Collection<Reminder>.findNextNotificationDate(): Date? {
return mapNotNull { it.nextNotification() }.minByOrNull { it }
return mapNotNull { it.nextNotification() }.minOrNull()
}

fun Collection<Reminder>.findLastNotificationDate(before: Date = Date()): Date? {
return mapNotNull { it.lastNotification(before) }.maxOrNull()
}

fun Collection<Reminder>.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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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<NotificationManager>()!!
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<NotificationManager>()!!
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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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)
}
Comment thread
ulibte marked this conversation as resolved.
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private fun cancelAlarms(context: Context) {
private fun getDatabase(context: Context): NotallyDatabase {
return NotallyDatabase.getDatabase(context.applicationContext as Application, false).value
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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()
}
}
}
Expand All @@ -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"
}
}
Loading