feat(android): schedule batch notifications with predictive_<epochMillis> IDs

Add scheduleNotifications so Android predictive scheduling matches
clearPredictiveNotifications and iOS batch API behavior.
This commit is contained in:
Jose Olarte III
2026-06-08 19:28:12 +08:00
parent fc5b4cd9fa
commit eca2bbf947
4 changed files with 121 additions and 0 deletions
@@ -19,6 +19,7 @@ import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.Data
import java.util.concurrent.TimeUnit
import org.timesafari.dailynotification.DailyNotificationFetchWorker
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
@@ -743,6 +744,46 @@ open class DailyNotificationPlugin : Plugin() {
}
}
/**
* Add one-shot API/predictive notifications at epoch-ms timestamps (additive; does not clear others).
* Caller should invoke [clearPredictiveNotifications] first when replacing a full batch.
*/
@PluginMethod
fun scheduleNotifications(call: PluginCall) {
val timestampsArray = call.getArray("timestamps")
if (timestampsArray == null) {
call.reject("Missing timestamps")
return
}
CoroutineScope(Dispatchers.IO).launch {
try {
if (context == null) {
return@launch call.reject("Context not available")
}
Log.i(TAG, "DNP-BATCH: scheduleNotifications — additive scheduling for ${timestampsArray.length()} timestamp(s)")
var scheduledCount = 0
for (i in 0 until timestampsArray.length()) {
val raw = timestampsArray.opt(i)
val epochMillis = when (raw) {
is Number -> raw.toLong()
else -> continue
}
if (ScheduleHelper.schedulePredictiveNotification(context!!, getDatabase(), epochMillis)) {
scheduledCount++
}
}
Log.i(TAG, "DNP-BATCH: scheduleNotifications done scheduled=$scheduledCount/${timestampsArray.length()}")
call.resolve()
} catch (e: Exception) {
Log.e(TAG, "Failed to schedule notifications", e)
call.reject("Failed to schedule notifications: ${e.message}")
}
}
}
/**
* Cancel only API/predictive notification schedules (`predictive_*`).
* Does not affect Daily Reminders, user-created schedules, dual schedules, or fetch jobs.
@@ -2957,6 +2998,62 @@ object ScheduleHelper {
}
}
/**
* Schedule one API/predictive notification at an epoch-ms trigger time.
* Uses the predictive_ ID namespace so [clearPredictiveSchedules] can replace batches safely.
*
* @return true when scheduled, false when timestamp is stale (in the past)
*/
suspend fun schedulePredictiveNotification(
context: Context,
database: DailyNotificationDatabase,
epochMillis: Long,
title: String = "Reminder",
body: String = "You have a scheduled notification"
): Boolean {
if (epochMillis <= System.currentTimeMillis()) {
Log.d("ScheduleHelper", "DNP-BATCH: skip stale timestamp ts=$epochMillis (not in the future)")
return false
}
// Predictive/API notifications use the predictive_ namespace so they can be safely
// replaced without affecting Daily Reminder schedules.
val scheduleId = "${DailyNotificationConstants.PREDICTIVE_SCHEDULE_ID_PREFIX}${epochMillis}"
val config = UserNotificationConfig(
enabled = true,
schedule = scheduleId,
title = title,
body = body,
sound = true,
vibration = true,
priority = "default"
)
// Persist before arming so NotifyReceiver updates this row, not an unrelated notify schedule.
createSchedule(
database,
Schedule(
id = scheduleId,
kind = "notify",
enabled = true,
nextRunAt = epochMillis
)
)
NotifyReceiver.scheduleExactNotification(
context,
epochMillis,
config,
reminderId = scheduleId,
scheduleId = scheduleId,
source = ScheduleSource.INITIAL_SETUP
)
Log.i("ScheduleHelper", "DNP-BATCH: scheduled ts=$epochMillis id=$scheduleId")
return true
}
/**
* Schedule user notification (alarm + database)
*
@@ -31,6 +31,21 @@ class ClearPredictiveSchedulesTest {
database.close()
}
@Test
fun schedulePredictiveNotification_usesPredictiveIdNamespace() {
val epochMillis = System.currentTimeMillis() + 300_000
val expectedId = "${DailyNotificationConstants.PREDICTIVE_SCHEDULE_ID_PREFIX}${epochMillis}"
val scheduled = runBlocking {
ScheduleHelper.schedulePredictiveNotification(context, database, epochMillis)
}
assertEquals(true, scheduled)
runBlocking {
assertNotNull(database.scheduleDao().getById(expectedId))
}
}
@Test
fun clearPredictiveSchedules_removesOnlyPredictiveAndPreservesDailyReminder() {
val dailyReminderId = DailyNotificationConstants.DEFAULT_SCHEDULE_ID
+5
View File
@@ -602,6 +602,11 @@ export interface DailyNotificationPlugin {
getLastNotification(): Promise<NotificationResponse | null>;
cancelAllNotifications(): Promise<void>;
/**
* Add one-shot API/predictive notifications at epoch-ms timestamps (additive).
* Android uses `predictive_<timestamp>` schedule IDs; caller should clear first when replacing a batch.
*/
scheduleNotifications(options: { timestamps: number[] }): Promise<void>;
/**
* Android only: cancel API/predictive notification schedules (`predictive_*`) without
* affecting Daily Reminders, user schedules, dual schedules, or fetch jobs.
+4
View File
@@ -118,6 +118,10 @@ export class DailyNotificationWeb implements DailyNotificationPlugin {
this.throwNotSupported();
}
async scheduleNotifications(): Promise<void> {
this.throwNotSupported();
}
async clearPredictiveNotifications(): Promise<void> {
this.throwNotSupported();
}