feat(android): add clearPredictiveNotifications for predictive_* schedules only

Lets the app refresh API/predictive notifications without cancelAllNotifications
disabling Daily Reminders, dual schedules, or fetch jobs.
This commit is contained in:
Jose Olarte III
2026-06-08 18:45:11 +08:00
parent f82c427108
commit fc5b4cd9fa
5 changed files with 161 additions and 0 deletions

View File

@@ -169,6 +169,12 @@ object DailyNotificationConstants {
*/
const val DUAL_NOTIFY_SCHEDULE_ID_PREFIX = "dual_notify_"
/**
* Prefix for API/predictive batch notification schedule IDs (`predictive_<epochMillis>`).
* Matches iOS [predictiveNotificationPrefix]; must not overlap daily reminders or dual schedules.
*/
const val PREDICTIVE_SCHEDULE_ID_PREFIX = "predictive_"
// ============================================================
// Request Code Versioning
// ============================================================

View File

@@ -743,6 +743,28 @@ open class DailyNotificationPlugin : Plugin() {
}
}
/**
* Cancel only API/predictive notification schedules (`predictive_*`).
* Does not affect Daily Reminders, user-created schedules, dual schedules, or fetch jobs.
*/
@PluginMethod
fun clearPredictiveNotifications(call: PluginCall) {
CoroutineScope(Dispatchers.IO).launch {
try {
if (context == null) {
return@launch call.reject("Context not available")
}
Log.i("DailyNotificationPlugin", "Clearing predictive notifications")
val cleared = ScheduleHelper.clearPredictiveSchedules(context!!, getDatabase())
Log.i("DailyNotificationPlugin", "Cleared $cleared predictive schedules")
call.resolve()
} catch (e: Exception) {
Log.e(TAG, "Failed to clear predictive notifications", e)
call.reject("Failed to clear predictive notifications: ${e.message}")
}
}
}
@PluginMethod
fun scheduleDailyReminder(call: PluginCall) {
// Alias for scheduleDailyNotification for backward compatibility
@@ -3011,6 +3033,50 @@ object ScheduleHelper {
}
}
/**
* Cancel only predictive/API notification schedules (`predictive_*` prefix).
* Removes matching alarms, persisted schedules, and notification content.
* Does not disable Daily Reminders, user schedules, dual schedules, or fetch jobs.
*/
suspend fun clearPredictiveSchedules(context: Context, database: DailyNotificationDatabase): Int {
return try {
val prefix = DailyNotificationConstants.PREDICTIVE_SCHEDULE_ID_PREFIX
val all = database.scheduleDao().getAll()
val predictiveSchedules = all.filter { it.id.startsWith(prefix) }
if (predictiveSchedules.isEmpty()) {
Log.d("ScheduleHelper", "clearPredictiveSchedules: no predictive schedules found")
return 0
}
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
predictiveSchedules.forEach { schedule ->
NotifyReceiver.cancelNotification(
context,
scheduleId = schedule.id,
triggerAtMillis = schedule.nextRunAt
)
notificationManager.cancel(schedule.id.hashCode())
}
predictiveSchedules.forEach { database.scheduleDao().deleteById(it.id) }
database.notificationContentDao().getAllNotifications()
.filter { it.id.startsWith(prefix) }
.forEach { content ->
database.notificationContentDao().deleteNotification(content.id)
notificationManager.cancel(content.id.hashCode())
}
Log.i("ScheduleHelper", "clearPredictiveSchedules: cancelled and removed ${predictiveSchedules.size} predictive schedule(s)")
predictiveSchedules.size
} catch (e: Exception) {
Log.e("ScheduleHelper", "clearPredictiveSchedules failed", e)
0
}
}
/**
* Cancel only WorkManager jobs that can create a second (UUID) alarm for the static-reminder path:
* prefetch and daily_notification_fetch. Does not cancel display, dismiss, or maintenance.

View File

@@ -0,0 +1,80 @@
package org.timesafari.dailynotification
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28])
class ClearPredictiveSchedulesTest {
private lateinit var context: Context
private lateinit var database: DailyNotificationDatabase
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
database = TestDBFactory.createInMemoryDatabase(context)
}
@After
fun tearDown() {
database.close()
}
@Test
fun clearPredictiveSchedules_removesOnlyPredictiveAndPreservesDailyReminder() {
val dailyReminderId = DailyNotificationConstants.DEFAULT_SCHEDULE_ID
val predictiveId = "${DailyNotificationConstants.PREDICTIVE_SCHEDULE_ID_PREFIX}1740000000000"
val userScheduleId = "notify_1740000000001"
runBlocking {
database.scheduleDao().upsert(
Schedule(
id = dailyReminderId,
kind = "notify",
clockTime = "08:00",
enabled = true,
nextRunAt = System.currentTimeMillis() + 60_000
)
)
database.scheduleDao().upsert(
Schedule(
id = predictiveId,
kind = "notify",
enabled = true,
nextRunAt = System.currentTimeMillis() + 120_000
)
)
database.scheduleDao().upsert(
Schedule(
id = userScheduleId,
kind = "notify",
enabled = true,
nextRunAt = System.currentTimeMillis() + 180_000
)
)
}
val cleared = runBlocking {
ScheduleHelper.clearPredictiveSchedules(context, database)
}
assertEquals(1, cleared)
runBlocking {
assertNotNull(database.scheduleDao().getById(dailyReminderId))
assertNull(database.scheduleDao().getById(predictiveId))
assertNotNull(database.scheduleDao().getById(userScheduleId))
}
}
}

View File

@@ -602,6 +602,11 @@ export interface DailyNotificationPlugin {
getLastNotification(): Promise<NotificationResponse | null>;
cancelAllNotifications(): Promise<void>;
/**
* Android only: cancel API/predictive notification schedules (`predictive_*`) without
* affecting Daily Reminders, user schedules, dual schedules, or fetch jobs.
*/
clearPredictiveNotifications(): Promise<void>;
getNotificationStatus(): Promise<NotificationStatus>;
updateSettings(settings: NotificationSettings): Promise<void>;
getBatteryStatus(): Promise<BatteryStatus>;

View File

@@ -118,6 +118,10 @@ export class DailyNotificationWeb implements DailyNotificationPlugin {
this.throwNotSupported();
}
async clearPredictiveNotifications(): Promise<void> {
this.throwNotSupported();
}
async getNotificationStatus(): Promise<{
isEnabled?: boolean;
isScheduled?: boolean;