Skip to content
Open
17 changes: 17 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

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

<application
android:name=".TaskManagerApp"
android:allowBackup="true"
Expand All @@ -20,6 +24,19 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<receiver
android:name=".notification.TaskReminderReceiver"
android:exported="false" />

<receiver
android:name=".notification.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>

</application>

</manifest>
13 changes: 12 additions & 1 deletion app/src/main/java/fr/benju/tasks/TaskManagerApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@ package fr.benju.tasks

import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import fr.benju.tasks.notification.NotificationHelper
import javax.inject.Inject

@HiltAndroidApp
class TaskManagerApp : Application()
class TaskManagerApp : Application() {

@Inject
lateinit var notificationHelper: NotificationHelper

override fun onCreate() {
super.onCreate()
notificationHelper.createNotificationChannel()
}
}
2 changes: 1 addition & 1 deletion app/src/main/java/fr/benju/tasks/di/DatabaseModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ object DatabaseModule {
context,
TaskDatabase::class.java,
TaskDatabase.DATABASE_NAME
).build()
).addMigrations(*TaskDatabase.migrations).build()
}

@Provides
Expand Down
20 changes: 20 additions & 0 deletions app/src/main/java/fr/benju/tasks/di/NotificationModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package fr.benju.tasks.di

import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import fr.benju.tasks.domain.service.ReminderScheduler
import fr.benju.tasks.notification.ReminderSchedulerImpl
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
abstract class NotificationModule {

@Binds
@Singleton
abstract fun bindReminderScheduler(
impl: ReminderSchedulerImpl
): ReminderScheduler
}
42 changes: 42 additions & 0 deletions app/src/main/java/fr/benju/tasks/notification/BootReceiver.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package fr.benju.tasks.notification

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import fr.benju.tasks.domain.model.TaskFilter
import fr.benju.tasks.domain.service.ReminderScheduler
import fr.benju.tasks.domain.usecase.GetTasksUseCase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject

@AndroidEntryPoint
class BootReceiver : BroadcastReceiver() {

@Inject
lateinit var getTasksUseCase: GetTasksUseCase

@Inject
lateinit var reminderScheduler: ReminderScheduler

override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return

val pendingResult = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
getTasksUseCase(TaskFilter.ACTIVE)
.first()
.filter { it.dueDate != null }
.forEach { task ->
reminderScheduler.schedule(task.id, task.title, task.dueDate!!, task.repeatInterval)
}
} finally {
pendingResult.finish()
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package fr.benju.tasks.notification

import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import fr.benju.tasks.R
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class NotificationHelper @Inject constructor(
@ApplicationContext private val context: Context
) {

fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_HIGH
).apply {
description = context.getString(R.string.notification_channel_description)
}
val nm = context.getSystemService(NotificationManager::class.java)
nm.createNotificationChannel(channel)
}

companion object {
const val CHANNEL_ID = "task_reminders"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package fr.benju.tasks.notification

import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import dagger.hilt.android.qualifiers.ApplicationContext
import fr.benju.tasks.domain.model.RepeatInterval
import fr.benju.tasks.domain.service.ReminderScheduler
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class ReminderSchedulerImpl @Inject constructor(
@ApplicationContext private val context: Context
) : ReminderScheduler {

override fun schedule(
taskId: Long,
taskTitle: String,
dueDate: Long,
repeatInterval: RepeatInterval
) {
if (dueDate <= System.currentTimeMillis()) return

val intent = Intent(context, TaskReminderReceiver::class.java).apply {
putExtra(TaskReminderReceiver.EXTRA_TASK_ID, taskId)
putExtra(TaskReminderReceiver.EXTRA_TASK_TITLE, taskTitle)
putExtra(TaskReminderReceiver.EXTRA_DUE_DATE_MS, dueDate)
putExtra(TaskReminderReceiver.EXTRA_REPEAT_INTERVAL, repeatInterval.name)
}
val pendingIntent = PendingIntent.getBroadcast(
context,
taskId.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
Comment on lines +33 to +38
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using taskId.toInt() as the PendingIntent requestCode can silently overflow/collide once IDs exceed Int.MAX_VALUE, leading to reminders overwriting/canceling the wrong task. Prefer a stable Int derived from the Long (e.g., hashCode()) or store/use an Int primary key for alarms.

Copilot uses AI. Check for mistakes.

val alarmManager = context.getSystemService(AlarmManager::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) {
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, dueDate, pendingIntent)
} else {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, dueDate, pendingIntent)
}
}

override fun cancel(taskId: Long) {
val intent = Intent(context, TaskReminderReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
context,
taskId.hashCode(),
intent,
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
) ?: return

val alarmManager = context.getSystemService(AlarmManager::class.java)
alarmManager.cancel(pendingIntent)
pendingIntent.cancel()
}
}
101 changes: 101 additions & 0 deletions app/src/main/java/fr/benju/tasks/notification/TaskReminderReceiver.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package fr.benju.tasks.notification

import android.Manifest
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.AndroidEntryPoint
import fr.benju.tasks.R
import fr.benju.tasks.domain.model.RepeatInterval
import fr.benju.tasks.domain.service.ReminderScheduler
import fr.benju.tasks.domain.usecase.GetTaskByIdUseCase
import fr.benju.tasks.domain.usecase.UpdateTaskUseCase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneId
import javax.inject.Inject

