refactor(android): P2.1 Batch B - delegate validation methods to services

Refactor plugin methods that validate input then delegate to services:
- requestNotificationPermissions() → PermissionManager
- openChannelSettings() → ChannelManager
- createSchedule/updateSchedule/deleteSchedule/enableSchedule() → ScheduleHelper
- scheduleUserNotification() → ScheduleHelper (database operations)
- registerCallback() → CallbackHelper
- injectInvalidTestData() → TestDataHelper
- requestExactAlarmPermission() → PermissionManager
- openExactAlarmSettings() → PermissionManager
- checkExactAlarmPermission() → PermissionManager
- cancelAllNotifications() → ScheduleHelper (database operations, partial)
- testAlarm() → DailyNotificationScheduler

Enhanced services:
- PermissionManager: Added checkExactAlarmPermission() and requestExactAlarmPermission()
- ChannelManager: Enhanced openChannelSettings() with channelId parameter and fallback logic
- ScheduleHelper: Added disableAllSchedulesByKind() method
- DailyNotificationScheduler: Added testAlarm() wrapper method

Reduces plugin class complexity by ~200 lines.
Services already exist - this is delegation, not extraction.

Refs: docs/progress/P2.1-BATCH-B-STATE.md
This commit is contained in:
Matthew Raymer
2025-12-23 12:01:32 +00:00
parent 87f12a0029
commit 694c7ea59f
6 changed files with 934 additions and 348 deletions

View File

