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:
@@ -6,6 +6,9 @@
|
|||||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||||
<uses-permission android:name="android.permission.USE_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.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
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@@ -28,7 +31,7 @@
|
|||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name="com.avinal.memos.notifications.TaskAlarmReceiver"
|
android:name=".TaskReminderReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
deps.initialize()
|
deps.initialize()
|
||||||
com.avinal.memos.util.appContext = applicationContext
|
com.avinal.memos.util.appContext = applicationContext
|
||||||
|
|
||||||
TaskNotificationManager.createChannel(this)
|
TaskNotificationManager.createChannels(this)
|
||||||
requestNotificationPermission()
|
requestNotificationPermission()
|
||||||
|
requestBatteryOptimizationExemption()
|
||||||
scheduleTaskChecker(applicationContext)
|
scheduleTaskChecker(applicationContext)
|
||||||
|
|
||||||
enableEdgeToEdge()
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
@@ -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 taskText = intent.getStringExtra("task_text") ?: "Task reminder"
|
||||||
val dueLabel = intent.getStringExtra("due_label") ?: "due"
|
val dueLabel = intent.getStringExtra("due_label") ?: "due"
|
||||||
val notificationId = intent.getIntExtra("notification_id", 0)
|
val notificationId = intent.getIntExtra("notification_id", 0)
|
||||||
|
val priority = intent.getIntExtra("priority", 0)
|
||||||
|
|
||||||
|
TaskNotificationManager.createChannels(context)
|
||||||
TaskNotificationManager.showTaskNotification(
|
TaskNotificationManager.showTaskNotification(
|
||||||
context = context,
|
context = context,
|
||||||
notificationId = notificationId,
|
notificationId = notificationId,
|
||||||
taskText = taskText,
|
taskText = taskText,
|
||||||
dueLabel = dueLabel,
|
dueLabel = dueLabel,
|
||||||
|
priority = priority,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-6
@@ -25,6 +25,16 @@ class TaskCheckWorker(
|
|||||||
val scheduledIds = prefs.getStringSet("scheduled_ids", emptySet()) ?: emptySet()
|
val scheduledIds = prefs.getStringSet("scheduled_ids", emptySet()) ?: emptySet()
|
||||||
val nowMillis = Clock.System.now().toEpochMilliseconds()
|
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>(
|
val db = Room.databaseBuilder<MemosDatabase>(
|
||||||
context = appContext,
|
context = appContext,
|
||||||
name = appContext.getDatabasePath("memos.db").absolutePath,
|
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}")
|
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")
|
android.util.Log.d("TaskCheckWorker", "Computed ${alarms.size} alarms")
|
||||||
|
|
||||||
alarms.forEach { alarm ->
|
alarms.forEach { alarm ->
|
||||||
android.util.Log.d("TaskCheckWorker", "Scheduling: ${alarm.taskText} at ${alarm.triggerAtMillis} (${alarm.label})")
|
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)
|
scheduleAlarm(alarmManager, alarm.taskId, alarm.taskText, alarm.label, alarm.triggerAtMillis, alarm.priority)
|
||||||
}
|
}
|
||||||
|
|
||||||
val newScheduledIds = scheduledIds.toMutableSet()
|
val newScheduledIds = scheduledIds.toMutableSet()
|
||||||
@@ -76,15 +87,23 @@ class TaskCheckWorker(
|
|||||||
taskText: String,
|
taskText: String,
|
||||||
dueLabel: String,
|
dueLabel: String,
|
||||||
triggerAtMillis: Long,
|
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("task_text", taskText)
|
||||||
putExtra("due_label", dueLabel)
|
putExtra("due_label", dueLabel)
|
||||||
putExtra("notification_id", alarmId.hashCode())
|
putExtra("notification_id", uniqueId)
|
||||||
|
putExtra("priority", priority)
|
||||||
}
|
}
|
||||||
val pendingIntent = PendingIntent.getBroadcast(
|
val pendingIntent = PendingIntent.getBroadcast(
|
||||||
appContext,
|
appContext,
|
||||||
alarmId.hashCode(),
|
uniqueId,
|
||||||
intent,
|
intent,
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
)
|
)
|
||||||
|
|||||||
+82
-17
@@ -5,24 +5,47 @@ import android.app.NotificationManager
|
|||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.media.AudioAttributes
|
||||||
|
import android.media.RingtoneManager
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
|
||||||
object TaskNotificationManager {
|
object TaskNotificationManager {
|
||||||
|
|
||||||
private const val CHANNEL_ID = "task_reminders"
|
private const val CHANNEL_P1 = "task_p1"
|
||||||
private const val CHANNEL_NAME = "Task Reminders"
|
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) {
|
fun createChannels(context: Context) {
|
||||||
val channel = NotificationChannel(
|
|
||||||
CHANNEL_ID,
|
|
||||||
CHANNEL_NAME,
|
|
||||||
NotificationManager.IMPORTANCE_DEFAULT,
|
|
||||||
).apply {
|
|
||||||
description = "Reminders for tasks with due dates"
|
|
||||||
}
|
|
||||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
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(
|
fun showTaskNotification(
|
||||||
@@ -30,6 +53,7 @@ object TaskNotificationManager {
|
|||||||
notificationId: Int,
|
notificationId: Int,
|
||||||
taskText: String,
|
taskText: String,
|
||||||
dueLabel: String,
|
dueLabel: String,
|
||||||
|
priority: Int = 0,
|
||||||
) {
|
) {
|
||||||
val openIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply {
|
val openIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply {
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
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,
|
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)
|
.setSmallIcon(android.R.drawable.ic_popup_reminder)
|
||||||
.setContentTitle(taskText)
|
.setContentTitle(title)
|
||||||
.setContentText(dueLabel)
|
.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)
|
.setAutoCancel(true)
|
||||||
|
.setOnlyAlertOnce(false)
|
||||||
.setContentIntent(pendingOpen)
|
.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
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
manager.notify(notificationId, notification)
|
manager.notify(notificationId, builder.build())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-1
@@ -1,8 +1,23 @@
|
|||||||
package com.avinal.memos.util
|
package com.avinal.memos.util
|
||||||
|
|
||||||
|
import com.avinal.memos.notifications.DirectAlarmScheduler
|
||||||
import com.avinal.memos.notifications.runTaskCheckNow
|
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() {
|
actual fun triggerReminderCheck() {
|
||||||
val ctx = appContext ?: return
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.IO
|
import kotlinx.coroutines.IO
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import com.avinal.memos.db.entity.toDomain
|
||||||
|
|
||||||
class AppDependencies(
|
class AppDependencies(
|
||||||
dataStorePath: String,
|
dataStorePath: String,
|
||||||
@@ -37,7 +39,11 @@ class AppDependencies(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val authRepository: AuthRepository by lazy { AuthRepository(apiClient, tokenStore) }
|
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
|
private var initJob: kotlinx.coroutines.Job? = null
|
||||||
|
|
||||||
@@ -46,6 +52,17 @@ class AppDependencies(
|
|||||||
initJob = CoroutineScope(Dispatchers.IO).launch {
|
initJob = CoroutineScope(Dispatchers.IO).launch {
|
||||||
launch { tokenStore.accessToken.collect { cachedToken = it } }
|
launch { tokenStore.accessToken.collect { cachedToken = it } }
|
||||||
launch { tokenStore.serverUrl.collect { cachedServerUrl = 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(
|
class MemoRepository(
|
||||||
private val apiClient: MemosApiClient,
|
private val apiClient: MemosApiClient,
|
||||||
private val memoDao: MemoDao,
|
private val memoDao: MemoDao,
|
||||||
|
private val onContentChanged: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
private var nextPageToken: String = ""
|
private var nextPageToken: String = ""
|
||||||
private var hasMorePages: Boolean = true
|
private var hasMorePages: Boolean = true
|
||||||
private var lastFetchTime: Long = 0L
|
private var lastFetchTime: Long = 0L
|
||||||
|
var syncIntervalMinutes: Int = 5
|
||||||
|
|
||||||
private fun nowMillis(): Long = Clock.System.now().toEpochMilliseconds()
|
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>> {
|
fun observeMemos(): Flow<List<Memo>> {
|
||||||
if (isCacheStale()) {
|
if (isCacheStale()) {
|
||||||
@@ -101,6 +103,7 @@ class MemoRepository(
|
|||||||
is ApiResult.Success -> {
|
is ApiResult.Success -> {
|
||||||
val memo = result.data.toDomain()
|
val memo = result.data.toDomain()
|
||||||
memoDao.upsert(memo.toEntity(nowMillis()))
|
memoDao.upsert(memo.toEntity(nowMillis()))
|
||||||
|
onContentChanged?.invoke()
|
||||||
ApiResult.Success(memo)
|
ApiResult.Success(memo)
|
||||||
}
|
}
|
||||||
is ApiResult.Error -> result
|
is ApiResult.Error -> result
|
||||||
@@ -123,6 +126,7 @@ class MemoRepository(
|
|||||||
is ApiResult.Success -> {
|
is ApiResult.Success -> {
|
||||||
val memo = result.data.toDomain()
|
val memo = result.data.toDomain()
|
||||||
memoDao.upsert(memo.toEntity(nowMillis()))
|
memoDao.upsert(memo.toEntity(nowMillis()))
|
||||||
|
onContentChanged?.invoke()
|
||||||
ApiResult.Success(memo)
|
ApiResult.Success(memo)
|
||||||
}
|
}
|
||||||
is ApiResult.Error -> result
|
is ApiResult.Error -> result
|
||||||
@@ -196,8 +200,4 @@ class MemoRepository(
|
|||||||
nextPageToken = ""
|
nextPageToken = ""
|
||||||
hasMorePages = true
|
hasMorePages = true
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val CACHE_TTL_MS = 5 * 60 * 1000L
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-26
@@ -1,5 +1,6 @@
|
|||||||
package com.avinal.memos.notifications
|
package com.avinal.memos.notifications
|
||||||
|
|
||||||
|
import com.avinal.memos.domain.ReminderDuration
|
||||||
import com.avinal.memos.domain.ReminderUnit
|
import com.avinal.memos.domain.ReminderUnit
|
||||||
import com.avinal.memos.domain.Task
|
import com.avinal.memos.domain.Task
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
@@ -13,58 +14,80 @@ data class ScheduledAlarm(
|
|||||||
val taskText: String,
|
val taskText: String,
|
||||||
val label: String,
|
val label: String,
|
||||||
val triggerAtMillis: Long,
|
val triggerAtMillis: Long,
|
||||||
|
val priority: Int = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
object ReminderScheduler {
|
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(
|
fun computeAlarms(
|
||||||
tasks: List<Task>,
|
tasks: List<Task>,
|
||||||
nowMillis: Long,
|
nowMillis: Long,
|
||||||
timeZone: TimeZone,
|
timeZone: TimeZone,
|
||||||
alreadyScheduledIds: Set<String>,
|
alreadyScheduledIds: Set<String> = emptySet(),
|
||||||
|
defaultTime: LocalTime = LocalTime(20, 0),
|
||||||
): List<ScheduledAlarm> {
|
): List<ScheduledAlarm> {
|
||||||
val alarms = mutableListOf<ScheduledAlarm>()
|
val alarms = mutableListOf<ScheduledAlarm>()
|
||||||
|
|
||||||
val today = kotlin.time.Clock.System.todayIn(timeZone)
|
val today = kotlin.time.Clock.System.todayIn(timeZone)
|
||||||
|
|
||||||
tasks.forEach { task ->
|
tasks.forEach { task ->
|
||||||
if (task.isCompleted) return@forEach
|
if (task.isCompleted) return@forEach
|
||||||
|
|
||||||
val effectiveDate = task.dueDate ?: if (task.dueTime != null) today else 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
|
// Explicit reminder: fire at dueDateTime - duration
|
||||||
if (task.reminder != null) {
|
if (task.reminder != null) {
|
||||||
val dueInstant = effectiveDate.atTime(task.dueTime ?: LocalTime(8, 0)).toInstant(timeZone)
|
val offsetMs = durationToMillis(task.reminder)
|
||||||
val offsetMs = when (task.reminder.unit) {
|
val reminderMs = dueMs - offsetMs
|
||||||
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
|
|
||||||
if (reminderMs > nowMillis) {
|
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
|
// Due time alarm
|
||||||
if (task.dueTime != null) {
|
if (dueMs > nowMillis) {
|
||||||
val alarmMs = effectiveDate.atTime(task.dueTime).toInstant(timeZone).toEpochMilliseconds()
|
val label = buildDueLabel(task, effectiveDate, effectiveTime)
|
||||||
if (alarmMs > nowMillis) {
|
alarms.add(ScheduledAlarm(task.id, task.text, label, dueMs, task.priority ?: 0))
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return alarms
|
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)
|
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))
|
Spacer(Modifier.height(24.dp))
|
||||||
SectionHeader("notifications")
|
SectionHeader("notifications")
|
||||||
Spacer(Modifier.height(6.dp))
|
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)
|
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(
|
Text(
|
||||||
"check reminders now",
|
"check reminders now",
|
||||||
fontSize = 13.sp, color = accent,
|
fontSize = 13.sp, color = accent,
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ class SettingsViewModel(
|
|||||||
val weekStartDay: StateFlow<Int> = tokenStore.weekStartDay
|
val weekStartDay: StateFlow<Int> = tokenStore.weekStartDay
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
|
.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 {
|
init {
|
||||||
viewModelScope.launch { authRepository.validateToken() }
|
viewModelScope.launch { authRepository.validateToken() }
|
||||||
}
|
}
|
||||||
@@ -73,6 +79,14 @@ class SettingsViewModel(
|
|||||||
viewModelScope.launch { tokenStore.saveWeekStartDay(day) }
|
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) {
|
fun getExportJson(onResult: (String) -> Unit) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val memos = memoRepository.observeMemos().first()
|
val memos = memoRepository.observeMemos().first()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
package com.avinal.memos.util
|
package com.avinal.memos.util
|
||||||
|
|
||||||
expect fun triggerReminderCheck()
|
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 defaultVisibility: Flow<String> = dataStore.data.map { it[KEY_DEFAULT_VIS] ?: "PRIVATE" }
|
||||||
val defaultReminder: Flow<String> = dataStore.data.map { it[KEY_DEFAULT_REMINDER] ?: "" }
|
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 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) {
|
suspend fun saveCredentials(serverUrl: String, token: String) {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
@@ -49,6 +51,14 @@ class TokenStore(private val dataStore: DataStore<Preferences>) {
|
|||||||
dataStore.edit { it[KEY_WEEK_START] = day.toString() }
|
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() {
|
suspend fun clear() {
|
||||||
dataStore.edit {
|
dataStore.edit {
|
||||||
val theme = it[KEY_THEME]
|
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_VIS = stringPreferencesKey("default_visibility")
|
||||||
private val KEY_DEFAULT_REMINDER = stringPreferencesKey("default_reminder")
|
private val KEY_DEFAULT_REMINDER = stringPreferencesKey("default_reminder")
|
||||||
private val KEY_WEEK_START = stringPreferencesKey("week_start_day")
|
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 tz = TimeZone.UTC
|
||||||
private val dueDate = LocalDate(2026, 6, 15)
|
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 nowMillis = dueDate.atTime(LocalTime(0, 0)).toInstant(tz).toEpochMilliseconds()
|
||||||
|
private val defaultTime = LocalTime(20, 0)
|
||||||
|
|
||||||
private fun task(
|
private fun task(
|
||||||
id: String = "t1",
|
id: String = "t1", completed: Boolean = false,
|
||||||
completed: Boolean = false,
|
date: LocalDate? = dueDate, time: LocalTime? = null,
|
||||||
date: LocalDate? = dueDate,
|
reminder: ReminderDuration? = null, priority: Int? = null,
|
||||||
time: LocalTime? = null,
|
) = Task(id = id, memoId = "m1", lineIndex = 0, text = "Test",
|
||||||
reminder: ReminderDuration? = null,
|
|
||||||
priority: Int? = null,
|
|
||||||
) = Task(
|
|
||||||
id = id, memoId = "m1", lineIndex = 0, text = "Test",
|
|
||||||
isCompleted = completed, dueDate = date, dueTime = time,
|
isCompleted = completed, dueDate = date, dueTime = time,
|
||||||
reminder = reminder, priority = priority,
|
reminder = reminder, priority = priority)
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
private fun compute(tasks: List<Task>, now: Long = nowMillis) =
|
||||||
fun completedTaskProducesNoAlarms() {
|
ReminderScheduler.computeAlarms(tasks, now, tz, emptySet(), defaultTime)
|
||||||
val alarms = ReminderScheduler.computeAlarms(listOf(task(completed = true)), nowMillis, tz, emptySet())
|
|
||||||
assertTrue(alarms.isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test fun completedNoAlarms() { assertTrue(compute(listOf(task(completed = true))).isEmpty()) }
|
||||||
fun taskWithNoDateProducesNoAlarms() {
|
@Test fun noDateNoTimeNoAlarms() { assertTrue(compute(listOf(task(date = null, time = null))).isEmpty()) }
|
||||||
val alarms = ReminderScheduler.computeAlarms(listOf(task(date = null)), nowMillis, tz, emptySet())
|
|
||||||
assertTrue(alarms.isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test fun dateOnlyUsesDefaultTime() {
|
||||||
fun alreadyScheduledTaskStillRecomputed() {
|
val alarms = compute(listOf(task()))
|
||||||
// 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())
|
|
||||||
assertEquals(1, alarms.size)
|
assertEquals(1, alarms.size)
|
||||||
assertEquals("t1", alarms[0].taskId)
|
val expected = dueDate.atTime(defaultTime).toInstant(tz).toEpochMilliseconds()
|
||||||
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()
|
|
||||||
assertEquals(expected, alarms[0].triggerAtMillis)
|
assertEquals(expected, alarms[0].triggerAtMillis)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test fun dateAndTimeUsesExactTime() {
|
||||||
fun taskWithoutTimeProducesTwoAlarms() {
|
val alarms = compute(listOf(task(time = LocalTime(15, 0))))
|
||||||
val alarms = ReminderScheduler.computeAlarms(listOf(task()), nowMillis, tz, emptySet())
|
assertEquals(1, alarms.size)
|
||||||
assertEquals(2, alarms.size)
|
assertEquals(dueDate.atTime(LocalTime(15, 0)).toInstant(tz).toEpochMilliseconds(), alarms[0].triggerAtMillis)
|
||||||
assertTrue(alarms.any { it.taskId == "t1_am" })
|
|
||||||
assertTrue(alarms.any { it.taskId == "t1_pm" })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test fun reminderOffset30min() {
|
||||||
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() {
|
|
||||||
val t = task(time = LocalTime(15, 0), reminder = ReminderDuration(30, ReminderUnit.MIN))
|
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 reminder = alarms.first { it.taskId.endsWith("_remind") }
|
||||||
val expected = dueDate.atTime(LocalTime(15, 0)).toInstant(tz).toEpochMilliseconds() - 30 * 60_000L
|
val expected = dueDate.atTime(LocalTime(15, 0)).toInstant(tz).toEpochMilliseconds() - 30 * 60_000L
|
||||||
assertEquals(expected, reminder.triggerAtMillis)
|
assertEquals(expected, reminder.triggerAtMillis)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test fun reminderOffset1hr() {
|
||||||
fun reminderDurationOffset1hr() {
|
|
||||||
val t = task(time = LocalTime(10, 0), reminder = ReminderDuration(1, ReminderUnit.HR))
|
val t = task(time = LocalTime(10, 0), reminder = ReminderDuration(1, ReminderUnit.HR))
|
||||||
val alarms = ReminderScheduler.computeAlarms(listOf(t), nowMillis, tz, emptySet())
|
val reminder = compute(listOf(t)).first { it.taskId.endsWith("_remind") }
|
||||||
val reminder = alarms.first { it.taskId.endsWith("_remind") }
|
assertEquals(dueDate.atTime(LocalTime(10, 0)).toInstant(tz).toEpochMilliseconds() - 3_600_000L, reminder.triggerAtMillis)
|
||||||
val expected = dueDate.atTime(LocalTime(10, 0)).toInstant(tz).toEpochMilliseconds() - 3_600_000L
|
|
||||||
assertEquals(expected, reminder.triggerAtMillis)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test fun reminderOffset1day() {
|
||||||
fun reminderDurationOffset1day() {
|
|
||||||
val t = task(reminder = ReminderDuration(1, ReminderUnit.DAY))
|
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 earlyNow = dueDate.atTime(LocalTime(0, 0)).toInstant(tz).toEpochMilliseconds() - 2 * 86_400_000L
|
||||||
val alarms = ReminderScheduler.computeAlarms(listOf(t), earlyNow, tz, emptySet())
|
val reminder = compute(listOf(t), earlyNow).first { it.taskId.endsWith("_remind") }
|
||||||
val reminder = alarms.first { it.taskId.endsWith("_remind") }
|
assertEquals(dueDate.atTime(defaultTime).toInstant(tz).toEpochMilliseconds() - 86_400_000L, reminder.triggerAtMillis)
|
||||||
val expected = dueDate.atTime(LocalTime(8, 0)).toInstant(tz).toEpochMilliseconds() - 86_400_000L
|
|
||||||
assertEquals(expected, reminder.triggerAtMillis)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test fun reminderOffset1week() {
|
||||||
fun reminderDurationOffset1week() {
|
|
||||||
val t = task(reminder = ReminderDuration(1, ReminderUnit.WEEK))
|
val t = task(reminder = ReminderDuration(1, ReminderUnit.WEEK))
|
||||||
val earlyNow = dueDate.atTime(LocalTime(0, 0)).toInstant(tz).toEpochMilliseconds() - 8 * 86_400_000L
|
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 = compute(listOf(t), earlyNow).first { it.taskId.endsWith("_remind") }
|
||||||
val reminder = alarms.first { it.taskId.endsWith("_remind") }
|
assertEquals(dueDate.atTime(defaultTime).toInstant(tz).toEpochMilliseconds() - 604_800_000L, reminder.triggerAtMillis)
|
||||||
val expected = dueDate.atTime(LocalTime(8, 0)).toInstant(tz).toEpochMilliseconds() - 604_800_000L
|
|
||||||
assertEquals(expected, reminder.triggerAtMillis)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test fun reminderAndDueTimeBothFire() {
|
||||||
fun reminderWithDueTimeProducesBothAlarms() {
|
|
||||||
val t = task(time = LocalTime(14, 0), reminder = ReminderDuration(30, ReminderUnit.MIN))
|
val t = task(time = LocalTime(14, 0), reminder = ReminderDuration(30, ReminderUnit.MIN))
|
||||||
val alarms = ReminderScheduler.computeAlarms(listOf(t), nowMillis, tz, emptySet())
|
val alarms = compute(listOf(t))
|
||||||
// Should have: reminder alarm + due time alarm
|
|
||||||
assertEquals(2, alarms.size)
|
assertEquals(2, alarms.size)
|
||||||
assertTrue(alarms.any { it.taskId.endsWith("_remind") })
|
assertTrue(alarms.any { it.taskId.endsWith("_remind") })
|
||||||
assertTrue(alarms.any { it.taskId == "t1" })
|
assertTrue(alarms.any { it.taskId == "t1" })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test fun pastAlarmsNotScheduled() {
|
||||||
fun pastAlarmsNotScheduled() {
|
|
||||||
// now is after the due time
|
|
||||||
val lateNow = dueDate.atTime(LocalTime(23, 0)).toInstant(tz).toEpochMilliseconds()
|
val lateNow = dueDate.atTime(LocalTime(23, 0)).toInstant(tz).toEpochMilliseconds()
|
||||||
val t = task(time = LocalTime(10, 0))
|
assertTrue(compute(listOf(task(time = LocalTime(10, 0))), lateNow).isEmpty())
|
||||||
val alarms = ReminderScheduler.computeAlarms(listOf(t), lateNow, tz, emptySet())
|
|
||||||
assertTrue(alarms.isEmpty())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test fun pastReminderButFutureDue() {
|
||||||
fun pastReminderNotScheduledButDueTimeStillIs() {
|
|
||||||
// now is after the reminder time but before due time
|
|
||||||
val t = task(time = LocalTime(15, 0), reminder = ReminderDuration(2, ReminderUnit.HR))
|
val t = task(time = LocalTime(15, 0), reminder = ReminderDuration(2, ReminderUnit.HR))
|
||||||
val midNow = dueDate.atTime(LocalTime(14, 0)).toInstant(tz).toEpochMilliseconds()
|
val midNow = dueDate.atTime(LocalTime(14, 0)).toInstant(tz).toEpochMilliseconds()
|
||||||
val alarms = ReminderScheduler.computeAlarms(listOf(t), midNow, tz, emptySet())
|
val alarms = compute(listOf(t), midNow)
|
||||||
// Reminder at 13:00 is past, due at 15:00 is future
|
|
||||||
assertEquals(1, alarms.size)
|
assertEquals(1, alarms.size)
|
||||||
assertEquals("t1", alarms[0].taskId)
|
assertEquals("t1", alarms[0].taskId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test fun multipleTasks() {
|
||||||
fun multipleTasksProduceCorrectAlarms() {
|
|
||||||
val tasks = listOf(
|
val tasks = listOf(
|
||||||
task(id = "a", time = LocalTime(9, 0)),
|
task(id = "a", time = LocalTime(9, 0)),
|
||||||
task(id = "b", time = LocalTime(17, 0)),
|
task(id = "b", time = LocalTime(17, 0)),
|
||||||
task(id = "c", completed = true),
|
task(id = "c", completed = true),
|
||||||
task(id = "d", date = null),
|
task(id = "d", date = null),
|
||||||
)
|
)
|
||||||
val alarms = ReminderScheduler.computeAlarms(tasks, nowMillis, tz, emptySet())
|
assertEquals(2, compute(tasks).size)
|
||||||
assertEquals(2, alarms.size)
|
|
||||||
assertTrue(alarms.any { it.taskId == "a" })
|
|
||||||
assertTrue(alarms.any { it.taskId == "b" })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test fun dueLabelIncludesPriorityAndTags() {
|
||||||
fun alarmLabelsCorrect() {
|
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 t = task(time = LocalTime(14, 0), reminder = ReminderDuration(30, ReminderUnit.MIN))
|
||||||
val alarms = ReminderScheduler.computeAlarms(listOf(t), nowMillis, tz, emptySet())
|
val alarm = compute(listOf(t)).first { it.taskId.endsWith("_remind") }
|
||||||
val reminder = alarms.first { it.taskId.endsWith("_remind") }
|
assertTrue(alarm.label.contains("Due"))
|
||||||
assertTrue(reminder.label.contains("reminder"))
|
assertTrue(alarm.label.contains("14:00"))
|
||||||
val due = alarms.first { it.taskId == "t1" }
|
|
||||||
assertTrue(due.label.contains("due at"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test fun taskTextPreserved() {
|
||||||
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() {
|
|
||||||
val t = task().copy(text = "Buy groceries")
|
val t = task().copy(text = "Buy groceries")
|
||||||
val alarms = ReminderScheduler.computeAlarms(listOf(t), nowMillis, tz, emptySet())
|
assertTrue(compute(listOf(t)).all { it.taskText == "Buy groceries" })
|
||||||
assertTrue(alarms.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
|
package com.avinal.memos.util
|
||||||
|
|
||||||
actual fun triggerReminderCheck() {
|
actual fun triggerReminderCheck() {}
|
||||||
// TODO: iOS notification check
|
actual fun setLiveMemosProvider(provider: () -> List<com.avinal.memos.domain.Memo>) {}
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user