1
0
mirror of https://github.com/avinal/nikki.git synced 2026-07-03 21:40:09 +05:30

Add time parsing, exact alarm notifications, boot reschedule

Time parsing:
- 12-hour format: 5pm, 2:30pm, 11am, 12am (midnight), 12pm (noon)
- 24-hour format: 14:30, 9:00, 17:00
- Time extracted alongside date: "@today 3pm p1 #work"
- Time cleaned from display text
- dueTime field added to Task domain model (LocalTime?)

Notification scheduling:
- Tasks with specific time: AlarmManager.setExactAndAllowWhileIdle at
  that exact time
- Tasks with date only: two alarms at 8am and 8pm on the due date
- SecurityException fallback to inexact alarm if permission denied
- TaskAlarmReceiver fires notification when alarm triggers
- BootReceiver re-schedules WorkManager on device reboot

Permissions added:
- SCHEDULE_EXACT_ALARM, USE_EXACT_ALARM for AlarmManager
- RECEIVE_BOOT_COMPLETED for reschedule after reboot

Test suite: 76 tests (38 TaskParser, 16 DtoMappers, 7 Serialization,
7 Backup, 5 Visibility, 3 ApiResult), all passing.

9 new time parsing tests: 12h am/pm, 12h with minutes, 24h, midnight,
noon, no time returns null, time+date combo, time cleaned from text.

Co-Authored-By: Claude Opus 4.6 (1M context)

