From ad536d1e3d036f1e36f70048a904612be0e23e23 Mon Sep 17 00:00:00 2001 From: Avinal Kumar Date: Mon, 1 Jun 2026 15:24:43 +0530 Subject: [PATCH] Fix 5 notification reliability issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Remove println debug statement from ReminderScheduler 2. Drop scheduledIds tracking — AlarmManager deduplicates via FLAG_UPDATE_CURRENT, and the tracking prevented rescheduling when a task's due time was edited 3. TaskCheckWorker uses live app DB via liveMemosProvider when available, falls back to opening its own DB only when the app process isn't running (boot receiver, background check) 4. Default notify time synced from DataStore to SharedPreferences via syncNotifyTime() — both DirectAlarmScheduler and TaskCheckWorker now read from the same source via readDefaultNotifyTime() helper 5. TaskReminderReceiver triggers runTaskCheckNow() after each alarm fires, so the next alarm is scheduled immediately instead of waiting up to 15 min for WorkManager Signed-off-by: Avinal Kumar Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com/avinal/memos/TaskReminderReceiver.kt | 2 + .../notifications/DirectAlarmScheduler.kt | 8 +-- .../memos/notifications/NotifyTimeHelper.kt | 20 +++++++ .../memos/notifications/TaskCheckWorker.kt | 53 +++++++------------ .../util/PlatformNotification.android.kt | 8 ++- .../com/avinal/memos/AppDependencies.kt | 2 + .../memos/notifications/ReminderScheduler.kt | 3 -- .../avinal/memos/util/PlatformNotification.kt | 1 + .../com/avinal/memos/ReminderSchedulerTest.kt | 4 +- .../memos/util/PlatformNotification.ios.kt | 1 + 10 files changed, 55 insertions(+), 47 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/NotifyTimeHelper.kt diff --git a/androidApp/src/main/kotlin/com/avinal/memos/TaskReminderReceiver.kt b/androidApp/src/main/kotlin/com/avinal/memos/TaskReminderReceiver.kt index 38c9bea..d88b052 100644 --- a/androidApp/src/main/kotlin/com/avinal/memos/TaskReminderReceiver.kt +++ b/androidApp/src/main/kotlin/com/avinal/memos/TaskReminderReceiver.kt @@ -4,6 +4,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import com.avinal.memos.notifications.TaskNotificationManager +import com.avinal.memos.notifications.runTaskCheckNow class TaskReminderReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { @@ -19,5 +20,6 @@ class TaskReminderReceiver : BroadcastReceiver() { dueLabel = dueLabel, priority = priority, ) + runTaskCheckNow(context) } } diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/DirectAlarmScheduler.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/DirectAlarmScheduler.kt index 8d4991e..e29f654 100644 --- a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/DirectAlarmScheduler.kt +++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/DirectAlarmScheduler.kt @@ -7,7 +7,6 @@ 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 { @@ -17,12 +16,9 @@ object DirectAlarmScheduler { 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 defaultTime = readDefaultNotifyTime(context) - val alarms = ReminderScheduler.computeAlarms(allTasks, nowMillis, tz, emptySet(), defaultTime) + val alarms = ReminderScheduler.computeAlarms(allTasks, nowMillis, tz, defaultTime) val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager alarms.forEach { alarm -> diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/NotifyTimeHelper.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/NotifyTimeHelper.kt new file mode 100644 index 0000000..f9666c8 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/NotifyTimeHelper.kt @@ -0,0 +1,20 @@ +package com.avinal.memos.notifications + +import android.content.Context +import kotlinx.datetime.LocalTime + +fun readDefaultNotifyTime(context: Context): LocalTime { + val prefs = context.getSharedPreferences("nikki_notify", Context.MODE_PRIVATE) + val timeStr = prefs.getString("default_notify_time", "20:00") ?: "20:00" + val parts = timeStr.split(":") + return try { + LocalTime(parts[0].toInt(), parts.getOrElse(1) { "0" }.toInt()) + } catch (_: Exception) { + LocalTime(20, 0) + } +} + +fun writeDefaultNotifyTime(context: Context, time: String) { + context.getSharedPreferences("nikki_notify", Context.MODE_PRIVATE) + .edit().putString("default_notify_time", time).apply() +} diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt index f514574..e00c1e0 100644 --- a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt +++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt @@ -21,20 +21,26 @@ class TaskCheckWorker( ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { - val prefs = appContext.getSharedPreferences("task_notifications", Context.MODE_PRIVATE) - val scheduledIds = prefs.getStringSet("scheduled_ids", emptySet()) ?: emptySet() val nowMillis = Clock.System.now().toEpochMilliseconds() + val defaultTime = readDefaultNotifyTime(appContext) - // 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 memos = com.avinal.memos.util.liveMemosProvider?.invoke() + ?: readMemosFromDb() + + val allTasks = memos.flatMap { memo -> TaskParser.extractTasks(memo.id, memo.content, memo.tags) } + val alarmManager = appContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val tz = TimeZone.currentSystemDefault() + + val alarms = ReminderScheduler.computeAlarms(allTasks, nowMillis, tz, defaultTime) + + alarms.forEach { alarm -> + scheduleAlarm(alarmManager, alarm.taskId, alarm.taskText, alarm.label, alarm.triggerAtMillis, alarm.priority) } + return Result.success() + } + + private suspend fun readMemosFromDb(): List { val db = Room.databaseBuilder( context = appContext, name = appContext.getDatabasePath("memos.db").absolutePath, @@ -44,33 +50,11 @@ class TaskCheckWorker( .setQueryCoroutineContext(Dispatchers.IO) .build() - try { - val memos = db.memoDao().getAll().map { it.toDomain() } - val allTasks = memos.flatMap { memo -> TaskParser.extractTasks(memo.id, memo.content, memo.tags) } - val alarmManager = appContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager - val tz = TimeZone.currentSystemDefault() - - val alarms = ReminderScheduler.computeAlarms(allTasks, nowMillis, tz, scheduledIds, defaultTime) - - alarms.forEach { alarm -> - scheduleAlarm(alarmManager, alarm.taskId, alarm.taskText, alarm.label, alarm.triggerAtMillis, alarm.priority) - } - - val newScheduledIds = scheduledIds.toMutableSet() - alarms.forEach { newScheduledIds.add(it.taskId) } - - val activeTaskIds = allTasks.filter { !it.isCompleted }.map { it.id }.toSet() - val cleaned = newScheduledIds.filter { id -> - val baseId = id.removeSuffix("_am").removeSuffix("_pm").removeSuffix("_remind") - baseId in activeTaskIds - }.toSet() - - prefs.edit().putStringSet("scheduled_ids", cleaned).apply() + return try { + db.memoDao().getAll().map { it.toDomain() } } finally { db.close() } - - return Result.success() } private fun scheduleAlarm( @@ -103,7 +87,6 @@ class TaskCheckWorker( try { alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent) } catch (_: SecurityException) { - // Fallback if exact alarm permission not granted alarmManager.set(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent) } } diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformNotification.android.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformNotification.android.kt index b84d718..803f081 100644 --- a/composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformNotification.android.kt +++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformNotification.android.kt @@ -6,12 +6,18 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -private var liveMemosProvider: (() -> List)? = null +var liveMemosProvider: (() -> List)? = null + private set actual fun setLiveMemosProvider(provider: () -> List) { liveMemosProvider = provider } +actual fun syncNotifyTime(time: String) { + val ctx = appContext ?: return + com.avinal.memos.notifications.writeDefaultNotifyTime(ctx, time) +} + actual fun triggerReminderCheck() { val ctx = appContext ?: return val memos = liveMemosProvider?.invoke() diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/AppDependencies.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/AppDependencies.kt index 75ab4d7..2ccf814 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/AppDependencies.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/AppDependencies.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.IO import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import com.avinal.memos.db.entity.toDomain +import com.avinal.memos.util.syncNotifyTime class AppDependencies( dataStorePath: String, @@ -55,6 +56,7 @@ class AppDependencies( launch { tokenStore.accessToken.collect { cachedToken = it } } launch { tokenStore.serverUrl.collect { cachedServerUrl = it } } launch { tokenStore.syncInterval.collect { memoRepository.syncIntervalMinutes = it } } + launch { tokenStore.defaultNotifyTime.collect { syncNotifyTime(it) } } launch { initializeLiveMemosProvider() } } } diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/notifications/ReminderScheduler.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/notifications/ReminderScheduler.kt index 8659c05..c018382 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/notifications/ReminderScheduler.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/notifications/ReminderScheduler.kt @@ -34,7 +34,6 @@ object ReminderScheduler { tasks: List, nowMillis: Long, timeZone: TimeZone, - alreadyScheduledIds: Set = emptySet(), defaultTime: LocalTime = LocalTime(20, 0), ): List { val alarms = mutableListOf() @@ -48,8 +47,6 @@ object ReminderScheduler { 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 offsetMs = durationToMillis(task.reminder) diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/util/PlatformNotification.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/util/PlatformNotification.kt index d06a5a1..ec74c60 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/util/PlatformNotification.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/util/PlatformNotification.kt @@ -2,3 +2,4 @@ package com.avinal.memos.util expect fun triggerReminderCheck() expect fun setLiveMemosProvider(provider: () -> List) +expect fun syncNotifyTime(time: String) diff --git a/composeApp/src/commonTest/kotlin/com/avinal/memos/ReminderSchedulerTest.kt b/composeApp/src/commonTest/kotlin/com/avinal/memos/ReminderSchedulerTest.kt index 3ad7d64..9fc3728 100644 --- a/composeApp/src/commonTest/kotlin/com/avinal/memos/ReminderSchedulerTest.kt +++ b/composeApp/src/commonTest/kotlin/com/avinal/memos/ReminderSchedulerTest.kt @@ -29,7 +29,7 @@ class ReminderSchedulerTest { reminder = reminder, priority = priority) private fun compute(tasks: List, now: Long = nowMillis) = - ReminderScheduler.computeAlarms(tasks, now, tz, emptySet(), defaultTime) + ReminderScheduler.computeAlarms(tasks, now, tz, defaultTime) @Test fun completedNoAlarms() { assertTrue(compute(listOf(task(completed = true))).isEmpty()) } @Test fun noDateNoTimeNoAlarms() { assertTrue(compute(listOf(task(date = null, time = null))).isEmpty()) } @@ -128,7 +128,7 @@ class ReminderSchedulerTest { @Test fun customDefaultTime() { val alarms = ReminderScheduler.computeAlarms( - listOf(task()), nowMillis, tz, emptySet(), LocalTime(9, 0) + listOf(task()), nowMillis, tz, LocalTime(9, 0) ) val expected = dueDate.atTime(LocalTime(9, 0)).toInstant(tz).toEpochMilliseconds() assertEquals(expected, alarms[0].triggerAtMillis) diff --git a/composeApp/src/iosMain/kotlin/com/avinal/memos/util/PlatformNotification.ios.kt b/composeApp/src/iosMain/kotlin/com/avinal/memos/util/PlatformNotification.ios.kt index b7fd1c8..eefa3cc 100644 --- a/composeApp/src/iosMain/kotlin/com/avinal/memos/util/PlatformNotification.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/avinal/memos/util/PlatformNotification.ios.kt @@ -2,3 +2,4 @@ package com.avinal.memos.util actual fun triggerReminderCheck() {} actual fun setLiveMemosProvider(provider: () -> List) {} +actual fun syncNotifyTime(time: String) {}