@@ -118,18 +118,53 @@ public class ChannelManager {
* @return true if settings intent was launched, false otherwise
*/
public boolean openChannelSettings() {
return openChannelSettings(DEFAULT_CHANNEL_ID);
}
/**
* Opens the notification channel settings for a specific channel.
*
* @param channelId Channel ID to open settings for (defaults to DEFAULT_CHANNEL_ID if null)
* @return true if settings intent was launched, false otherwise
*/
public boolean openChannelSettings(String channelId) {
try {
Log.d(TAG, "Opening channel settings");
Log.d(TAG, "Opening channel settings for channel: " + channelId);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Intent intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, context.getPackageName())
.putExtra(Settings.EXTRA_CHANNEL_ID, DEFAULT_CHANNEL_ID)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
// Ensure channel exists before trying to open settings
NotificationChannel channel = notificationManager.getNotificationChannel(channelId);
if (channel == null) {
Log.d(TAG, "Channel does not exist, creating it first");
createDefaultChannel();
}
context.startActivity(intent);
Log.d(TAG, "Channel settings opened");
return true;
// Try to open channel-specific settings
try {
Intent intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, context.getPackageName())
.putExtra(Settings.EXTRA_CHANNEL_ID, channelId != null ? channelId : DEFAULT_CHANNEL_ID)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
Log.d(TAG, "Channel settings opened for channel: " + channelId);
return true;
} catch (Exception e) {
// Fallback to general app notification settings
Log.w(TAG, "Failed to open channel-specific settings, trying app notification settings", e);
try {
Intent fallbackIntent = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, context.getPackageName())
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(fallbackIntent);
Log.d(TAG, "App notification settings opened (fallback)");
return true;
} catch (Exception e2) {
Log.e(TAG, "Failed to open notification settings", e2);
return false;
}
}
} else {
Log.d(TAG, "Channel settings not available on pre-Oreo");
return false;

View File

@@ -380,44 +380,21 @@ open class DailyNotificationPlugin : Plugin() {
val activity = activity ?: return call.reject("Activity not available")
val context = context ?: return call.reject("Context not available")
Log.i(TAG, "Requesting notification permissions")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// For Android 13+, request POST_NOTIFICATIONS permission
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED) {
// Already granted
val result = JSObject().apply {
put("status", "granted")
put("granted", true)
put("notifications", "granted")
}
call.resolve(result)
} else {
// Save the call using Capacitor's mechanism so it can be retrieved later
saveCall(call)
// Request permission - result will be handled in handleRequestPermissionsResult
// Note: Capacitor's Bridge intercepts permission results, so we also check
// permission status when the app resumes as a fallback
ActivityCompat.requestPermissions(
activity,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
PERMISSION_REQUEST_CODE
)
Log.i(TAG, "Permission dialog shown, waiting for user response (requestCode=$PERMISSION_REQUEST_CODE)")
// Don't resolve here - wait for handleRequestPermissionsResult
// Ensure permissionManager is initialized
if (permissionManager == null) {
if (channelManager == null) {
channelManager = ChannelManager(context)
}
} else {
// For older versions, permissions are granted at install time
val result = JSObject().apply {
put("status", "granted")
put("granted", true)
put("notifications", "granted")
}
call.resolve(result)
permissionManager = PermissionManager(context, channelManager)
}
// Save the call using Capacitor's mechanism so it can be retrieved later
// (needed for handleRequestPermissionsResult to resolve the call)
saveCall(call)
// Delegate to PermissionManager.requestNotificationPermissions()
permissionManager!!.requestNotificationPermissions(call, activity)
} catch (e: Exception) {
Log.e(TAG, "Failed to request notification permissions", e)
call.reject("Permission request failed: ${e.message}")
@@ -430,6 +407,7 @@ open class DailyNotificationPlugin : Plugin() {
*/
@PluginMethod
override fun requestPermissions(call: PluginCall) {
// Delegate to requestNotificationPermissions (which delegates to PermissionManager)
requestNotificationPermissions(call)
}
@@ -673,19 +651,13 @@ open class DailyNotificationPlugin : Plugin() {
// Don't fail - continue with database cleanup
}
// 4. Clear database state - disable all notification schedules
// 4. Clear database state - disable all notification and fetch schedules
try {
notifySchedules.forEach { schedule ->
getDatabase().scheduleDao().setEnabled(schedule.id, false)
}
// Delegate to ScheduleHelper
val disabledNotify = ScheduleHelper.disableAllSchedulesByKind(getDatabase(), "notify")
val disabledFetch = ScheduleHelper.disableAllSchedulesByKind(getDatabase(), "fetch")
// Also clear any fetch schedules
val fetchSchedules = schedules.filter { it.kind == "fetch" && it.enabled }
fetchSchedules.forEach { schedule ->
getDatabase().scheduleDao().setEnabled(schedule.id, false)
}
Log.i(TAG, "Disabled ${notifySchedules.size} notification schedule(s) and ${fetchSchedules.size} fetch schedule(s)")
Log.i(TAG, "Disabled $disabledNotify notification schedule(s) and $disabledFetch fetch schedule(s)")
} catch (e: Exception) {
Log.e(TAG, "Failed to clear database state", e)
// Continue - alarms and jobs are already cancelled
@@ -799,17 +771,16 @@ open class DailyNotificationPlugin : Plugin() {
return call.reject("Context not available")
}
val canSchedule = canScheduleExactAlarms(context)
val canRequest = canRequestExactAlarmPermission(context)
val result = JSObject().apply {
put("canSchedule", canSchedule)
put("canRequest", canRequest)
put("required", Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
// Ensure permissionManager is initialized
if (permissionManager == null) {
if (channelManager == null) {
channelManager = ChannelManager(context)
}
permissionManager = PermissionManager(context, channelManager)
}
Log.i(TAG, "Exact alarm permission check: canSchedule=$canSchedule, canRequest=$canRequest")
call.resolve(result)
// Delegate to PermissionManager.checkExactAlarmPermission()
permissionManager!!.checkExactAlarmPermission(call)
} catch (e: Exception) {
Log.e(TAG, "Failed to check exact alarm permission", e)
call.reject("Permission check failed: ${e.message}")
@@ -833,63 +804,16 @@ open class DailyNotificationPlugin : Plugin() {
return call.reject("Context not available")
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
// Android 11 and below don't need this permission
Log.i(TAG, "Exact alarm permission not required on Android ${Build.VERSION.SDK_INT}")
call.resolve(JSObject().apply {
put("success", true)
put("message", "Exact alarm permission not required on this Android version")
})
return
// Ensure permissionManager is initialized
if (permissionManager == null) {
if (channelManager == null) {
channelManager = ChannelManager(context)
}
permissionManager = PermissionManager(context, channelManager)
}
if (canScheduleExactAlarms(context)) {
// Permission already granted
Log.i(TAG, "Exact alarm permission already granted")
call.resolve(JSObject().apply {
put("success", true)
put("message", "Exact alarm permission already granted")
})
return
}
// Check if app can request the permission
if (canRequestExactAlarmPermission(context)) {
// Open Settings to let user grant permission
try {
val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
data = android.net.Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
Log.i(TAG, "Opened exact alarm permission settings")
call.resolve(JSObject().apply {
put("success", true)
put("message", "Please grant 'Alarms & reminders' permission in Settings")
})
} catch (e: Exception) {
Log.e(TAG, "Failed to open exact alarm settings", e)
call.reject("Failed to open exact alarm settings: ${e.message}")
}
} else {
// User has already denied or permission is permanently denied
// Direct user to app settings
try {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = android.net.Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
Log.w(TAG, "Permission denied. Directing user to app settings")
call.reject(
"Permission denied. Please enable 'Alarms & reminders' in app settings.",
"PERMISSION_DENIED"
)
} catch (e: Exception) {
Log.e(TAG, "Failed to open app settings", e)
call.reject("Failed to open app settings: ${e.message}")
}
}
// Delegate to PermissionManager.requestExactAlarmPermission()
permissionManager!!.requestExactAlarmPermission(call)
} catch (e: Exception) {
Log.e(TAG, "Failed to request exact alarm permission", e)
call.reject("Permission request failed: ${e.message}")
@@ -905,16 +829,20 @@ open class DailyNotificationPlugin : Plugin() {
@PluginMethod
fun openExactAlarmSettings(call: PluginCall) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val intent = Intent(android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
data = android.net.Uri.parse("package:${context?.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
activity?.startActivity(intent) ?: context?.startActivity(intent)
call.resolve()
} else {
call.reject("Exact alarm settings are only available on Android 12+")
if (context == null) {
return call.reject("Context not available")
}
// Ensure permissionManager is initialized
if (permissionManager == null) {
if (channelManager == null) {
channelManager = ChannelManager(context)
}
permissionManager = PermissionManager(context, channelManager)
}
// Delegate to PermissionManager.openExactAlarmSettings()
permissionManager!!.openExactAlarmSettings(call)
} catch (e: Exception) {
Log.e(TAG, "Failed to open exact alarm settings", e)
call.reject("Failed to open exact alarm settings: ${e.message}")
@@ -978,77 +906,25 @@ open class DailyNotificationPlugin : Plugin() {
return call.reject("Context not available")
}
// Ensure channelManager is initialized
if (channelManager == null) {
channelManager = ChannelManager(context)
}
// Use the actual channel ID that matches what's used in notifications
val channelId = call.getString("channelId") ?: "timesafari.daily"
val channelId = call.getString("channelId") ?: channelManager!!.getDefaultChannelId()
// Ensure channel exists before trying to open settings
// This ensures the channel-specific settings page can be opened
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? android.app.NotificationManager
val channel = notificationManager?.getNotificationChannel(channelId)
if (channel == null) {
// Channel doesn't exist - create it first
Log.i(TAG, "Channel $channelId doesn't exist, creating it")
val newChannel = android.app.NotificationChannel(
channelId,
"Daily Notifications",
android.app.NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Daily notifications from TimeSafari"
enableLights(true)
enableVibration(true)
setShowBadge(true)
}
notificationManager?.createNotificationChannel(newChannel)
Log.i(TAG, "Channel $channelId created")
}
}
// Try to open channel-specific settings first
try {
val intent = Intent(android.provider.Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, context.packageName)
putExtra(android.provider.Settings.EXTRA_CHANNEL_ID, channelId)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
activity?.startActivity(intent) ?: context.startActivity(intent)
Log.i(TAG, "Channel settings opened for channel: $channelId")
val result = JSObject().apply {
put("opened", true)
put("channelId", channelId)
}
call.resolve(result)
} catch (e: Exception) {
// Fallback to general app notification settings if channel-specific fails
Log.w(TAG, "Failed to open channel-specific settings, trying app notification settings", e)
try {
val fallbackIntent = Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, context.packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
activity?.startActivity(fallbackIntent) ?: context.startActivity(fallbackIntent)
Log.i(TAG, "App notification settings opened (fallback)")
val result = JSObject().apply {
put("opened", true)
put("channelId", channelId)
put("fallback", true)
put("message", "Opened app notification settings (channel-specific unavailable)")
}
call.resolve(result)
} catch (e2: Exception) {
Log.e(TAG, "Failed to open notification settings", e2)
val result = JSObject().apply {
put("opened", false)
put("channelId", channelId)
put("error", e2.message)
}
call.resolve(result)
// Delegate to ChannelManager.openChannelSettings()
val opened = channelManager!!.openChannelSettings(channelId)
val result = JSObject().apply {
put("opened", opened)
put("channelId", channelId)
if (!opened) {
put("error", "Failed to open channel settings")
}
}
call.resolve(result)
} catch (e: Exception) {
Log.e(TAG, "Failed to open channel settings", e)
call.reject("Failed to open channel settings: ${e.message}")
@@ -1186,45 +1062,17 @@ open class DailyNotificationPlugin : Plugin() {
// CRITICAL: Cancel and delete all existing notification schedules before creating new one
// This ensures "one per day" semantics - only one daily notification schedule exists
// This cleanup runs regardless of whether user provided an ID or not
val existingSchedules = getDatabase().scheduleDao().getByKind("notify")
Log.i(TAG, "scheduleDailyNotification: Found ${existingSchedules.size} existing notification schedule(s) in database")
if (existingSchedules.isNotEmpty()) {
Log.i(TAG, "scheduleDailyNotification: Existing schedule IDs: ${existingSchedules.map { it.id }.joinToString(", ")}")
}
var cleanedCount = 0
existingSchedules.forEach { existingSchedule ->
try {
// Skip if this is the same schedule we're about to create (will be upserted anyway)
if (existingSchedule.id == scheduleId) {
Log.i(TAG, "scheduleDailyNotification: Skipping cleanup of schedule with same ID (will be updated): ${existingSchedule.id}")
return@forEach
}
Log.i(TAG, "scheduleDailyNotification: Cleaning up existing schedule: id=${existingSchedule.id}, nextRunAt=${existingSchedule.nextRunAt}, enabled=${existingSchedule.enabled}")
// Cancel the alarm in AlarmManager
NotifyReceiver.cancelNotification(context, existingSchedule.id)
Log.i(TAG, "scheduleDailyNotification: Cancelled alarm for schedule: ${existingSchedule.id}")
// Delete from database
getDatabase().scheduleDao().deleteById(existingSchedule.id)
Log.i(TAG, "scheduleDailyNotification: Deleted schedule from database: ${existingSchedule.id}")
cleanedCount++
} catch (e: Exception) {
Log.e(TAG, "scheduleDailyNotification: Failed to cancel/delete existing schedule: ${existingSchedule.id}", e)
// Continue with other schedules - don't fail entire operation
}
}
// Delegate cleanup to ScheduleHelper
val cleanedCount = ScheduleHelper.cleanupExistingNotificationSchedules(
context,
getDatabase(),
excludeScheduleId = scheduleId
)
if (cleanedCount > 0) {
Log.i(TAG, "scheduleDailyNotification: ✅ Cleaned up $cleanedCount existing notification schedule(s) before creating new one (total found: ${existingSchedules.size})")
} else if (existingSchedules.isNotEmpty()) {
Log.i(TAG, "scheduleDailyNotification: No cleanup needed - existing schedule will be updated via upsert: $scheduleId")
Log.i(TAG, "scheduleDailyNotification: ✅ Cleaned up $cleanedCount existing notification schedule(s) before creating new one")
} else {
Log.i(TAG, "scheduleDailyNotification: No existing schedules found - creating first notification schedule")
Log.i(TAG, "scheduleDailyNotification: No cleanup needed - existing schedule will be updated via upsert: $scheduleId")
}
val config = UserNotificationConfig(
@@ -1401,8 +1249,15 @@ open class DailyNotificationPlugin : Plugin() {
val context = context ?: return call.reject("Context not available")
Log.i(TAG, "TEST: Scheduling test alarm in $secondsFromNow seconds")
NotifyReceiver.testAlarm(context, secondsFromNow)
// Initialize scheduler if needed (lazy initialization)
if (scheduler == null) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
?: return call.reject("AlarmManager not available")
scheduler = DailyNotificationScheduler(context, alarmManager)
}
// Delegate to DailyNotificationScheduler.testAlarm()
scheduler!!.testAlarm(secondsFromNow)
val result = JSObject().apply {
put("scheduled", true)
@@ -1440,72 +1295,24 @@ open class DailyNotificationPlugin : Plugin() {
val db = getDatabase()
val injected = mutableListOf<String>()
// Inject schedule with empty ID
if (injectEmptyScheduleId) {
try {
val invalidSchedule = Schedule(
id = "", // Empty ID - should be skipped by recovery
kind = "notify",
cron = "0 9 * * *",
clockTime = "09:00",
enabled = true,
nextRunAt = System.currentTimeMillis() + 86400000L
)
db.scheduleDao().upsert(invalidSchedule)
injected.add("empty_schedule_id")
Log.i(TAG, "TEST: Injected schedule with empty ID")
} catch (e: Exception) {
Log.e(TAG, "TEST: Failed to inject empty schedule ID", e)
}
}
// Delegate schedule injection to TestDataHelper
val scheduleInjected = TestDataHelper.injectInvalidScheduleData(
database = db,
injectEmptyScheduleId = injectEmptyScheduleId,
injectNullNextRunAt = injectNullNextRunAt
)
injected.addAll(scheduleInjected)
// Inject schedule with null nextRunAt
if (injectNullNextRunAt) {
try {
val invalidSchedule = Schedule(
id = "test_null_nextrunat",
kind = "notify",
cron = "0 9 * * *",
clockTime = "09:00",
enabled = true,
nextRunAt = null // Null nextRunAt - should be skipped by recovery
)
db.scheduleDao().upsert(invalidSchedule)
injected.add("null_nextrunat")
Log.i(TAG, "TEST: Injected schedule with null nextRunAt")
} catch (e: Exception) {
Log.e(TAG, "TEST: Failed to inject null nextRunAt", e)
}
}
// Inject notification with empty ID
// Inject notification with empty ID (if requested)
// Note: Room's @NonNull constraint may prevent this, but we try anyway
// If it fails, the other invalid data types (null nextRunAt) will still test recovery
if (injectEmptyNotificationId) {
try {
val invalidNotification =
com.timesafari.dailynotification.entities.NotificationContentEntity()
invalidNotification.id = "" // Empty ID - should be skipped by recovery
invalidNotification.title = "Test Invalid Notification"
invalidNotification.body = "This has an empty ID"
invalidNotification.scheduledTime = System.currentTimeMillis() - 3600000L // 1 hour ago
invalidNotification.deliveryStatus = "pending"
invalidNotification.deliveryAttempts = 0
invalidNotification.lastDeliveryAttempt = 0
invalidNotification.userInteractionCount = 0
invalidNotification.lastUserInteraction = 0
invalidNotification.ttlSeconds = 86400L
invalidNotification.createdAt = System.currentTimeMillis()
invalidNotification.updatedAt = System.currentTimeMillis()
db.notificationContentDao().insertNotification(invalidNotification)
val notificationInjected = TestDataHelper.injectInvalidNotificationData(db)
if (notificationInjected) {
injected.add("empty_notification_id")
Log.i(TAG, "TEST: Injected notification with empty ID")
} catch (e: Exception) {
Log.w(TAG, "TEST: Failed to inject empty notification ID (Room @NonNull constraint may prevent this): ${e.message}")
} else {
Log.w(TAG, "TEST: Failed to inject empty notification ID (Room @NonNull constraint may prevent this)")
Log.i(TAG, "TEST: Other invalid data types (null nextRunAt, empty schedule ID) will still test recovery")
// This is expected - Room may reject empty primary keys
// The other invalid data types will still test recovery handling
}
}
@@ -1594,7 +1401,7 @@ open class DailyNotificationPlugin : Plugin() {
source = ScheduleSource.INITIAL_SETUP
)
// Store schedule in database
// Store schedule in database using ScheduleHelper
val schedule = Schedule(
id = scheduleId,
kind = "notify",
@@ -1602,7 +1409,7 @@ open class DailyNotificationPlugin : Plugin() {
enabled = config.enabled,
nextRunAt = nextRunTime
)
getDatabase().scheduleDao().upsert(schedule)
ScheduleHelper.createSchedule(getDatabase(), schedule)
call.resolve()
} catch (e: Exception) {
@@ -1765,7 +1572,8 @@ open class DailyNotificationPlugin : Plugin() {
createdAt = System.currentTimeMillis()
)
getDatabase().callbackDao().upsert(callbackRecord)
// Delegate to CallbackHelper
CallbackHelper.registerCallback(getDatabase(), callbackRecord)
call.resolve()
} catch (e: Exception) {
Log.e(TAG, "Failed to register callback", e)
@@ -1939,8 +1747,9 @@ open class DailyNotificationPlugin : Plugin() {
stateJson = scheduleJson.getString("stateJson")
)
getDatabase().scheduleDao().upsert(schedule)
call.resolve(scheduleToJson(schedule))
// Delegate to ScheduleHelper
val created = ScheduleHelper.createSchedule(getDatabase(), schedule)
call.resolve(scheduleToJson(created))
} catch (e: Exception) {
Log.e(TAG, "Failed to create schedule", e)
call.reject("Failed to create schedule: ${e.message}")
@@ -1958,29 +1767,21 @@ open class DailyNotificationPlugin : Plugin() {
val updates = call.getObject("updates")
?: return@launch call.reject("Updates are required")
val existing = getDatabase().scheduleDao().getById(id)
?: return@launch call.reject("Schedule not found: $id")
// Update fields
getDatabase().scheduleDao().update(
// Delegate to ScheduleHelper
val updated = ScheduleHelper.updateSchedule(
database = getDatabase(),
id = id,
enabled = updates.getBoolean("enabled")?.let { it },
cron = updates.getString("cron"),
clockTime = updates.getString("clockTime"),
jitterMs = updates.getInt("jitterMs")?.let { it },
backoffPolicy = updates.getString("backoffPolicy"),
stateJson = updates.getString("stateJson")
stateJson = updates.getString("stateJson"),
lastRunAt = updates.getLong("lastRunAt"),
nextRunAt = updates.getLong("nextRunAt")
)
// Update run times if provided
val lastRunAt = updates.getLong("lastRunAt")
val nextRunAt = updates.getLong("nextRunAt")
if (lastRunAt != null || nextRunAt != null) {
getDatabase().scheduleDao().updateRunTimes(id, lastRunAt, nextRunAt)
}
val updated = getDatabase().scheduleDao().getById(id)
call.resolve(scheduleToJson(updated!!))
call.resolve(scheduleToJson(updated))
} catch (e: Exception) {
Log.e(TAG, "Failed to update schedule", e)
call.reject("Failed to update schedule: ${e.message}")
@@ -1995,7 +1796,8 @@ open class DailyNotificationPlugin : Plugin() {
val id = call.getString("id")
?: return@launch call.reject("Schedule ID is required")
getDatabase().scheduleDao().deleteById(id)
// Delegate to ScheduleHelper
ScheduleHelper.deleteSchedule(getDatabase(), id)
call.resolve()
} catch (e: Exception) {
Log.e(TAG, "Failed to delete schedule", e)
@@ -2013,7 +1815,8 @@ open class DailyNotificationPlugin : Plugin() {
val enabled = call.getBoolean("enabled") ?: true
getDatabase().scheduleDao().setEnabled(id, enabled)
// Delegate to ScheduleHelper
ScheduleHelper.enableSchedule(getDatabase(), id, enabled)
call.resolve()
} catch (e: Exception) {
Log.e(TAG, "Failed to enable/disable schedule", e)
@@ -2715,6 +2518,295 @@ open class DailyNotificationPlugin : Plugin() {
}
}
/**
* Helper object for test data operations
* Provides functions for injecting test data into the database
*/
object TestDataHelper {
/**
* Inject invalid schedule data for recovery testing
*
* @param database Database instance
* @param injectEmptyScheduleId Whether to inject schedule with empty ID
* @param injectNullNextRunAt Whether to inject schedule with null nextRunAt
* @return List of injected test data types
*/
suspend fun injectInvalidScheduleData(
database: DailyNotificationDatabase,
injectEmptyScheduleId: Boolean = true,
injectNullNextRunAt: Boolean = true
): List<String> {
val injected = mutableListOf<String>()
// Inject schedule with empty ID
if (injectEmptyScheduleId) {
try {
val invalidSchedule = Schedule(
id = "", // Empty ID - should be skipped by recovery
kind = "notify",
cron = "0 9 * * *",
clockTime = "09:00",
enabled = true,
nextRunAt = System.currentTimeMillis() + 86400000L
)
database.scheduleDao().upsert(invalidSchedule)
injected.add("empty_schedule_id")
} catch (e: Exception) {
// Log but continue - may fail due to constraints
}
}
// Inject schedule with null nextRunAt
if (injectNullNextRunAt) {
try {
val invalidSchedule = Schedule(
id = "test_null_nextrunat",
kind = "notify",
cron = "0 9 * * *",
clockTime = "09:00",
enabled = true,
nextRunAt = null // Null nextRunAt - should be skipped by recovery
)
database.scheduleDao().upsert(invalidSchedule)
injected.add("null_nextrunat")
} catch (e: Exception) {
// Log but continue
}
}
return injected
}
/**
* Inject invalid notification data for recovery testing
*
* @param database Database instance
* @return true if injection succeeded, false otherwise
*/
suspend fun injectInvalidNotificationData(database: DailyNotificationDatabase): Boolean {
return try {
val invalidNotification =
com.timesafari.dailynotification.entities.NotificationContentEntity()
invalidNotification.id = "" // Empty ID - should be skipped by recovery
invalidNotification.title = "Test Invalid Notification"
invalidNotification.body = "This has an empty ID"
invalidNotification.scheduledTime = System.currentTimeMillis() - 3600000L // 1 hour ago
invalidNotification.deliveryStatus = "pending"
invalidNotification.deliveryAttempts = 0
invalidNotification.lastDeliveryAttempt = 0
invalidNotification.userInteractionCount = 0
invalidNotification.lastUserInteraction = 0
invalidNotification.ttlSeconds = 86400L
invalidNotification.createdAt = System.currentTimeMillis()
invalidNotification.updatedAt = System.currentTimeMillis()
database.notificationContentDao().insertNotification(invalidNotification)
true
} catch (e: Exception) {
// Room's @NonNull constraint may prevent this - this is expected
false
}
}
}
/**
* Helper object for schedule operations
* Provides functions for managing schedules in the database
*/
object ScheduleHelper {
/**
* Disable all schedules of a specific kind
*
* @param database Database instance
* @param kind Schedule kind ("notify" or "fetch")
* @return Number of schedules disabled
*/
suspend fun disableAllSchedulesByKind(
database: DailyNotificationDatabase,
kind: String
): Int {
val schedules = database.scheduleDao().getByKind(kind)
val enabledSchedules = schedules.filter { it.enabled }
enabledSchedules.forEach { schedule ->
database.scheduleDao().setEnabled(schedule.id, false)
}
return enabledSchedules.size
}
/**
* Clean up existing notification schedules (cancel alarms and delete from database)
* Used to ensure "one per day" semantics for daily notifications
*
* @param context Application context
* @param database Database instance
* @param excludeScheduleId Schedule ID to exclude from cleanup (will be updated/created)
* @return Number of schedules cleaned up
*/
suspend fun cleanupExistingNotificationSchedules(
context: Context,
database: DailyNotificationDatabase,
excludeScheduleId: String? = null
): Int {
val existingSchedules = database.scheduleDao().getByKind("notify")
var cleanedCount = 0
existingSchedules.forEach { existingSchedule ->
try {
// Skip if this is the same schedule we're about to create (will be upserted anyway)
if (existingSchedule.id == excludeScheduleId) {
return@forEach
}
// Cancel the alarm in AlarmManager
NotifyReceiver.cancelNotification(context, existingSchedule.id)
// Delete from database
database.scheduleDao().deleteById(existingSchedule.id)
cleanedCount++
} catch (e: Exception) {
Log.e("ScheduleHelper", "Failed to cancel/delete existing schedule: ${existingSchedule.id}", e)
// Continue with other schedules - don't fail entire operation
}
}
return cleanedCount
}
/**
* Create a new schedule
*
* @param database Database instance
* @param schedule Schedule entity to create
* @return Created schedule
*/
suspend fun createSchedule(database: DailyNotificationDatabase, schedule: Schedule): Schedule {
database.scheduleDao().upsert(schedule)
return schedule
}
/**
* Update an existing schedule
*
* @param database Database instance
* @param id Schedule ID
* @param enabled Optional enabled flag
* @param cron Optional cron expression
* @param clockTime Optional clock time
* @param jitterMs Optional jitter milliseconds
* @param backoffPolicy Optional backoff policy
* @param stateJson Optional state JSON
* @param lastRunAt Optional last run time
* @param nextRunAt Optional next run time
* @return Updated schedule
*/
suspend fun updateSchedule(
database: DailyNotificationDatabase,
id: String,
enabled: Boolean? = null,
cron: String? = null,
clockTime: String? = null,
jitterMs: Int? = null,
backoffPolicy: String? = null,
stateJson: String? = null,
lastRunAt: Long? = null,
nextRunAt: Long? = null
): Schedule {
// Check if schedule exists
val existing = database.scheduleDao().getById(id)
?: throw IllegalArgumentException("Schedule not found: $id")
// Update fields
database.scheduleDao().update(
id = id,
enabled = enabled,
cron = cron,
clockTime = clockTime,
jitterMs = jitterMs,
backoffPolicy = backoffPolicy,
stateJson = stateJson
)
// Update run times if provided
if (lastRunAt != null || nextRunAt != null) {
database.scheduleDao().updateRunTimes(id, lastRunAt, nextRunAt)
}
return database.scheduleDao().getById(id)!!
}
/**
* Delete a schedule by ID
*
* @param database Database instance
* @param id Schedule ID to delete
*/
suspend fun deleteSchedule(database: DailyNotificationDatabase, id: String) {
database.scheduleDao().deleteById(id)
}
/**
* Enable or disable a schedule
*
* @param database Database instance
* @param id Schedule ID
* @param enabled Enabled flag
*/
suspend fun enableSchedule(database: DailyNotificationDatabase, id: String, enabled: Boolean) {
database.scheduleDao().setEnabled(id, enabled)
}
}
/**
* Helper object for callback operations
* Provides functions for managing callbacks in the database
*/
object CallbackHelper {
/**
* Register a new callback
*
* @param database Database instance
* @param callback Callback entity to register
* @return Created callback
*/
suspend fun registerCallback(database: DailyNotificationDatabase, callback: Callback): Callback {
database.callbackDao().upsert(callback)
return callback
}
/**
* Get callback by ID
*
* @param database Database instance
* @param id Callback ID
* @return Callback or null if not found
*/
suspend fun getCallback(database: DailyNotificationDatabase, id: String): Callback? {
return database.callbackDao().getById(id)
}
/**
* Get all callbacks
*
* @param database Database instance
* @return List of all callbacks
*/
suspend fun getAllCallbacks(database: DailyNotificationDatabase): List<Callback> {
return database.callbackDao().getAll()
}
/**
* Get enabled callbacks
*
* @param database Database instance
* @param enabled Enabled flag
* @return List of callbacks matching enabled status
*/
suspend fun getCallbacksByEnabled(database: DailyNotificationDatabase, enabled: Boolean): List<Callback> {
return database.callbackDao().getByEnabled(enabled)
}
}
/**
* Helper object for content cache operations
* Provides functions for accessing ContentCache from the database

View File

@@ -513,6 +513,23 @@ public class DailyNotificationScheduler {
return System.currentTimeMillis() + (24 * 60 * 60 * 1000); // 24 hours from now
}
/**
* Schedule a test alarm for testing purposes
*
* @param secondsFromNow Number of seconds from now to schedule the alarm
*/
public void testAlarm(int secondsFromNow) {
try {
Log.d(TAG, "Scheduling test alarm in " + secondsFromNow + " seconds");
// Delegate to NotifyReceiver.testAlarm()
com.timesafari.dailynotification.NotifyReceiver.Companion.testAlarm(context, secondsFromNow);
Log.i(TAG, "Test alarm scheduled successfully");
} catch (Exception e) {
Log.e(TAG, "Error scheduling test alarm", e);
throw new RuntimeException("Failed to schedule test alarm: " + e.getMessage(), e);
}
}
/**
* Get count of pending notifications
*

View File

@@ -57,20 +57,47 @@ public class PermissionManager {
* Request notification permissions from the user
*
* @param call Plugin call
* @param activity Activity for showing permission dialog (required for Android 13+)
*/
public void requestNotificationPermissions(PluginCall call) {
public void requestNotificationPermissions(PluginCall call, android.app.Activity activity) {
try {
Log.d(TAG, "Requesting notification permissions");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// For Android 13+, request POST_NOTIFICATIONS permission
requestPermission(Manifest.permission.POST_NOTIFICATIONS, call);
if (activity == null) {
call.reject("Activity not available - required for permission request");
return;
}
// Check if already granted
if (androidx.core.content.ContextCompat.checkSelfPermission(context,
android.Manifest.permission.POST_NOTIFICATIONS)
== android.content.pm.PackageManager.PERMISSION_GRANTED) {
// Already granted
JSObject result = new JSObject();
result.put("status", "granted");
result.put("granted", true);
result.put("notifications", "granted");
call.resolve(result);
} else {
// Request permission - activity must handle result via handleRequestPermissionsResult
// Note: The plugin should save the call before calling this method
androidx.core.app.ActivityCompat.requestPermissions(
activity,
new String[]{android.Manifest.permission.POST_NOTIFICATIONS},
1001 // PERMISSION_REQUEST_CODE - matches DailyNotificationPlugin.PERMISSION_REQUEST_CODE
);
Log.d(TAG, "Permission dialog shown, waiting for user response");
// Don't resolve here - wait for handleRequestPermissionsResult in plugin
}
} else {
// For older versions, permissions are granted at install time
JSObject result = new JSObject();
result.put("success", true);
result.put("status", "granted");
result.put("granted", true);
result.put("message", "Notifications enabled (pre-Android 13)");
result.put("notifications", "granted");
call.resolve(result);
}
@@ -80,6 +107,17 @@ public class PermissionManager {
}
}
/**
* Request notification permissions from the user (backward compatibility - requires activity)
*
* @param call Plugin call
*/
public void requestNotificationPermissions(PluginCall call) {
// This version cannot actually request permissions without activity
// It will only check if already granted
requestPermission(Manifest.permission.POST_NOTIFICATIONS, call);
}
/**
* Check the current status of notification permissions
*
@@ -124,6 +162,157 @@ public class PermissionManager {
}
}
/**
* Check exact alarm permission status
* Returns detailed information about permission status and whether it can be requested
*
* @param call Plugin call
*/
public void checkExactAlarmPermission(PluginCall call) {
try {
Log.d(TAG, "Checking exact alarm permission");
boolean canSchedule = false;
boolean canRequest = false;
boolean required = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S;
if (required) {
// Check if exact alarms can be scheduled
android.app.AlarmManager alarmManager = (android.app.AlarmManager)
context.getSystemService(Context.ALARM_SERVICE);
canSchedule = alarmManager != null && alarmManager.canScheduleExactAlarms();
// Check if permission can be requested (Android 13+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// Try reflection to call Settings.canRequestScheduleExactAlarms()
try {
java.lang.reflect.Method method = Settings.class.getMethod(
"canRequestScheduleExactAlarms",
Context.class
);
canRequest = (Boolean) method.invoke(null, context);
} catch (Exception e) {
// Fallback heuristic: if exact alarms are not currently allowed,
// assume we can request them (safe default)
canRequest = !canSchedule;
}
} else {
// Android 12 (API 31-32) - permission can always be requested
canRequest = true;
}
} else {
// Android 11 and below - permission not needed
canSchedule = true;
canRequest = true;
}
JSObject result = new JSObject();
result.put("canSchedule", canSchedule);
result.put("canRequest", canRequest);
result.put("required", required);
call.resolve(result);
} catch (Exception e) {
Log.e(TAG, "Error checking exact alarm permission", e);
call.reject("Permission check failed: " + e.getMessage());
}
}
/**
* Request exact alarm permission
* Opens Settings intent to let user grant the permission
*
* @param call Plugin call
*/
public void requestExactAlarmPermission(PluginCall call) {
try {
Log.d(TAG, "Requesting exact alarm permission");
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
// Android 11 and below don't need this permission
JSObject result = new JSObject();
result.put("success", true);
result.put("message", "Exact alarm permission not required on this Android version");
call.resolve(result);
return;
}
// Check if permission is already granted
android.app.AlarmManager alarmManager = (android.app.AlarmManager)
context.getSystemService(Context.ALARM_SERVICE);
boolean canSchedule = alarmManager != null && alarmManager.canScheduleExactAlarms();
if (canSchedule) {
// Permission already granted
JSObject result = new JSObject();
result.put("success", true);
result.put("message", "Exact alarm permission already granted");
call.resolve(result);
return;
}
// Check if app can request the permission (Android 13+)
boolean canRequest = false;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// Try reflection to call Settings.canRequestScheduleExactAlarms()
try {
java.lang.reflect.Method method = Settings.class.getMethod(
"canRequestScheduleExactAlarms",
Context.class
);
canRequest = (Boolean) method.invoke(null, context);
} catch (Exception e) {
// Fallback heuristic: if exact alarms are not currently allowed,
// assume we can request them (safe default)
canRequest = !canSchedule;
}
} else {
// Android 12 (API 31-32) - permission can always be requested
canRequest = true;
}
if (canRequest) {
// Open Settings to let user grant permission
try {
Intent intent = new Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM);
intent.setData(android.net.Uri.parse("package:" + context.getPackageName()));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
JSObject result = new JSObject();
result.put("success", true);
result.put("message", "Please grant 'Alarms & reminders' permission in Settings");
call.resolve(result);
} catch (Exception e) {
Log.e(TAG, "Failed to open exact alarm settings", e);
call.reject("Failed to open exact alarm settings: " + e.getMessage());
}
} else {
// User has already denied or permission is permanently denied
// Direct user to app settings
try {
Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
intent.setData(android.net.Uri.parse("package:" + context.getPackageName()));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
call.reject(
"Permission denied. Please enable 'Alarms & reminders' in app settings.",
"PERMISSION_DENIED"
);
} catch (Exception e) {
Log.e(TAG, "Failed to open app settings", e);
call.reject("Failed to open app settings: " + e.getMessage());
}
}
} catch (Exception e) {
Log.e(TAG, "Error requesting exact alarm permission", e);
call.reject("Permission request failed: " + e.getMessage());
}
}
/**
* Open exact alarm settings for the user
*