Signed-off-by: Avinal Kumar <avinal.xlvii@gmail.com>
This commit is contained in:
2026-05-21 14:24:11 +05:30
parent e165be9f6c
commit 4416d7e98b
7 changed files with 236 additions and 37 deletions
@@ -0,0 +1,13 @@
package com.avinal.memos.notifications
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
scheduleTaskChecker(context)
}
}
}
@@ -0,0 +1,20 @@
package com.avinal.memos.notifications
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
class TaskAlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val taskText = intent.getStringExtra("task_text") ?: "Task reminder"
val dueLabel = intent.getStringExtra("due_label") ?: "due"
val notificationId = intent.getIntExtra("notification_id", 0)
TaskNotificationManager.showTaskNotification(
context = context,
notificationId = notificationId,
taskText = taskText,
dueLabel = dueLabel,
)
}
}
@@ -1,6 +1,9 @@
package com.avinal.memos.notifications
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.room.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.work.CoroutineWorker
@@ -8,26 +11,32 @@ import androidx.work.WorkerParameters
import com.avinal.memos.db.MemosDatabase
import com.avinal.memos.db.entity.toDomain
import com.avinal.memos.parser.TaskParser
import kotlin.time.Clock
import kotlinx.coroutines.Dispatchers
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atTime
import kotlinx.datetime.toInstant
import kotlinx.datetime.todayIn
class TaskCheckWorker(
private val context: Context,
private val appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(context, params) {
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val prefs = context.getSharedPreferences("task_notifications", Context.MODE_PRIVATE)
val prefs = appContext.getSharedPreferences("task_notifications", Context.MODE_PRIVATE)
val notificationsEnabled = prefs.getBoolean("enabled", true)
if (!notificationsEnabled) return Result.success()
val notifiedIds = prefs.getStringSet("notified_ids", emptySet()) ?: emptySet()
val today = kotlin.time.Clock.System.todayIn(TimeZone.currentSystemDefault())
val scheduledIds = prefs.getStringSet("scheduled_ids", emptySet()) ?: emptySet()
val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
val nowMillis = Clock.System.now().toEpochMilliseconds()
val db = Room.databaseBuilder<MemosDatabase>(
context = context,
name = context.getDatabasePath("memos.db").absolutePath,
context = appContext,
name = appContext.getDatabasePath("memos.db").absolutePath,
)
.fallbackToDestructiveMigration(true)
.setDriver(BundledSQLiteDriver())
@@ -37,45 +46,75 @@ class TaskCheckWorker(
try {
val memos = db.memoDao().getAll().map { it.toDomain() }
val allTasks = memos.flatMap { memo -> TaskParser.extractTasks(memo.id, memo.content) }
val alarmManager = appContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val tz = TimeZone.currentSystemDefault()
val newScheduledIds = scheduledIds.toMutableSet()
val dueTasks = allTasks.filter { task ->
!task.isCompleted &&
task.dueDate != null &&
task.dueDate <= today &&
task.id !in notifiedIds
}
allTasks.forEach { task ->
if (task.isCompleted || task.dueDate == null || task.id in scheduledIds) return@forEach
val newNotifiedIds = notifiedIds.toMutableSet()
if (task.dueTime != null) {
// Specific time: schedule one alarm at that exact time
val alarmInstant = task.dueDate.atTime(task.dueTime).toInstant(tz)
val alarmMs = alarmInstant.toEpochMilliseconds()
if (alarmMs > nowMillis) {
scheduleAlarm(alarmManager, task.id, task.text, "due at ${task.dueTime}", alarmMs)
newScheduledIds.add(task.id)
}
} else {
// No specific time: schedule at 8am and 8pm on the due date
val morning = task.dueDate.atTime(LocalTime(8, 0)).toInstant(tz).toEpochMilliseconds()
val evening = task.dueDate.atTime(LocalTime(20, 0)).toInstant(tz).toEpochMilliseconds()
dueTasks.forEach { task ->
val dueLabel = when {
task.dueDate!! < today -> "overdue"
else -> "due today"
if (morning > nowMillis) {
scheduleAlarm(alarmManager, "${task.id}_am", task.text, "due today", morning)
}
if (evening > nowMillis) {
scheduleAlarm(alarmManager, "${task.id}_pm", task.text, "reminder: still due today", evening)
}
newScheduledIds.add(task.id)
}
val priority = task.priority?.let { " p$it" } ?: ""
TaskNotificationManager.showTaskNotification(
context = context,
notificationId = task.id.hashCode(),
taskText = task.text,
dueLabel = "$dueLabel$priority",
)
newNotifiedIds.add(task.id)
}
if (newNotifiedIds.size > notifiedIds.size) {
prefs.edit().putStringSet("notified_ids", newNotifiedIds).apply()
}
// Clean up IDs for completed/removed tasks
val activeTaskIds = allTasks.filter { !it.isCompleted }.map { it.id }.toSet()
val cleaned = newScheduledIds.filter { id ->
val baseId = id.removeSuffix("_am").removeSuffix("_pm")
baseId in activeTaskIds
}.toSet()
// Clean up old IDs (tasks that no longer exist)
val allTaskIds = allTasks.map { it.id }.toSet()
val cleaned = newNotifiedIds.filter { it in allTaskIds }.toSet()
if (cleaned.size < newNotifiedIds.size) {
prefs.edit().putStringSet("notified_ids", cleaned).apply()
}
prefs.edit().putStringSet("scheduled_ids", cleaned).apply()
} finally {
db.close()
}
return Result.success()
}
private fun scheduleAlarm(
alarmManager: AlarmManager,
alarmId: String,
taskText: String,
dueLabel: String,
triggerAtMillis: Long,
) {
val intent = Intent(appContext, TaskAlarmReceiver::class.java).apply {
putExtra("task_text", taskText)
putExtra("due_label", dueLabel)
putExtra("notification_id", alarmId.hashCode())
}
val pendingIntent = PendingIntent.getBroadcast(
appContext,
alarmId.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
try {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent)
} catch (_: SecurityException) {
// Fallback if exact alarm permission not granted
alarmManager.set(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent)
}
}
}
@@ -1,6 +1,7 @@
package com.avinal.memos.domain
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalTime
data class Task(
val id: String,
@@ -11,6 +12,7 @@ data class Task(
val originalLine: String = "",
val isCompleted: Boolean,
val dueDate: LocalDate? = null,
val dueTime: LocalTime? = null,
val priority: Int? = null,
val labels: List<String> = emptyList(),
val lists: List<String> = emptyList(),
@@ -3,6 +3,7 @@ package com.avinal.memos.parser
import com.avinal.memos.domain.Task
import kotlin.time.Clock
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalTime
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
@@ -15,6 +16,9 @@ object TaskParser {
private val priorityRegex = Regex("""\bp([1-3])\b""")
private val labelRegex = Regex("""(?<!\w)@(\w+)""")
private val listRegex = Regex("""(?<!\w)#(\w+)""")
// Time patterns: @14:30, @2:30pm, @5pm, @17:00
private val time24Regex = Regex("""(?<!\d)(\d{1,2}):(\d{2})(?!\d)""")
private val time12Regex = Regex("""(?<!\w)(\d{1,2})(?::(\d{2}))?\s*(am|pm)""", RegexOption.IGNORE_CASE)
private val dateKeywords = setOf("today", "tomorrow", "yesterday")
@@ -38,6 +42,7 @@ object TaskParser {
originalLine = line,
isCompleted = completed,
dueDate = parseDueDate(rawText),
dueTime = parseDueTime(rawText),
priority = parsePriority(rawText),
labels = parseLabels(rawText),
lists = parseLists(rawText),
@@ -47,10 +52,8 @@ object TaskParser {
fun toggleTaskInContent(content: String, task: Task): String {
val lines = content.lines().toMutableList()
val targetIndex = findTaskLine(lines, task)
if (targetIndex < 0) return content
val line = lines[targetIndex]
lines[targetIndex] = if (task.isCompleted) {
line.replaceFirst("- [x]", "- [ ]").replaceFirst("- [X]", "- [ ]")
@@ -79,12 +82,23 @@ object TaskParser {
val checkbox = if (task.isCompleted) "- [x]" else "- [ ]"
val parts = mutableListOf(task.text)
task.dueDate?.let { parts.add(it.toString()) }
task.dueTime?.let { parts.add(formatTime(it)) }
task.priority?.let { parts.add("p$it") }
task.labels.forEach { parts.add("@$it") }
task.lists.forEach { parts.add("#$it") }
return "$checkbox ${parts.joinToString(" ")}"
}
private fun formatTime(time: LocalTime): String {
val hour = time.hour
val minute = time.minute
return when {
minute == 0 && hour <= 12 -> "${if (hour == 0) 12 else hour}${if (hour < 12) "am" else "pm"}"
minute == 0 && hour > 12 -> "${hour - 12}pm"
else -> "${hour}:${minute.toString().padStart(2, '0')}"
}
}
private fun hashTaskContent(cleanText: String, ordinal: Int): String {
val input = "$cleanText#$ordinal"
var hash = 0L
@@ -113,6 +127,31 @@ object TaskParser {
return null
}
private fun parseDueTime(text: String): LocalTime? {
// Check 12-hour format first: 5pm, 2:30pm, 11am
val match12 = time12Regex.find(text)
if (match12 != null) {
var hour = match12.groupValues[1].toIntOrNull() ?: return null
val minute = match12.groupValues[2].toIntOrNull() ?: 0
val ampm = match12.groupValues[3].lowercase()
if (hour !in 1..12 || minute !in 0..59) return null
if (ampm == "pm" && hour != 12) hour += 12
if (ampm == "am" && hour == 12) hour = 0
return LocalTime(hour, minute)
}
// Check 24-hour format: 14:30, 9:00
val match24 = time24Regex.find(text)
if (match24 != null) {
val hour = match24.groupValues[1].toIntOrNull() ?: return null
val minute = match24.groupValues[2].toIntOrNull() ?: return null
if (hour !in 0..23 || minute !in 0..59) return null
return LocalTime(hour, minute)
}
return null
}
private fun parsePriority(text: String): Int? {
val match = priorityRegex.find(text) ?: return null
return match.groupValues[1].toIntOrNull()
@@ -134,6 +173,8 @@ object TaskParser {
var clean = text
clean = priorityRegex.replace(clean, "")
clean = dateRegex.replace(clean, "")
clean = time12Regex.replace(clean, "")
clean = time24Regex.replace(clean, "")
clean = labelRegex.replace(clean, "")
clean = listRegex.replace(clean, "")
return clean.trim().replace(Regex("""\s{2,}"""), " ")
@@ -230,4 +230,73 @@ class TaskParserTest {
assertEquals(2, tasks[0].lineIndex)
assertEquals(4, tasks[1].lineIndex)
}
// --- Time parsing tests ---
@Test
fun parses12HourPm() {
val tasks = TaskParser.extractTasks("m1", "- [ ] Meeting 5pm")
assertNotNull(tasks[0].dueTime)
assertEquals(17, tasks[0].dueTime!!.hour)
assertEquals(0, tasks[0].dueTime!!.minute)
}
@Test
fun parses12HourAm() {
val tasks = TaskParser.extractTasks("m1", "- [ ] Standup 9am")
assertNotNull(tasks[0].dueTime)
assertEquals(9, tasks[0].dueTime!!.hour)
}
@Test
fun parses12HourWithMinutes() {
val tasks = TaskParser.extractTasks("m1", "- [ ] Call 2:30pm")
assertNotNull(tasks[0].dueTime)
assertEquals(14, tasks[0].dueTime!!.hour)
assertEquals(30, tasks[0].dueTime!!.minute)
}
@Test
fun parses24Hour() {
val tasks = TaskParser.extractTasks("m1", "- [ ] Deploy 14:30")
assertNotNull(tasks[0].dueTime)
assertEquals(14, tasks[0].dueTime!!.hour)
assertEquals(30, tasks[0].dueTime!!.minute)
}
@Test
fun parsesMidnight12am() {
val tasks = TaskParser.extractTasks("m1", "- [ ] Reset 12am")
assertNotNull(tasks[0].dueTime)
assertEquals(0, tasks[0].dueTime!!.hour)
}
@Test
fun parsesNoon12pm() {
val tasks = TaskParser.extractTasks("m1", "- [ ] Lunch 12pm")
assertNotNull(tasks[0].dueTime)
assertEquals(12, tasks[0].dueTime!!.hour)
}
@Test
fun noTimeReturnsNull() {
val tasks = TaskParser.extractTasks("m1", "- [ ] Simple task")
assertNull(tasks[0].dueTime)
}
@Test
fun timeAndDateTogether() {
val tasks = TaskParser.extractTasks("m1", "- [ ] Review @today 3pm p1 #work")
assertNotNull(tasks[0].dueDate)
assertNotNull(tasks[0].dueTime)
assertEquals(15, tasks[0].dueTime!!.hour)
assertEquals(1, tasks[0].priority)
}
@Test
fun timeCleanedFromText() {
val tasks = TaskParser.extractTasks("m1", "- [ ] Meeting 5pm @today")
assertFalse(tasks[0].text.contains("5pm"))
assertFalse(tasks[0].text.contains("@today"))
}
}