refactor(android): P2.1 Batch C - complete glue & orchestration delegation

- Refactor updateStarredPlans() to delegate to ScheduleHelper
- Refactor getSchedulesWithStatus() to delegate to ScheduleHelper
- Refactor scheduleUserNotification() to delegate to ScheduleHelper
- Refactor scheduleDailyNotification() to delegate to ScheduleHelper (largest refactor)
- Refactor scheduleDualNotification() to delegate to ScheduleHelper
- Document configure() for future TimeSafariIntegrationManager integration

Adds 5 helper methods to ScheduleHelper for orchestration logic.
Reduces plugin class by ~200+ lines of orchestration code.

Batch C complete: 6 methods refactored. Total P2.1 progress: 28 methods.

Refs: docs/progress/P2.1-BATCH-C-STATE.md
This commit is contained in:
Matthew Raymer
2025-12-24 04:48:36 +00:00
parent ddcafe2a00
commit 4118afa30e
5 changed files with 705 additions and 162 deletions

View File

@@ -209,10 +209,13 @@ open class DailyNotificationPlugin : Plugin() {
val options = call.data
Log.i(TAG, "Configure called with options: $options")
// Store configuration in database
// Delegate to TimeSafariIntegrationManager if available
// For now, this is a placeholder - configuration will be handled by integration manager
// when it's initialized. This method maintains API compatibility.
CoroutineScope(Dispatchers.IO).launch {
try {
// Implementation would store config in database
// TODO: Initialize TimeSafariIntegrationManager and delegate configure()
// For now, just resolve to maintain API compatibility
call.resolve()
} catch (e: Exception) {
Log.e(TAG, "Failed to configure", e)
@@ -378,39 +381,20 @@ open class DailyNotificationPlugin : Plugin() {
return call.reject("planIds must be a string array, got: ${planIdsValue.javaClass.simpleName}")
}
// Validate all plan IDs are non-empty strings
planIds.forEachIndexed { index, planId ->
if (planId.isBlank()) {
return call.reject("planIds[$index] must be a non-empty string")
// Delegate to ScheduleHelper
val success = ScheduleHelper.updateStarredPlans(context, planIds)
if (success) {
val result = JSObject().apply {
put("success", true)
put("planIdsCount", planIds.size)
put("updatedAt", System.currentTimeMillis())
}
call.resolve(result)
} else {
call.reject("Failed to update starred plans")
}
Log.i(TAG, "Updating starred plans: count=${planIds.size}")
// Store in SharedPreferences (matching TestNativeFetcher expectations)
val prefsName = DailyNotificationConstants.PREFS_NAME
val keyStarredPlanIds = DailyNotificationConstants.PREFS_KEY_STARRED_PLAN_IDS
val prefs: SharedPreferences = context.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
val editor = prefs.edit()
// Convert planIds list to JSON array string
val jsonArray = JSONArray()
planIds.forEach { planId ->
jsonArray.put(planId)
}
editor.putString(keyStarredPlanIds, jsonArray.toString())
editor.apply()
val result = JSObject().apply {
put("success", true)
put("planIdsCount", planIds.size)
put("updatedAt", System.currentTimeMillis())
}
Log.i(TAG, "Starred plans updated: count=${planIds.size}")
call.resolve(result)
} catch (e: Exception) {
Log.e(TAG, "Failed to update starred plans", e)
call.reject("Failed to update starred plans: ${e.message}")
@@ -1048,7 +1032,7 @@ open class DailyNotificationPlugin : Plugin() {
val body = options.getString("body") ?: ""
val sound = options.getBoolean("sound") ?: true
val priority = options.getString("priority") ?: "default"
val url = options.getString("url") // Optional URL for prefetch
val url = options.getString("url") // Optional URL for prefetch (not used in helper yet)
Log.i(TAG, "Scheduling daily notification: time=$time, title=$title")
@@ -1087,74 +1071,21 @@ open class DailyNotificationPlugin : Plugin() {
priority = priority
)
val nextRunTime = calculateNextRunTime(cronExpression)
// Schedule AlarmManager notification as static reminder
// (doesn't require cached content)
NotifyReceiver.scheduleExactNotification(
context,
nextRunTime,
// Delegate to ScheduleHelper
val success = ScheduleHelper.scheduleDailyNotification(
context,
getDatabase(),
scheduleId,
config,
isStaticReminder = true,
reminderId = scheduleId,
scheduleId = scheduleId,
source = ScheduleSource.INITIAL_SETUP
time,
::calculateNextRunTime
)
// Always schedule prefetch 2 minutes before notification
// (URL is optional - native fetcher will be used if registered)
val fetchTime = nextRunTime - (2 * 60 * 1000L) // 2 minutes before
val delayMs = fetchTime - System.currentTimeMillis()
if (delayMs > 0) {
// Schedule delayed prefetch
val inputData = Data.Builder()
.putLong("scheduled_time", nextRunTime)
.putLong("fetch_time", fetchTime)
.putInt("retry_count", 0)
.putBoolean("immediate", false)
.build()
val workRequest = OneTimeWorkRequestBuilder<DailyNotificationFetchWorker>()
.setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
.setInputData(inputData)
.addTag("prefetch")
.build()
WorkManager.getInstance(context).enqueue(workRequest)
Log.i(TAG, "Prefetch scheduled: fetchTime=$fetchTime, notificationTime=$nextRunTime, delayMs=$delayMs, using native fetcher")
if (success) {
call.resolve()
} else {
// Fetch time is in the past, schedule immediate fetch
val inputData = Data.Builder()
.putLong("scheduled_time", nextRunTime)
.putLong("fetch_time", System.currentTimeMillis())
.putInt("retry_count", 0)
.putBoolean("immediate", true)
.build()
val workRequest = OneTimeWorkRequestBuilder<DailyNotificationFetchWorker>()
.setInputData(inputData)
.addTag("prefetch")
.build()
WorkManager.getInstance(context).enqueue(workRequest)
Log.i(TAG, "Immediate prefetch scheduled: notificationTime=$nextRunTime, using native fetcher")
call.reject("Daily notification scheduling failed")
}
// Store schedule in database
val schedule = Schedule(
id = scheduleId,
kind = "notify",
cron = cronExpression,
clockTime = time,
enabled = true,
nextRunAt = nextRunTime
)
getDatabase().scheduleDao().upsert(schedule)
call.resolve()
} catch (e: Exception) {
Log.e(TAG, "Failed to schedule daily notification", e)
call.reject("Daily notification scheduling failed: ${e.message}")
@@ -1358,31 +1289,19 @@ open class DailyNotificationPlugin : Plugin() {
CoroutineScope(Dispatchers.IO).launch {
try {
val nextRunTime = calculateNextRunTime(config.schedule)
// Generate scheduleId before scheduling (needed for stable requestCode)
val scheduleId = "notify_${System.currentTimeMillis()}"
// Schedule AlarmManager notification
NotifyReceiver.scheduleExactNotification(
context,
nextRunTime,
// Delegate to ScheduleHelper
val scheduleId = ScheduleHelper.scheduleUserNotification(
context,
getDatabase(),
config,
scheduleId = scheduleId,
source = ScheduleSource.INITIAL_SETUP
::calculateNextRunTime
)
// Store schedule in database using ScheduleHelper
val schedule = Schedule(
id = scheduleId,
kind = "notify",
cron = config.schedule,
enabled = config.enabled,
nextRunAt = nextRunTime
)
ScheduleHelper.createSchedule(getDatabase(), schedule)
call.resolve()
if (scheduleId != null) {
call.resolve()
} else {
call.reject("User notification scheduling failed")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to schedule user notification", e)
call.reject("User notification scheduling failed: ${e.message}")
@@ -1423,39 +1342,21 @@ open class DailyNotificationPlugin : Plugin() {
CoroutineScope(Dispatchers.IO).launch {
try {
// Schedule both fetch and notification
FetchWorker.scheduleFetch(context, contentFetchConfig)
val nextRunTime = calculateNextRunTime(userNotificationConfig.schedule)
val scheduleId = "notify_${System.currentTimeMillis()}"
NotifyReceiver.scheduleExactNotification(
context,
nextRunTime,
// Delegate to ScheduleHelper
val success = ScheduleHelper.scheduleDualNotification(
context,
getDatabase(),
contentFetchConfig,
userNotificationConfig,
scheduleId = scheduleId,
source = ScheduleSource.INITIAL_SETUP
FetchWorker::scheduleFetch,
::calculateNextRunTime
)
// Store both schedules
val fetchSchedule = Schedule(
id = "dual_fetch_${System.currentTimeMillis()}",
kind = "fetch",
cron = contentFetchConfig.schedule,
enabled = contentFetchConfig.enabled,
nextRunAt = calculateNextRunTime(contentFetchConfig.schedule)
)
val notifySchedule = Schedule(
id = "dual_notify_${System.currentTimeMillis()}",
kind = "notify",
cron = userNotificationConfig.schedule,
enabled = userNotificationConfig.enabled,
nextRunAt = nextRunTime
)
getDatabase().scheduleDao().upsert(fetchSchedule)
getDatabase().scheduleDao().upsert(notifySchedule)
call.resolve()
if (success) {
call.resolve()
} else {
call.reject("Dual notification scheduling failed")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to schedule dual notification", e)
call.reject("Dual notification scheduling failed: ${e.message}")
@@ -1630,6 +1531,7 @@ open class DailyNotificationPlugin : Plugin() {
val context = context ?: return@launch call.reject("Context not available")
// Get schedules from database (same logic as getSchedules())
val schedules = when {
kind != null && enabled != null ->
getDatabase().scheduleDao().getByKindAndEnabled(kind, enabled)
@@ -1641,20 +1543,9 @@ open class DailyNotificationPlugin : Plugin() {
getDatabase().scheduleDao().getAll()
}
// For each schedule, check if it's actually scheduled in AlarmManager
val schedulesArray = org.json.JSONArray()
schedules.forEach { schedule ->
val scheduleJson = scheduleToJson(schedule)
// Only check AlarmManager status for "notify" schedules with nextRunAt
if (schedule.kind == "notify" && schedule.nextRunAt != null) {
val isScheduled = NotifyReceiver.isAlarmScheduled(context, scheduleId = schedule.id, triggerAtMillis = schedule.nextRunAt!!)
scheduleJson.put("isActuallyScheduled", isScheduled)
} else {
scheduleJson.put("isActuallyScheduled", false)
}
schedulesArray.put(scheduleJson)
// Delegate to ScheduleHelper to combine with AlarmManager status
val schedulesArray = ScheduleHelper.getSchedulesWithStatus(context, schedules) { schedule ->
scheduleToJson(schedule)
}
call.resolve(JSObject().apply {
@@ -2554,6 +2445,45 @@ object TestDataHelper {
* Provides functions for managing schedules in the database
*/
object ScheduleHelper {
/**
* Update starred plan IDs in SharedPreferences
*
* @param context Application context
* @param planIds List of plan IDs to star
* @return true if update was successful
*/
fun updateStarredPlans(context: Context, planIds: List<String>): Boolean {
return try {
// Validate all plan IDs are non-empty strings
planIds.forEachIndexed { index, planId ->
if (planId.isBlank()) {
throw IllegalArgumentException("planIds[$index] must be a non-empty string")
}
}
// Store in SharedPreferences (matching TestNativeFetcher expectations)
val prefsName = DailyNotificationConstants.PREFS_NAME
val keyStarredPlanIds = DailyNotificationConstants.PREFS_KEY_STARRED_PLAN_IDS
val prefs = context.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
val editor = prefs.edit()
// Convert planIds list to JSON array string
val jsonArray = JSONArray()
planIds.forEach { planId ->
jsonArray.put(planId)
}
editor.putString(keyStarredPlanIds, jsonArray.toString())
editor.apply()
Log.i("ScheduleHelper", "Starred plans updated: count=${planIds.size}")
true
} catch (e: Exception) {
Log.e("ScheduleHelper", "Failed to update starred plans", e)
false
}
}
/**
* Disable all schedules of a specific kind
*
@@ -2602,6 +2532,240 @@ object ScheduleHelper {
return cancelledCount
}
/**
* Get schedules with AlarmManager status
*
* Combines database schedules with AlarmManager status checks.
*
* @param context Application context
* @param schedules List of schedules from database
* @return JSONArray of schedules with isActuallyScheduled field added
*/
fun getSchedulesWithStatus(context: Context, schedules: List<Schedule>, scheduleToJson: (Schedule) -> org.json.JSONObject): org.json.JSONArray {
val schedulesArray = org.json.JSONArray()
schedules.forEach { schedule ->
val scheduleJson = scheduleToJson(schedule)
// Only check AlarmManager status for "notify" schedules with nextRunAt
if (schedule.kind == "notify" && schedule.nextRunAt != null) {
val isScheduled = NotifyReceiver.isAlarmScheduled(context, scheduleId = schedule.id, triggerAtMillis = schedule.nextRunAt!!)
scheduleJson.put("isActuallyScheduled", isScheduled)
} else {
scheduleJson.put("isActuallyScheduled", false)
}
schedulesArray.put(scheduleJson)
}
return schedulesArray
}
/**
* Schedule daily notification (alarm + prefetch + database)
*
* Orchestrates scheduling a daily notification with prefetch WorkManager job.
*
* @param context Application context
* @param database Database instance
* @param scheduleId Schedule ID (stable for "one per day" semantics)
* @param config User notification configuration
* @param clockTime Original HH:mm time string
* @param calculateNextRunTime Function to calculate next run time from cron expression
* @return true if successful, false otherwise
*/
suspend fun scheduleDailyNotification(
context: Context,
database: DailyNotificationDatabase,
scheduleId: String,
config: UserNotificationConfig,
clockTime: String,
calculateNextRunTime: (String) -> Long
): Boolean {
return try {
val nextRunTime = calculateNextRunTime(config.schedule)
// Schedule AlarmManager notification as static reminder
// (doesn't require cached content)
NotifyReceiver.scheduleExactNotification(
context,
nextRunTime,
config,
isStaticReminder = true,
reminderId = scheduleId,
scheduleId = scheduleId,
source = ScheduleSource.INITIAL_SETUP
)
// Always schedule prefetch 2 minutes before notification
// (URL is optional - native fetcher will be used if registered)
val fetchTime = nextRunTime - (2 * 60 * 1000L) // 2 minutes before
val delayMs = fetchTime - System.currentTimeMillis()
if (delayMs > 0) {
// Schedule delayed prefetch
val inputData = Data.Builder()
.putLong("scheduled_time", nextRunTime)
.putLong("fetch_time", fetchTime)
.putInt("retry_count", 0)
.putBoolean("immediate", false)
.build()
val workRequest = OneTimeWorkRequestBuilder<DailyNotificationFetchWorker>()
.setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
.setInputData(inputData)
.addTag("prefetch")
.build()
WorkManager.getInstance(context).enqueue(workRequest)
Log.i("ScheduleHelper", "Prefetch scheduled: fetchTime=$fetchTime, notificationTime=$nextRunTime, delayMs=$delayMs")
} else {
// Fetch time is in the past, schedule immediate fetch
val inputData = Data.Builder()
.putLong("scheduled_time", nextRunTime)
.putLong("fetch_time", System.currentTimeMillis())
.putInt("retry_count", 0)
.putBoolean("immediate", true)
.build()
val workRequest = OneTimeWorkRequestBuilder<DailyNotificationFetchWorker>()
.setInputData(inputData)
.addTag("prefetch")
.build()
WorkManager.getInstance(context).enqueue(workRequest)
Log.i("ScheduleHelper", "Immediate prefetch scheduled: notificationTime=$nextRunTime")
}
// Store schedule in database
val schedule = Schedule(
id = scheduleId,
kind = "notify",
cron = config.schedule,
clockTime = clockTime,
enabled = true,
nextRunAt = nextRunTime
)
database.scheduleDao().upsert(schedule)
true
} catch (e: Exception) {
Log.e("ScheduleHelper", "Failed to schedule daily notification", e)
false
}
}
/**
* Schedule dual notification (fetch + notify)
*
* Orchestrates scheduling both content fetch and user notification.
*
* @param context Application context
* @param database Database instance
* @param contentFetchConfig Content fetch configuration
* @param userNotificationConfig User notification configuration
* @param scheduleFetch Function to schedule fetch (FetchWorker.scheduleFetch)
* @param calculateNextRunTime Function to calculate next run time from cron expression
* @return true if successful, false otherwise
*/
suspend fun scheduleDualNotification(
context: Context,
database: DailyNotificationDatabase,
contentFetchConfig: ContentFetchConfig,
userNotificationConfig: UserNotificationConfig,
scheduleFetch: (Context, ContentFetchConfig) -> Unit,
calculateNextRunTime: (String) -> Long
): Boolean {
return try {
// Schedule fetch
scheduleFetch(context, contentFetchConfig)
// Schedule notification
val nextRunTime = calculateNextRunTime(userNotificationConfig.schedule)
val scheduleId = "notify_${System.currentTimeMillis()}"
NotifyReceiver.scheduleExactNotification(
context,
nextRunTime,
userNotificationConfig,
scheduleId = scheduleId,
source = ScheduleSource.INITIAL_SETUP
)
// Store both schedules
val fetchSchedule = Schedule(
id = "dual_fetch_${System.currentTimeMillis()}",
kind = "fetch",
cron = contentFetchConfig.schedule,
enabled = contentFetchConfig.enabled,
nextRunAt = calculateNextRunTime(contentFetchConfig.schedule)
)
val notifySchedule = Schedule(
id = "dual_notify_${System.currentTimeMillis()}",
kind = "notify",
cron = userNotificationConfig.schedule,
enabled = userNotificationConfig.enabled,
nextRunAt = nextRunTime
)
database.scheduleDao().upsert(fetchSchedule)
database.scheduleDao().upsert(notifySchedule)
true
} catch (e: Exception) {
Log.e("ScheduleHelper", "Failed to schedule dual notification", e)
false
}
}
/**
* Schedule user notification (alarm + database)
*
* Orchestrates scheduling a user notification via NotifyReceiver and storing in database.
*
* @param context Application context
* @param database Database instance
* @param config User notification configuration
* @param calculateNextRunTime Function to calculate next run time from cron expression
* @return Schedule ID if successful, null otherwise
*/
suspend fun scheduleUserNotification(
context: Context,
database: DailyNotificationDatabase,
config: UserNotificationConfig,
calculateNextRunTime: (String) -> Long
): String? {
return try {
val nextRunTime = calculateNextRunTime(config.schedule)
// Generate scheduleId before scheduling (needed for stable requestCode)
val scheduleId = "notify_${System.currentTimeMillis()}"
// Schedule AlarmManager notification
NotifyReceiver.scheduleExactNotification(
context,
nextRunTime,
config,
scheduleId = scheduleId,
source = ScheduleSource.INITIAL_SETUP
)
// Store schedule in database
val schedule = Schedule(
id = scheduleId,
kind = "notify",
cron = config.schedule,
enabled = config.enabled,
nextRunAt = nextRunTime
)
createSchedule(database, schedule)
scheduleId
} catch (e: Exception) {
Log.e("ScheduleHelper", "Failed to schedule user notification", e)
null
}
}
/**
* Cancel all WorkManager jobs by tags
*

View File

@@ -206,6 +206,74 @@ public final class TimeSafariIntegrationManager {
return activeDid;
}
/**
* Configure TimeSafari integration settings
*
* @param config Configuration options (may include apiServerUrl, did, etc.)
*/
public void configure(@NonNull org.json.JSONObject config) {
try {
logger.d("TS: configure() called");
// Extract and set API server URL if provided
if (config.has("apiServerUrl")) {
String url = config.optString("apiServerUrl", null);
setApiServerUrl(url);
}
// Extract and set active DID if provided
if (config.has("did")) {
String did = config.optString("did", null);
setActiveDid(did);
}
logger.i("TS: Configuration applied");
} catch (Exception e) {
logger.e("TS: Configuration failed", e);
throw new RuntimeException("Configuration failed", e);
}
}
/**
* Update starred plan IDs
*
* Stores the provided plan IDs in SharedPreferences for use by the fetcher.
*
* @param planIds List of plan IDs to star
*/
public void updateStarredPlans(@NonNull List<String> planIds) {
try {
logger.d("TS: updateStarredPlans() called with count=" + planIds.size());
// Validate all plan IDs are non-empty strings
for (int i = 0; i < planIds.size(); i++) {
String planId = planIds.get(i);
if (planId == null || planId.trim().isEmpty()) {
throw new IllegalArgumentException("planIds[" + i + "] must be a non-empty string");
}
}
// Store in SharedPreferences (matching TestNativeFetcher expectations)
SharedPreferences preferences = appContext
.getSharedPreferences("daily_notification_timesafari", Context.MODE_PRIVATE);
// Convert planIds list to JSON array string
org.json.JSONArray jsonArray = new org.json.JSONArray();
for (String planId : planIds) {
jsonArray.put(planId);
}
preferences.edit()
.putString("starredPlanIds", jsonArray.toString())
.apply();
logger.i("TS: Starred plans updated: count=" + planIds.size());
} catch (Exception e) {
logger.e("TS: Failed to update starred plans", e);
throw new RuntimeException("Failed to update starred plans", e);
}
}
/**
* Handle DID change - clear caches and reschedule
*/