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

Auto sync setting, pretty notifications, background permissions,

reliable alarm scheduling

Auto sync:
- Configurable interval: 1/2/5/10/15/30/60 min (default 5)
- MemoRepository.syncIntervalMinutes updated live from DataStore
- Setting shown under "memos" section in settings

Notifications by priority:
- p1: 🔴 alarm sound, long vibration, wakes screen, bypasses DND
- p2: 🟠 notification sound, medium vibration, heads-up
- p3: 🔵 notification sound, short vibration
- none: silent, no vibration
- BigTextStyle with priority tag, colored accent bar
- 4 separate Android notification channels

Background reliability:
- FOREGROUND_SERVICE, WAKE_LOCK, REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
- Battery optimization exemption requested on first launch
- DirectAlarmScheduler: schedules alarms directly from app process
  using live Room data (bypasses WorkManager DB sync issue)
- onContentChanged fires direct scheduling on every memo create/update
- TaskReminderReceiver moved to androidApp module for reliable
  cold-start instantiation
- WorkManager kept as 15-min backup for server-side changes

123 tests passing.

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 21:14:27 +05:30
parent 8c3ab59f2f
commit 0512c9a698
17 changed files with 400 additions and 182 deletions
+4 -1
View File
@@ -6,6 +6,9 @@
<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" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:allowBackup="true"
@@ -28,7 +31,7 @@
</activity>
<receiver
android:name="com.avinal.memos.notifications.TaskAlarmReceiver"
android:name=".TaskReminderReceiver"
android:exported="false" />
<receiver
@@ -28,8 +28,9 @@ class MainActivity : ComponentActivity() {
deps.initialize()
com.avinal.memos.util.appContext = applicationContext
TaskNotificationManager.createChannel(this)
TaskNotificationManager.createChannels(this)
requestNotificationPermission()
requestBatteryOptimizationExemption()
scheduleTaskChecker(applicationContext)
enableEdgeToEdge()
@@ -49,4 +50,16 @@ class MainActivity : ComponentActivity() {
}
}
}
private fun requestBatteryOptimizationExemption() {
val pm = getSystemService(android.os.PowerManager::class.java)
if (!pm.isIgnoringBatteryOptimizations(packageName)) {
try {
startActivity(android.content.Intent(
android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
android.net.Uri.parse("package:$packageName")
))
} catch (_: Exception) {}
}
}
}
@@ -0,0 +1,26 @@
package com.avinal.memos
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.avinal.memos.notifications.TaskNotificationManager
class TaskReminderReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
android.util.Log.d("TaskReminderReceiver", "onReceive fired!")
val taskText = intent.getStringExtra("task_text") ?: "Task reminder"
val dueLabel = intent.getStringExtra("due_label") ?: "due"
val notificationId = intent.getIntExtra("notification_id", 0)
val priority = intent.getIntExtra("priority", 0)
android.util.Log.d("TaskReminderReceiver", "Showing: $taskText - $dueLabel (p=$priority, id=$notificationId)")
TaskNotificationManager.createChannels(context)
TaskNotificationManager.showTaskNotification(
context = context,
notificationId = notificationId,
taskText = taskText,
dueLabel = dueLabel,
priority = priority,
)
}
}
@@ -0,0 +1,54 @@
package com.avinal.memos.notifications
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import com.avinal.memos.domain.Memo
import com.avinal.memos.parser.TaskParser
import kotlin.time.Clock
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
object DirectAlarmScheduler {
fun scheduleFromMemos(context: Context, memos: List<Memo>) {
val allTasks = memos.flatMap { memo -> TaskParser.extractTasks(memo.id, memo.content) }
val nowMillis = Clock.System.now().toEpochMilliseconds()
val tz = TimeZone.currentSystemDefault()
val prefs = context.getSharedPreferences("memos_prefs", Context.MODE_PRIVATE)
val defaultTimeStr = prefs.getString("default_notify_time", "20:00") ?: "20:00"
val parts = defaultTimeStr.split(":")
val defaultTime = try { LocalTime(parts[0].toInt(), parts.getOrElse(1) { "0" }.toInt()) } catch (_: Exception) { LocalTime(20, 0) }
val alarms = ReminderScheduler.computeAlarms(allTasks, nowMillis, tz, emptySet(), defaultTime)
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarms.forEach { alarm ->
val receiverClass = try {
Class.forName("com.avinal.memos.TaskReminderReceiver")
} catch (_: Exception) {
TaskAlarmReceiver::class.java
}
val uniqueId = (alarm.taskId + alarm.triggerAtMillis.toString()).hashCode()
val intent = Intent(context, receiverClass).apply {
putExtra("task_text", alarm.taskText)
putExtra("due_label", alarm.label)
putExtra("notification_id", uniqueId)
putExtra("priority", alarm.priority)
}
val pendingIntent = PendingIntent.getBroadcast(
context, uniqueId, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
try {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, alarm.triggerAtMillis, pendingIntent)
} catch (_: SecurityException) {
alarmManager.set(AlarmManager.RTC_WAKEUP, alarm.triggerAtMillis, pendingIntent)
}
}
android.util.Log.d("DirectAlarmScheduler", "Scheduled ${alarms.size} alarms from ${memos.size} memos")
}
}
@@ -9,12 +9,15 @@ class TaskAlarmReceiver : BroadcastReceiver() {
val taskText = intent.getStringExtra("task_text") ?: "Task reminder"
val dueLabel = intent.getStringExtra("due_label") ?: "due"
val notificationId = intent.getIntExtra("notification_id", 0)
val priority = intent.getIntExtra("priority", 0)
TaskNotificationManager.createChannels(context)
TaskNotificationManager.showTaskNotification(
context = context,
notificationId = notificationId,
taskText = taskText,
dueLabel = dueLabel,
priority = priority,
)
}
}
@@ -25,6 +25,16 @@ class TaskCheckWorker(
val scheduledIds = prefs.getStringSet("scheduled_ids", emptySet()) ?: emptySet()
val nowMillis = Clock.System.now().toEpochMilliseconds()
// Read default notify time from DataStore file (shared prefs fallback)
val notifyPrefs = appContext.getSharedPreferences("memos_prefs", Context.MODE_PRIVATE)
val defaultTimeStr = notifyPrefs.getString("default_notify_time", "20:00") ?: "20:00"
val defaultTimeParts = defaultTimeStr.split(":")
val defaultTime = try {
kotlinx.datetime.LocalTime(defaultTimeParts[0].toInt(), defaultTimeParts.getOrElse(1) { "0" }.toInt())
} catch (_: Exception) {
kotlinx.datetime.LocalTime(20, 0)
}
val db = Room.databaseBuilder<MemosDatabase>(
context = appContext,
name = appContext.getDatabasePath("memos.db").absolutePath,
@@ -45,12 +55,13 @@ class TaskCheckWorker(
android.util.Log.d("TaskCheckWorker", "Task: ${t.text}, date=${t.dueDate}, time=${t.dueTime}, reminder=${t.reminder}, completed=${t.isCompleted}, id=${t.id}")
}
val alarms = ReminderScheduler.computeAlarms(allTasks, nowMillis, tz, scheduledIds)
android.util.Log.d("TaskCheckWorker", "nowMillis=$nowMillis, tz=$tz, defaultTime=$defaultTime")
val alarms = ReminderScheduler.computeAlarms(allTasks, nowMillis, tz, scheduledIds, defaultTime)
android.util.Log.d("TaskCheckWorker", "Computed ${alarms.size} alarms")
alarms.forEach { alarm ->
android.util.Log.d("TaskCheckWorker", "Scheduling: ${alarm.taskText} at ${alarm.triggerAtMillis} (${alarm.label})")
scheduleAlarm(alarmManager, alarm.taskId, alarm.taskText, alarm.label, alarm.triggerAtMillis)
android.util.Log.d("TaskCheckWorker", "Scheduling: ${alarm.taskText} at ${alarm.triggerAtMillis} (${alarm.label}) p=${alarm.priority}")
scheduleAlarm(alarmManager, alarm.taskId, alarm.taskText, alarm.label, alarm.triggerAtMillis, alarm.priority)
}
val newScheduledIds = scheduledIds.toMutableSet()
@@ -76,15 +87,23 @@ class TaskCheckWorker(
taskText: String,
dueLabel: String,
triggerAtMillis: Long,
priority: Int = 0,
) {
val intent = Intent(appContext, TaskAlarmReceiver::class.java).apply {
val uniqueId = (alarmId + triggerAtMillis.toString()).hashCode()
val receiverClass = try {
Class.forName("com.avinal.memos.TaskReminderReceiver")
} catch (_: Exception) {
TaskAlarmReceiver::class.java
}
val intent = Intent(appContext, receiverClass).apply {
putExtra("task_text", taskText)
putExtra("due_label", dueLabel)
putExtra("notification_id", alarmId.hashCode())
putExtra("notification_id", uniqueId)
putExtra("priority", priority)
}
val pendingIntent = PendingIntent.getBroadcast(
appContext,
alarmId.hashCode(),
uniqueId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
@@ -5,24 +5,47 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.media.AudioAttributes
import android.media.RingtoneManager
import androidx.core.app.NotificationCompat
object TaskNotificationManager {
private const val CHANNEL_ID = "task_reminders"
private const val CHANNEL_NAME = "Task Reminders"
private const val CHANNEL_P1 = "task_p1"
private const val CHANNEL_P2 = "task_p2"
private const val CHANNEL_P3 = "task_p3"
private const val CHANNEL_DEFAULT = "task_default"
fun createChannel(context: Context) {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Reminders for tasks with due dates"
}
fun createChannels(context: Context) {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(channel)
val soundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
val alarmUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)
val audioAttr = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
manager.createNotificationChannel(NotificationChannel(CHANNEL_P1, "P1 — Urgent", NotificationManager.IMPORTANCE_HIGH).apply {
description = "High priority — alarm sound, strong vibration, wakes screen"
enableVibration(true); vibrationPattern = longArrayOf(0, 500, 200, 500, 200, 500)
setSound(alarmUri, audioAttr); lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC; setShowBadge(true); setBypassDnd(true)
})
manager.createNotificationChannel(NotificationChannel(CHANNEL_P2, "P2 — Medium", NotificationManager.IMPORTANCE_HIGH).apply {
description = "Medium priority — notification sound, vibration"
enableVibration(true); vibrationPattern = longArrayOf(0, 300, 200, 300)
setSound(soundUri, audioAttr); lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC; setShowBadge(true)
})
manager.createNotificationChannel(NotificationChannel(CHANNEL_P3, "P3 — Low", NotificationManager.IMPORTANCE_DEFAULT).apply {
description = "Low priority — notification sound, short vibration"
enableVibration(true); vibrationPattern = longArrayOf(0, 200)
setSound(soundUri, audioAttr); lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
})
manager.createNotificationChannel(NotificationChannel(CHANNEL_DEFAULT, "No Priority", NotificationManager.IMPORTANCE_LOW).apply {
description = "No priority — silent notification"
enableVibration(false); setSound(null, null)
})
manager.deleteNotificationChannel("task_reminders")
}
fun showTaskNotification(
@@ -30,6 +53,7 @@ object TaskNotificationManager {
notificationId: Int,
taskText: String,
dueLabel: String,
priority: Int = 0,
) {
val openIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
@@ -39,16 +63,57 @@ object TaskNotificationManager {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
val channelId = when (priority) { 1 -> CHANNEL_P1; 2 -> CHANNEL_P2; 3 -> CHANNEL_P3; else -> CHANNEL_DEFAULT }
val notifPriority = when (priority) { 1 -> NotificationCompat.PRIORITY_MAX; 2 -> NotificationCompat.PRIORITY_HIGH; 3 -> NotificationCompat.PRIORITY_DEFAULT; else -> NotificationCompat.PRIORITY_LOW }
val priorityEmoji = when (priority) { 1 -> "🔴"; 2 -> "🟠"; 3 -> "🔵"; else -> "" }
val priorityTag = when (priority) { 1 -> "URGENT"; 2 -> "MEDIUM"; 3 -> "LOW"; else -> "" }
val title = buildString {
if (priorityEmoji.isNotEmpty()) append("$priorityEmoji ")
append(taskText)
}
val bigText = buildString {
append(dueLabel)
if (priorityTag.isNotEmpty()) append("\nPriority: $priorityTag")
}
val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(android.R.drawable.ic_popup_reminder)
.setContentTitle(taskText)
.setContentTitle(title)
.setContentText(dueLabel)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setStyle(NotificationCompat.BigTextStyle().bigText(bigText))
.setSubText(when (priority) { 1 -> "p1 urgent"; 2 -> "p2 medium"; 3 -> "p3 low"; else -> "memos" })
.setWhen(System.currentTimeMillis())
.setShowWhen(true)
.setPriority(notifPriority)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setAutoCancel(true)
.setOnlyAlertOnce(false)
.setContentIntent(pendingOpen)
.build()
.setDefaults(NotificationCompat.DEFAULT_LIGHTS)
.setGroup("task_reminders_${notificationId}")
.setColor(when (priority) { 1 -> 0xFFE51400.toInt(); 2 -> 0xFFF0A30A.toInt(); 3 -> 0xFF1BA1E2.toInt(); else -> 0xFF666666.toInt() })
when (priority) {
1 -> {
builder.setFullScreenIntent(pendingOpen, true)
builder.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM))
builder.setVibrate(longArrayOf(0, 500, 200, 500, 200, 500))
}
2 -> {
builder.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))
builder.setVibrate(longArrayOf(0, 300, 200, 300))
}
3 -> {
builder.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))
builder.setVibrate(longArrayOf(0, 200))
}
}
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(notificationId, notification)
manager.notify(notificationId, builder.build())
}
}
@@ -1,8 +1,23 @@
package com.avinal.memos.util
import com.avinal.memos.notifications.DirectAlarmScheduler
import com.avinal.memos.notifications.runTaskCheckNow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
private var liveMemosProvider: (() -> List<com.avinal.memos.domain.Memo>)? = null
actual fun setLiveMemosProvider(provider: () -> List<com.avinal.memos.domain.Memo>) {
liveMemosProvider = provider
}
actual fun triggerReminderCheck() {
val ctx = appContext ?: return
runTaskCheckNow(ctx)
val memos = liveMemosProvider?.invoke()
if (memos != null && memos.isNotEmpty()) {
DirectAlarmScheduler.scheduleFromMemos(ctx, memos)
} else {
runTaskCheckNow(ctx)
}
}
@@ -12,6 +12,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import com.avinal.memos.db.entity.toDomain
class AppDependencies(
dataStorePath: String,
@@ -37,7 +39,11 @@ class AppDependencies(
}
val authRepository: AuthRepository by lazy { AuthRepository(apiClient, tokenStore) }
val memoRepository: MemoRepository by lazy { MemoRepository(apiClient, database.memoDao()) }
val memoRepository: MemoRepository by lazy {
MemoRepository(apiClient, database.memoDao()) {
com.avinal.memos.util.triggerReminderCheck()
}
}
private var initJob: kotlinx.coroutines.Job? = null
@@ -46,6 +52,17 @@ class AppDependencies(
initJob = CoroutineScope(Dispatchers.IO).launch {
launch { tokenStore.accessToken.collect { cachedToken = it } }
launch { tokenStore.serverUrl.collect { cachedServerUrl = it } }
launch { tokenStore.syncInterval.collect { memoRepository.syncIntervalMinutes = it } }
launch { initializeLiveMemosProvider() }
}
}
private suspend fun initializeLiveMemosProvider() {
try {
val dao = database.memoDao()
com.avinal.memos.util.setLiveMemosProvider {
runBlocking { dao.getAll().map { it.toDomain() } }
}
} catch (_: Exception) {}
}
}
@@ -17,14 +17,16 @@ import kotlinx.coroutines.launch
class MemoRepository(
private val apiClient: MemosApiClient,
private val memoDao: MemoDao,
private val onContentChanged: (() -> Unit)? = null,
) {
private var nextPageToken: String = ""
private var hasMorePages: Boolean = true
private var lastFetchTime: Long = 0L
var syncIntervalMinutes: Int = 5
private fun nowMillis(): Long = Clock.System.now().toEpochMilliseconds()
private fun isCacheStale(): Boolean = (nowMillis() - lastFetchTime) > CACHE_TTL_MS
private fun isCacheStale(): Boolean = (nowMillis() - lastFetchTime) > (syncIntervalMinutes * 60 * 1000L)
fun observeMemos(): Flow<List<Memo>> {
if (isCacheStale()) {
@@ -101,6 +103,7 @@ class MemoRepository(
is ApiResult.Success -> {
val memo = result.data.toDomain()
memoDao.upsert(memo.toEntity(nowMillis()))
onContentChanged?.invoke()
ApiResult.Success(memo)
}
is ApiResult.Error -> result
@@ -123,6 +126,7 @@ class MemoRepository(
is ApiResult.Success -> {
val memo = result.data.toDomain()
memoDao.upsert(memo.toEntity(nowMillis()))
onContentChanged?.invoke()
ApiResult.Success(memo)
}
is ApiResult.Error -> result
@@ -196,8 +200,4 @@ class MemoRepository(
nextPageToken = ""
hasMorePages = true
}
companion object {
private const val CACHE_TTL_MS = 5 * 60 * 1000L
}
}
@@ -1,5 +1,6 @@
package com.avinal.memos.notifications
import com.avinal.memos.domain.ReminderDuration
import com.avinal.memos.domain.ReminderUnit
import com.avinal.memos.domain.Task
import kotlinx.datetime.LocalTime
@@ -13,58 +14,80 @@ data class ScheduledAlarm(
val taskText: String,
val label: String,
val triggerAtMillis: Long,
val priority: Int = 0,
)
object ReminderScheduler {
/**
* Compute alarms for tasks. Logic:
* - time only, no date → date = today
* - date only, no time → time = [defaultTime] (default 20:00)
* - both set → use as-is
* - neither → skip (no alarm possible)
*
* Reminder:
* - explicit !duration → alarm at dueDateTime - duration
* - no explicit reminder → alarm at dueDateTime itself
*/
fun computeAlarms(
tasks: List<Task>,
nowMillis: Long,
timeZone: TimeZone,
alreadyScheduledIds: Set<String>,
alreadyScheduledIds: Set<String> = emptySet(),
defaultTime: LocalTime = LocalTime(20, 0),
): List<ScheduledAlarm> {
val alarms = mutableListOf<ScheduledAlarm>()
val today = kotlin.time.Clock.System.todayIn(timeZone)
tasks.forEach { task ->
if (task.isCompleted) return@forEach
val effectiveDate = task.dueDate ?: if (task.dueTime != null) today else return@forEach
val effectiveTime = task.dueTime ?: defaultTime
val dueMs = effectiveDate.atTime(effectiveTime).toInstant(timeZone).toEpochMilliseconds()
println("ReminderScheduler: task=${task.text} effectiveDate=$effectiveDate effectiveTime=$effectiveTime dueMs=$dueMs nowMillis=$nowMillis future=${dueMs > nowMillis}")
// Explicit reminder: fire at dueDateTime - duration
if (task.reminder != null) {
val dueInstant = effectiveDate.atTime(task.dueTime ?: LocalTime(8, 0)).toInstant(timeZone)
val offsetMs = when (task.reminder.unit) {
ReminderUnit.MIN -> task.reminder.value * 60_000L
ReminderUnit.HR -> task.reminder.value * 3_600_000L
ReminderUnit.DAY -> task.reminder.value * 86_400_000L
ReminderUnit.WEEK -> task.reminder.value * 604_800_000L
}
val reminderMs = dueInstant.toEpochMilliseconds() - offsetMs
val offsetMs = durationToMillis(task.reminder)
val reminderMs = dueMs - offsetMs
if (reminderMs > nowMillis) {
alarms.add(ScheduledAlarm("${task.id}_remind", task.text, "reminder: ${task.reminder}", reminderMs))
val label = buildReminderLabel(task, effectiveDate, effectiveTime)
alarms.add(ScheduledAlarm("${task.id}_remind", task.text, label, reminderMs, task.priority ?: 0))
}
}
// Due time alarm
if (task.dueTime != null) {
val alarmMs = effectiveDate.atTime(task.dueTime).toInstant(timeZone).toEpochMilliseconds()
if (alarmMs > nowMillis) {
alarms.add(ScheduledAlarm(task.id, task.text, "due at ${task.dueTime}", alarmMs))
}
} else {
// Default: 8am and 8pm on due date
val morning = effectiveDate.atTime(LocalTime(8, 0)).toInstant(timeZone).toEpochMilliseconds()
val evening = effectiveDate.atTime(LocalTime(20, 0)).toInstant(timeZone).toEpochMilliseconds()
if (morning > nowMillis) {
alarms.add(ScheduledAlarm("${task.id}_am", task.text, "due today", morning))
}
if (evening > nowMillis) {
alarms.add(ScheduledAlarm("${task.id}_pm", task.text, "reminder: still due today", evening))
}
if (dueMs > nowMillis) {
val label = buildDueLabel(task, effectiveDate, effectiveTime)
alarms.add(ScheduledAlarm(task.id, task.text, label, dueMs, task.priority ?: 0))
}
}
return alarms
}
private fun durationToMillis(d: ReminderDuration): Long = when (d.unit) {
ReminderUnit.MIN -> d.value * 60_000L
ReminderUnit.HR -> d.value * 3_600_000L
ReminderUnit.DAY -> d.value * 86_400_000L
ReminderUnit.WEEK -> d.value * 604_800_000L
}
private fun buildReminderLabel(task: Task, date: kotlinx.datetime.LocalDate, time: LocalTime): String {
val timeStr = "${time.hour.toString().padStart(2, '0')}:${time.minute.toString().padStart(2, '0')}"
val priority = task.priority?.let { " · p$it" } ?: ""
val tags = task.lists.joinToString("") { " #$it" }
return "Due $date $timeStr$priority$tags"
}
private fun buildDueLabel(task: Task, date: kotlinx.datetime.LocalDate, time: LocalTime): String {
val timeStr = "${time.hour.toString().padStart(2, '0')}:${time.minute.toString().padStart(2, '0')}"
val priority = task.priority?.let { " · p$it" } ?: ""
val tags = task.lists.joinToString("") { " #$it" }
return "Now · $date $timeStr$priority$tags"
}
}
@@ -147,6 +147,14 @@ fun SettingsScreen(
viewModel.setWeekStartDay((weekStart + 1) % 7)
}
val syncInterval by viewModel.syncInterval.collectAsState()
SettingToggle("auto sync", "${syncInterval} min", accent, MaterialTheme.colorScheme.onSurfaceVariant) {
val options = listOf(1, 2, 5, 10, 15, 30, 60)
val idx = options.indexOf(syncInterval)
viewModel.setSyncInterval(options[(idx + 1) % options.size])
}
Text("how often to fetch from server", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(24.dp))
SectionHeader("notifications")
Spacer(Modifier.height(6.dp))
@@ -165,6 +173,15 @@ fun SettingsScreen(
)
}
Text("get notified when tasks are due or overdue", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
val defaultNotifyTime by viewModel.defaultNotifyTime.collectAsState()
SettingToggle("default notify time", defaultNotifyTime, accent, MaterialTheme.colorScheme.onSurfaceVariant) {
val options = listOf("08:00", "09:00", "12:00", "17:00", "18:00", "20:00", "21:00")
val idx = options.indexOf(defaultNotifyTime)
viewModel.setDefaultNotifyTime(options[(idx + 1) % options.size])
}
Text("when a task has a date but no time", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
"check reminders now",
fontSize = 13.sp, color = accent,
@@ -45,6 +45,12 @@ class SettingsViewModel(
val weekStartDay: StateFlow<Int> = tokenStore.weekStartDay
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
val defaultNotifyTime: StateFlow<String> = tokenStore.defaultNotifyTime
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "20:00")
val syncInterval: StateFlow<Int> = tokenStore.syncInterval
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 5)
init {
viewModelScope.launch { authRepository.validateToken() }
}
@@ -73,6 +79,14 @@ class SettingsViewModel(
viewModelScope.launch { tokenStore.saveWeekStartDay(day) }
}
fun setDefaultNotifyTime(time: String) {
viewModelScope.launch { tokenStore.saveDefaultNotifyTime(time) }
}
fun setSyncInterval(minutes: Int) {
viewModelScope.launch { tokenStore.saveSyncInterval(minutes) }
}
fun getExportJson(onResult: (String) -> Unit) {
viewModelScope.launch {
val memos = memoRepository.observeMemos().first()
@@ -1,3 +1,4 @@
package com.avinal.memos.util
expect fun triggerReminderCheck()
expect fun setLiveMemosProvider(provider: () -> List<com.avinal.memos.domain.Memo>)
@@ -17,6 +17,8 @@ class TokenStore(private val dataStore: DataStore<Preferences>) {
val defaultVisibility: Flow<String> = dataStore.data.map { it[KEY_DEFAULT_VIS] ?: "PRIVATE" }
val defaultReminder: Flow<String> = dataStore.data.map { it[KEY_DEFAULT_REMINDER] ?: "" }
val weekStartDay: Flow<Int> = dataStore.data.map { (it[KEY_WEEK_START] ?: "0").toIntOrNull() ?: 0 }
val defaultNotifyTime: Flow<String> = dataStore.data.map { it[KEY_DEFAULT_NOTIFY_TIME] ?: "20:00" }
val syncInterval: Flow<Int> = dataStore.data.map { (it[KEY_SYNC_INTERVAL] ?: "5").toIntOrNull() ?: 5 }
suspend fun saveCredentials(serverUrl: String, token: String) {
dataStore.edit { prefs ->
@@ -49,6 +51,14 @@ class TokenStore(private val dataStore: DataStore<Preferences>) {
dataStore.edit { it[KEY_WEEK_START] = day.toString() }
}
suspend fun saveDefaultNotifyTime(time: String) {
dataStore.edit { it[KEY_DEFAULT_NOTIFY_TIME] = time }
}
suspend fun saveSyncInterval(minutes: Int) {
dataStore.edit { it[KEY_SYNC_INTERVAL] = minutes.toString() }
}
suspend fun clear() {
dataStore.edit {
val theme = it[KEY_THEME]
@@ -68,5 +78,7 @@ class TokenStore(private val dataStore: DataStore<Preferences>) {
private val KEY_DEFAULT_VIS = stringPreferencesKey("default_visibility")
private val KEY_DEFAULT_REMINDER = stringPreferencesKey("default_reminder")
private val KEY_WEEK_START = stringPreferencesKey("week_start_day")
private val KEY_DEFAULT_NOTIFY_TIME = stringPreferencesKey("default_notify_time")
private val KEY_SYNC_INTERVAL = stringPreferencesKey("sync_interval")
}
}
@@ -17,183 +17,120 @@ class ReminderSchedulerTest {
private val tz = TimeZone.UTC
private val dueDate = LocalDate(2026, 6, 15)
// nowMillis = 2026-06-15 00:00 UTC
private val nowMillis = dueDate.atTime(LocalTime(0, 0)).toInstant(tz).toEpochMilliseconds()
private val defaultTime = LocalTime(20, 0)
private fun task(
id: String = "t1",
completed: Boolean = false,
date: LocalDate? = dueDate,
time: LocalTime? = null,
reminder: ReminderDuration? = null,
priority: Int? = null,
) = Task(
id = id, memoId = "m1", lineIndex = 0, text = "Test",
id: String = "t1", completed: Boolean = false,
date: LocalDate? = dueDate, time: LocalTime? = null,
reminder: ReminderDuration? = null, priority: Int? = null,
) = Task(id = id, memoId = "m1", lineIndex = 0, text = "Test",
isCompleted = completed, dueDate = date, dueTime = time,
reminder = reminder, priority = priority,
)
reminder = reminder, priority = priority)
@Test
fun completedTaskProducesNoAlarms() {
val alarms = ReminderScheduler.computeAlarms(listOf(task(completed = true)), nowMillis, tz, emptySet())
assertTrue(alarms.isEmpty())
}
private fun compute(tasks: List<Task>, now: Long = nowMillis) =
ReminderScheduler.computeAlarms(tasks, now, tz, emptySet(), defaultTime)
@Test
fun taskWithNoDateProducesNoAlarms() {
val alarms = ReminderScheduler.computeAlarms(listOf(task(date = null)), nowMillis, tz, emptySet())
assertTrue(alarms.isEmpty())
}
@Test fun completedNoAlarms() { assertTrue(compute(listOf(task(completed = true))).isEmpty()) }
@Test fun noDateNoTimeNoAlarms() { assertTrue(compute(listOf(task(date = null, time = null))).isEmpty()) }
@Test
fun alreadyScheduledTaskStillRecomputed() {
// Alarms are always recomputed — AlarmManager deduplicates via PendingIntent
val alarms = ReminderScheduler.computeAlarms(listOf(task()), nowMillis, tz, setOf("t1"))
assertTrue(alarms.isNotEmpty())
}
@Test
fun taskWithDueTimeProducesOneAlarm() {
val t = task(time = LocalTime(15, 0))
val alarms = ReminderScheduler.computeAlarms(listOf(t), nowMillis, tz, emptySet())
@Test fun dateOnlyUsesDefaultTime() {
val alarms = compute(listOf(task()))
assertEquals(1, alarms.size)
assertEquals("t1", alarms[0].taskId)
assertTrue(alarms[0].label.contains("due at"))
}
@Test
fun taskWithDueTimeAlarmAtCorrectTime() {
val t = task(time = LocalTime(15, 0))
val alarms = ReminderScheduler.computeAlarms(listOf(t), nowMillis, tz, emptySet())
val expected = dueDate.atTime(LocalTime(15, 0)).toInstant(tz).toEpochMilliseconds()
val expected = dueDate.atTime(defaultTime).toInstant(tz).toEpochMilliseconds()
assertEquals(expected, alarms[0].triggerAtMillis)
}
@Test
fun taskWithoutTimeProducesTwoAlarms() {
val alarms = ReminderScheduler.computeAlarms(listOf(task()), nowMillis, tz, emptySet())
assertEquals(2, alarms.size)
assertTrue(alarms.any { it.taskId == "t1_am" })
assertTrue(alarms.any { it.taskId == "t1_pm" })
@Test fun dateAndTimeUsesExactTime() {
val alarms = compute(listOf(task(time = LocalTime(15, 0))))
assertEquals(1, alarms.size)
assertEquals(dueDate.atTime(LocalTime(15, 0)).toInstant(tz).toEpochMilliseconds(), alarms[0].triggerAtMillis)
}
@Test
fun defaultAlarmsAt8amAnd8pm() {
val alarms = ReminderScheduler.computeAlarms(listOf(task()), nowMillis, tz, emptySet())
val am = alarms.first { it.taskId == "t1_am" }
val pm = alarms.first { it.taskId == "t1_pm" }
val expected8am = dueDate.atTime(LocalTime(8, 0)).toInstant(tz).toEpochMilliseconds()
val expected8pm = dueDate.atTime(LocalTime(20, 0)).toInstant(tz).toEpochMilliseconds()
assertEquals(expected8am, am.triggerAtMillis)
assertEquals(expected8pm, pm.triggerAtMillis)
}
@Test
fun reminderDurationOffset30min() {
@Test fun reminderOffset30min() {
val t = task(time = LocalTime(15, 0), reminder = ReminderDuration(30, ReminderUnit.MIN))
val alarms = ReminderScheduler.computeAlarms(listOf(t), nowMillis, tz, emptySet())
val alarms = compute(listOf(t))
assertEquals(2, alarms.size)
val reminder = alarms.first { it.taskId.endsWith("_remind") }
val expected = dueDate.atTime(LocalTime(15, 0)).toInstant(tz).toEpochMilliseconds() - 30 * 60_000L
assertEquals(expected, reminder.triggerAtMillis)
}
@Test
fun reminderDurationOffset1hr() {
@Test fun reminderOffset1hr() {
val t = task(time = LocalTime(10, 0), reminder = ReminderDuration(1, ReminderUnit.HR))
val alarms = ReminderScheduler.computeAlarms(listOf(t), nowMillis, tz, emptySet())
val reminder = alarms.first { it.taskId.endsWith("_remind") }
val expected = dueDate.atTime(LocalTime(10, 0)).toInstant(tz).toEpochMilliseconds() - 3_600_000L
assertEquals(expected, reminder.triggerAtMillis)
val reminder = compute(listOf(t)).first { it.taskId.endsWith("_remind") }
assertEquals(dueDate.atTime(LocalTime(10, 0)).toInstant(tz).toEpochMilliseconds() - 3_600_000L, reminder.triggerAtMillis)
}
@Test
fun reminderDurationOffset1day() {
@Test fun reminderOffset1day() {
val t = task(reminder = ReminderDuration(1, ReminderUnit.DAY))
// Set now to 2 days before due date so the 1-day reminder is in the future
val earlyNow = dueDate.atTime(LocalTime(0, 0)).toInstant(tz).toEpochMilliseconds() - 2 * 86_400_000L
val alarms = ReminderScheduler.computeAlarms(listOf(t), earlyNow, tz, emptySet())
val reminder = alarms.first { it.taskId.endsWith("_remind") }
val expected = dueDate.atTime(LocalTime(8, 0)).toInstant(tz).toEpochMilliseconds() - 86_400_000L
assertEquals(expected, reminder.triggerAtMillis)
val reminder = compute(listOf(t), earlyNow).first { it.taskId.endsWith("_remind") }
assertEquals(dueDate.atTime(defaultTime).toInstant(tz).toEpochMilliseconds() - 86_400_000L, reminder.triggerAtMillis)
}
@Test
fun reminderDurationOffset1week() {
@Test fun reminderOffset1week() {
val t = task(reminder = ReminderDuration(1, ReminderUnit.WEEK))
val earlyNow = dueDate.atTime(LocalTime(0, 0)).toInstant(tz).toEpochMilliseconds() - 8 * 86_400_000L
val alarms = ReminderScheduler.computeAlarms(listOf(t), earlyNow, tz, emptySet())
val reminder = alarms.first { it.taskId.endsWith("_remind") }
val expected = dueDate.atTime(LocalTime(8, 0)).toInstant(tz).toEpochMilliseconds() - 604_800_000L
assertEquals(expected, reminder.triggerAtMillis)
val reminder = compute(listOf(t), earlyNow).first { it.taskId.endsWith("_remind") }
assertEquals(dueDate.atTime(defaultTime).toInstant(tz).toEpochMilliseconds() - 604_800_000L, reminder.triggerAtMillis)
}
@Test
fun reminderWithDueTimeProducesBothAlarms() {
@Test fun reminderAndDueTimeBothFire() {
val t = task(time = LocalTime(14, 0), reminder = ReminderDuration(30, ReminderUnit.MIN))
val alarms = ReminderScheduler.computeAlarms(listOf(t), nowMillis, tz, emptySet())
// Should have: reminder alarm + due time alarm
val alarms = compute(listOf(t))
assertEquals(2, alarms.size)
assertTrue(alarms.any { it.taskId.endsWith("_remind") })
assertTrue(alarms.any { it.taskId == "t1" })
}
@Test
fun pastAlarmsNotScheduled() {
// now is after the due time
@Test fun pastAlarmsNotScheduled() {
val lateNow = dueDate.atTime(LocalTime(23, 0)).toInstant(tz).toEpochMilliseconds()
val t = task(time = LocalTime(10, 0))
val alarms = ReminderScheduler.computeAlarms(listOf(t), lateNow, tz, emptySet())
assertTrue(alarms.isEmpty())
assertTrue(compute(listOf(task(time = LocalTime(10, 0))), lateNow).isEmpty())
}
@Test
fun pastReminderNotScheduledButDueTimeStillIs() {
// now is after the reminder time but before due time
@Test fun pastReminderButFutureDue() {
val t = task(time = LocalTime(15, 0), reminder = ReminderDuration(2, ReminderUnit.HR))
val midNow = dueDate.atTime(LocalTime(14, 0)).toInstant(tz).toEpochMilliseconds()
val alarms = ReminderScheduler.computeAlarms(listOf(t), midNow, tz, emptySet())
// Reminder at 13:00 is past, due at 15:00 is future
val alarms = compute(listOf(t), midNow)
assertEquals(1, alarms.size)
assertEquals("t1", alarms[0].taskId)
}
@Test
fun multipleTasksProduceCorrectAlarms() {
@Test fun multipleTasks() {
val tasks = listOf(
task(id = "a", time = LocalTime(9, 0)),
task(id = "b", time = LocalTime(17, 0)),
task(id = "c", completed = true),
task(id = "d", date = null),
)
val alarms = ReminderScheduler.computeAlarms(tasks, nowMillis, tz, emptySet())
assertEquals(2, alarms.size)
assertTrue(alarms.any { it.taskId == "a" })
assertTrue(alarms.any { it.taskId == "b" })
assertEquals(2, compute(tasks).size)
}
@Test
fun alarmLabelsCorrect() {
@Test fun dueLabelIncludesPriorityAndTags() {
val t = task(time = LocalTime(14, 0), priority = 1).copy(lists = listOf("work"))
val alarm = compute(listOf(t)).first { it.taskId == "t1" }
assertTrue(alarm.label.contains("p1"))
assertTrue(alarm.label.contains("#work"))
}
@Test fun reminderLabelIncludesDateAndTime() {
val t = task(time = LocalTime(14, 0), reminder = ReminderDuration(30, ReminderUnit.MIN))
val alarms = ReminderScheduler.computeAlarms(listOf(t), nowMillis, tz, emptySet())
val reminder = alarms.first { it.taskId.endsWith("_remind") }
assertTrue(reminder.label.contains("reminder"))
val due = alarms.first { it.taskId == "t1" }
assertTrue(due.label.contains("due at"))
val alarm = compute(listOf(t)).first { it.taskId.endsWith("_remind") }
assertTrue(alarm.label.contains("Due"))
assertTrue(alarm.label.contains("14:00"))
}
@Test
fun defaultAlarmLabelsCorrect() {
val alarms = ReminderScheduler.computeAlarms(listOf(task()), nowMillis, tz, emptySet())
val am = alarms.first { it.taskId == "t1_am" }
val pm = alarms.first { it.taskId == "t1_pm" }
assertEquals("due today", am.label)
assertTrue(pm.label.contains("still due"))
}
@Test
fun taskTextPreservedInAlarm() {
@Test fun taskTextPreserved() {
val t = task().copy(text = "Buy groceries")
val alarms = ReminderScheduler.computeAlarms(listOf(t), nowMillis, tz, emptySet())
assertTrue(alarms.all { it.taskText == "Buy groceries" })
assertTrue(compute(listOf(t)).all { it.taskText == "Buy groceries" })
}
@Test fun customDefaultTime() {
val alarms = ReminderScheduler.computeAlarms(
listOf(task()), nowMillis, tz, emptySet(), LocalTime(9, 0)
)
val expected = dueDate.atTime(LocalTime(9, 0)).toInstant(tz).toEpochMilliseconds()
assertEquals(expected, alarms[0].triggerAtMillis)
}
}
@@ -1,5 +1,4 @@
package com.avinal.memos.util
actual fun triggerReminderCheck() {
// TODO: iOS notification check
}
actual fun triggerReminderCheck() {}
actual fun setLiveMemosProvider(provider: () -> List<com.avinal.memos.domain.Memo>) {}