@AndroidEntryPoint
class TaskReminderReceiver : BroadcastReceiver() {

@Inject
lateinit var reminderScheduler: ReminderScheduler

@Inject
lateinit var getTaskByIdUseCase: GetTaskByIdUseCase

@Inject
lateinit var updateTaskUseCase: UpdateTaskUseCase

override fun onReceive(context: Context, intent: Intent) {
val taskId = intent.getLongExtra(EXTRA_TASK_ID, -1L)
val taskTitle = intent.getStringExtra(EXTRA_TASK_TITLE) ?: return
if (taskId == -1L) return

// ── Show notification ────────────────────────────────────────────────
val hasPermission = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
} else {
true
}
if (hasPermission) {
val notification = NotificationCompat.Builder(context, NotificationHelper.CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_monochrome)
.setContentTitle(context.getString(R.string.notification_title))
.setContentText(context.getString(R.string.notification_text, taskTitle))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.build()

NotificationManagerCompat.from(context).notify(taskId.hashCode(), notification)
}

// ── Reschedule repeating tasks ────────────────────────────────────────
val dueDateMs = intent.getLongExtra(EXTRA_DUE_DATE_MS, -1L)
val repeatInterval = runCatching {
RepeatInterval.valueOf(intent.getStringExtra(EXTRA_REPEAT_INTERVAL) ?: "NONE")
}.getOrDefault(RepeatInterval.NONE)

if (repeatInterval != RepeatInterval.NONE && dueDateMs != -1L) {
val pendingResult = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
val nextDueDate = computeNextOccurrence(dueDateMs, repeatInterval)
val task = getTaskByIdUseCase(taskId)
if (task != null) {
updateTaskUseCase(task.copy(dueDate = nextDueDate))
reminderScheduler.schedule(taskId, taskTitle, nextDueDate, repeatInterval)
Comment on lines +72 to +76
}
} finally {
pendingResult.finish()
}
}
}
}

companion object {
const val EXTRA_TASK_ID = "extra_task_id"
const val EXTRA_TASK_TITLE = "extra_task_title"
const val EXTRA_DUE_DATE_MS = "extra_due_date_ms"
const val EXTRA_REPEAT_INTERVAL = "extra_repeat_interval"

fun computeNextOccurrence(currentMs: Long, interval: RepeatInterval): Long {
val zdt = Instant.ofEpochMilli(currentMs).atZone(ZoneId.systemDefault())
return when (interval) {
RepeatInterval.DAILY -> zdt.plusDays(1)
RepeatInterval.WEEKLY -> zdt.plusWeeks(1)
RepeatInterval.MONTHLY -> zdt.plusMonths(1)
RepeatInterval.NONE -> zdt
}.toInstant().toEpochMilli()
}
}
}
6 changes: 6 additions & 0 deletions app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,11 @@
<!-- Common -->
<string name="delete">Löschen</string>
<string name="edit">Bearbeiten</string>

<!-- Notifications -->
<string name="notification_channel_name">Aufgabenerinnerungen</string>
<string name="notification_channel_description">Benachrichtigt dich, wenn eine Aufgabe fällig ist</string>
<string name="notification_title">Aufgabe fällig</string>
<string name="notification_text">"%1$s" ist heute fällig</string>
</resources>

6 changes: 6 additions & 0 deletions app/src/main/res/values-es/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,11 @@
<!-- Common -->
<string name="delete">Eliminar</string>
<string name="edit">Editar</string>

<!-- Notifications -->
<string name="notification_channel_name">Recordatorios de tareas</string>
<string name="notification_channel_description">Te avisa cuando una tarea está pendiente</string>
<string name="notification_title">Tarea pendiente</string>
<string name="notification_text">"%1$s" vence hoy</string>
</resources>

6 changes: 6 additions & 0 deletions app/src/main/res/values-fr/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,11 @@
<!-- Common -->
<string name="delete">Supprimer</string>
<string name="edit">Modifier</string>

<!-- Notifications -->
<string name="notification_channel_name">Rappels de tâches</string>
<string name="notification_channel_description">Vous avertit lorsqu\'une tâche est due</string>
<string name="notification_title">Tâche due</string>
<string name="notification_text">"%1$s" est due aujourd\'hui</string>
</resources>

6 changes: 6 additions & 0 deletions app/src/main/res/values-it/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,11 @@
<!-- Common -->
<string name="delete">Elimina</string>
<string name="edit">Modifica</string>

<!-- Notifications -->
<string name="notification_channel_name">Promemoria attività</string>
<string name="notification_channel_description">Ti avvisa quando un\'attività è in scadenza</string>
<string name="notification_title">Attività in scadenza</string>
<string name="notification_text">"%1$s" scade oggi</string>
</resources>

6 changes: 6 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,10 @@
<!-- Common -->
<string name="delete">Delete</string>
<string name="edit">Edit</string>

<!-- Notifications -->
<string name="notification_channel_name">Task Reminders</string>
<string name="notification_channel_description">Notifies you when a task is due</string>
<string name="notification_title">Task Due</string>
<string name="notification_text">"%1$s" is due today</string>
</resources>
Loading