1
0
mirror of https://github.com/avinal/nikki.git synced 2026-07-04 05:50:10 +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
+15
View File
@@ -3,6 +3,9 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -23,6 +26,18 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<receiver
android:name="com.avinal.memos.notifications.TaskAlarmReceiver"
android:exported="false" />
<receiver
android:name="com.avinal.memos.notifications.BootReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application> </application>
</manifest> </manifest>
@@ -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 package com.avinal.memos.notifications
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent
import androidx.room.Room import androidx.room.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
@@ -8,26 +11,32 @@ import androidx.work.WorkerParameters
import com.avinal.memos.db.MemosDatabase import com.avinal.memos.db.MemosDatabase
import com.avinal.memos.db.entity.toDomain import com.avinal.memos.db.entity.toDomain
import com.avinal.memos.parser.TaskParser import com.avinal.memos.parser.TaskParser
import kotlin.time.Clock
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.atTime
import kotlinx.datetime.toInstant
import kotlinx.datetime.todayIn import kotlinx.datetime.todayIn
class TaskCheckWorker( class TaskCheckWorker(
private val context: Context, private val appContext: Context,
params: WorkerParameters, params: WorkerParameters,
) : CoroutineWorker(context, params) { ) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result { 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) val notificationsEnabled = prefs.getBoolean("enabled", true)
if (!notificationsEnabled) return Result.success() if (!notificationsEnabled) return Result.success()
val notifiedIds = prefs.getStringSet("notified_ids", emptySet()) ?: emptySet() val scheduledIds = prefs.getStringSet("scheduled_ids", emptySet()) ?: emptySet()
val today = kotlin.time.Clock.System.todayIn(TimeZone.currentSystemDefault()) val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
val nowMillis = Clock.System.now().toEpochMilliseconds()
val db = Room.databaseBuilder<MemosDatabase>( val db = Room.databaseBuilder<MemosDatabase>(
context = context, context = appContext,
name = context.getDatabasePath("memos.db").absolutePath, name = appContext.getDatabasePath("memos.db").absolutePath,
) )
.fallbackToDestructiveMigration(true) .fallbackToDestructiveMigration(true)
.setDriver(BundledSQLiteDriver()) .setDriver(BundledSQLiteDriver())
@@ -37,45 +46,75 @@ class TaskCheckWorker(
try { try {
val memos = db.memoDao().getAll().map { it.toDomain() } val memos = db.memoDao().getAll().map { it.toDomain() }
val allTasks = memos.flatMap { memo -> TaskParser.extractTasks(memo.id, memo.content) } 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 -> allTasks.forEach { task ->
!task.isCompleted && if (task.isCompleted || task.dueDate == null || task.id in scheduledIds) return@forEach
task.dueDate != null &&
task.dueDate <= today && if (task.dueTime != null) {
task.id !in notifiedIds // 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()
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 newNotifiedIds = notifiedIds.toMutableSet() // 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()
dueTasks.forEach { task -> prefs.edit().putStringSet("scheduled_ids", cleaned).apply()
val dueLabel = when {
task.dueDate!! < today -> "overdue"
else -> "due today"
}
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 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()
}
} finally { } finally {
db.close() db.close()
} }
return Result.success() 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 package com.avinal.memos.domain
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalTime
data class Task( data class Task(
val id: String, val id: String,
@@ -11,6 +12,7 @@ data class Task(
val originalLine: String = "", val originalLine: String = "",
val isCompleted: Boolean, val isCompleted: Boolean,
val dueDate: LocalDate? = null, val dueDate: LocalDate? = null,
val dueTime: LocalTime? = null,
val priority: Int? = null, val priority: Int? = null,
val labels: List<String> = emptyList(), val labels: List<String> = emptyList(),
val lists: List<String> = emptyList(), val lists: List<String> = emptyList(),
@@ -3,6 +3,7 @@ package com.avinal.memos.parser
import com.avinal.memos.domain.Task import com.avinal.memos.domain.Task
import kotlin.time.Clock import kotlin.time.Clock
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalTime
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus import kotlinx.datetime.plus
@@ -15,6 +16,9 @@ object TaskParser {
private val priorityRegex = Regex("""\bp([1-3])\b""") private val priorityRegex = Regex("""\bp([1-3])\b""")
private val labelRegex = Regex("""(?<!\w)@(\w+)""") private val labelRegex = Regex("""(?<!\w)@(\w+)""")
private val listRegex = 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") private val dateKeywords = setOf("today", "tomorrow", "yesterday")
@@ -38,6 +42,7 @@ object TaskParser {
originalLine = line, originalLine = line,
isCompleted = completed, isCompleted = completed,
dueDate = parseDueDate(rawText), dueDate = parseDueDate(rawText),
dueTime = parseDueTime(rawText),
priority = parsePriority(rawText), priority = parsePriority(rawText),
labels = parseLabels(rawText), labels = parseLabels(rawText),
lists = parseLists(rawText), lists = parseLists(rawText),
@@ -47,10 +52,8 @@ object TaskParser {
fun toggleTaskInContent(content: String, task: Task): String { fun toggleTaskInContent(content: String, task: Task): String {
val lines = content.lines().toMutableList() val lines = content.lines().toMutableList()
val targetIndex = findTaskLine(lines, task) val targetIndex = findTaskLine(lines, task)
if (targetIndex < 0) return content if (targetIndex < 0) return content
val line = lines[targetIndex] val line = lines[targetIndex]
lines[targetIndex] = if (task.isCompleted) { lines[targetIndex] = if (task.isCompleted) {
line.replaceFirst("- [x]", "- [ ]").replaceFirst("- [X]", "- [ ]") line.replaceFirst("- [x]", "- [ ]").replaceFirst("- [X]", "- [ ]")
@@ -79,12 +82,23 @@ object TaskParser {
val checkbox = if (task.isCompleted) "- [x]" else "- [ ]" val checkbox = if (task.isCompleted) "- [x]" else "- [ ]"
val parts = mutableListOf(task.text) val parts = mutableListOf(task.text)
task.dueDate?.let { parts.add(it.toString()) } task.dueDate?.let { parts.add(it.toString()) }
task.dueTime?.let { parts.add(formatTime(it)) }
task.priority?.let { parts.add("p$it") } task.priority?.let { parts.add("p$it") }
task.labels.forEach { parts.add("@$it") } task.labels.forEach { parts.add("@$it") }
task.lists.forEach { parts.add("#$it") } task.lists.forEach { parts.add("#$it") }
return "$checkbox ${parts.joinToString(" ")}" 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 { private fun hashTaskContent(cleanText: String, ordinal: Int): String {
val input = "$cleanText#$ordinal" val input = "$cleanText#$ordinal"
var hash = 0L var hash = 0L
@@ -113,6 +127,31 @@ object TaskParser {
return null 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? { private fun parsePriority(text: String): Int? {
val match = priorityRegex.find(text) ?: return null val match = priorityRegex.find(text) ?: return null
return match.groupValues[1].toIntOrNull() return match.groupValues[1].toIntOrNull()
@@ -134,6 +173,8 @@ object TaskParser {
var clean = text var clean = text
clean = priorityRegex.replace(clean, "") clean = priorityRegex.replace(clean, "")
clean = dateRegex.replace(clean, "") clean = dateRegex.replace(clean, "")
clean = time12Regex.replace(clean, "")
clean = time24Regex.replace(clean, "")
clean = labelRegex.replace(clean, "") clean = labelRegex.replace(clean, "")
clean = listRegex.replace(clean, "") clean = listRegex.replace(clean, "")
return clean.trim().replace(Regex("""\s{2,}"""), " ") return clean.trim().replace(Regex("""\s{2,}"""), " ")
@@ -230,4 +230,73 @@ class TaskParserTest {
assertEquals(2, tasks[0].lineIndex) assertEquals(2, tasks[0].lineIndex)
assertEquals(4, tasks[1].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"))
}
} }