feat(android): implement P0 channel guard system for blocked notifications
- Add ChannelManager class for notification channel management - Implement channel existence checking and creation - Add channel importance validation (guards against IMPORTANCE_NONE) - Add deep link to channel settings when notifications are blocked - Integrate channel manager into plugin load() method - Add new plugin methods: isChannelEnabled(), openChannelSettings(), checkStatus() - Add comprehensive status checking including permissions and channel state - Add test app UI for channel management testing P0 Priority Implementation: - Guards against channel = NONE (blocked notifications) - Provides actionable error messages with deep links to settings - Ensures notifications can actually be delivered - Comprehensive status checking for all notification requirements This addresses the critical issue where notifications are scheduled but silently dropped due to blocked notification channels.
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
package com.timesafari.dailynotification;
|
||||
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.provider.Settings;
|
||||
import android.util.Log;
|
||||
|
||||
/**
|
||||
* Manages notification channels and ensures they are properly configured
|
||||
* for reliable notification delivery.
|
||||
*
|
||||
* Handles channel creation, importance checking, and provides deep links
|
||||
* to channel settings when notifications are blocked.
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.0
|
||||
*/
|
||||
public class ChannelManager {
|
||||
private static final String TAG = "ChannelManager";
|
||||
private static final String DEFAULT_CHANNEL_ID = "daily_default";
|
||||
private static final String DEFAULT_CHANNEL_NAME = "Daily Notifications";
|
||||
private static final String DEFAULT_CHANNEL_DESCRIPTION = "Daily notifications from TimeSafari";
|
||||
|
||||
private final Context context;
|
||||
private final NotificationManager notificationManager;
|
||||
|
||||
public ChannelManager(Context context) {
|
||||
this.context = context;
|
||||
this.notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the default notification channel exists and is properly configured.
|
||||
* Creates the channel if it doesn't exist.
|
||||
*
|
||||
* @return true if channel is ready for notifications, false if blocked
|
||||
*/
|
||||
public boolean ensureChannelExists() {
|
||||
try {
|
||||
Log.d(TAG, "Ensuring notification channel exists");
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel channel = notificationManager.getNotificationChannel(DEFAULT_CHANNEL_ID);
|
||||
|
||||
if (channel == null) {
|
||||
Log.d(TAG, "Creating notification channel");
|
||||
createDefaultChannel();
|
||||
return true;
|
||||
} else {
|
||||
Log.d(TAG, "Channel exists with importance: " + channel.getImportance());
|
||||
return channel.getImportance() != NotificationManager.IMPORTANCE_NONE;
|
||||
}
|
||||
} else {
|
||||
// Pre-Oreo: channels don't exist, always ready
|
||||
Log.d(TAG, "Pre-Oreo device, channels not applicable");
|
||||
return true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error ensuring channel exists", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the notification channel is enabled and can deliver notifications.
|
||||
*
|
||||
* @return true if channel is enabled, false if blocked
|
||||
*/
|
||||
public boolean isChannelEnabled() {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel channel = notificationManager.getNotificationChannel(DEFAULT_CHANNEL_ID);
|
||||
if (channel == null) {
|
||||
Log.w(TAG, "Channel does not exist");
|
||||
return false;
|
||||
}
|
||||
|
||||
int importance = channel.getImportance();
|
||||
Log.d(TAG, "Channel importance: " + importance);
|
||||
return importance != NotificationManager.IMPORTANCE_NONE;
|
||||
} else {
|
||||
// Pre-Oreo: always enabled
|
||||
return true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error checking channel status", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current channel importance level.
|
||||
*
|
||||
* @return importance level, or -1 if channel doesn't exist
|
||||
*/
|
||||
public int getChannelImportance() {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel channel = notificationManager.getNotificationChannel(DEFAULT_CHANNEL_ID);
|
||||
if (channel != null) {
|
||||
return channel.getImportance();
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error getting channel importance", e);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the notification channel settings for the user to enable notifications.
|
||||
*
|
||||
* @return true if settings intent was launched, false otherwise
|
||||
*/
|
||||
public boolean openChannelSettings() {
|
||||
try {
|
||||
Log.d(TAG, "Opening channel settings");
|
||||
|
||||
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);
|
||||
|
||||
context.startActivity(intent);
|
||||
Log.d(TAG, "Channel settings opened");
|
||||
return true;
|
||||
} else {
|
||||
Log.d(TAG, "Channel settings not available on pre-Oreo");
|
||||
return false;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error opening channel settings", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the default notification channel with high importance.
|
||||
*/
|
||||
private void createDefaultChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel channel = new NotificationChannel(
|
||||
DEFAULT_CHANNEL_ID,
|
||||
DEFAULT_CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
);
|
||||
channel.setDescription(DEFAULT_CHANNEL_DESCRIPTION);
|
||||
channel.enableLights(true);
|
||||
channel.enableVibration(true);
|
||||
channel.setShowBadge(true);
|
||||
|
||||
notificationManager.createNotificationChannel(channel);
|
||||
Log.d(TAG, "Default channel created with HIGH importance");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the default channel ID for use in notifications.
|
||||
*
|
||||
* @return the default channel ID
|
||||
*/
|
||||
public String getDefaultChannelId() {
|
||||
return DEFAULT_CHANNEL_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs the current channel status for debugging.
|
||||
*/
|
||||
public void logChannelStatus() {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel channel = notificationManager.getNotificationChannel(DEFAULT_CHANNEL_ID);
|
||||
if (channel != null) {
|
||||
Log.i(TAG, "Channel Status - ID: " + channel.getId() +
|
||||
", Importance: " + channel.getImportance() +
|
||||
", Enabled: " + (channel.getImportance() != NotificationManager.IMPORTANCE_NONE));
|
||||
} else {
|
||||
Log.w(TAG, "Channel does not exist");
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "Pre-Oreo device, channels not applicable");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error logging channel status", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,7 @@ public class DailyNotificationPlugin extends Plugin {
|
||||
private DailyNotificationStorage storage;
|
||||
private DailyNotificationScheduler scheduler;
|
||||
private DailyNotificationFetcher fetcher;
|
||||
private ChannelManager channelManager;
|
||||
|
||||
// SQLite database components
|
||||
private DailyNotificationDatabase database;
|
||||
@@ -119,6 +120,13 @@ public class DailyNotificationPlugin extends Plugin {
|
||||
storage = new DailyNotificationStorage(getContext());
|
||||
scheduler = new DailyNotificationScheduler(getContext(), alarmManager);
|
||||
fetcher = new DailyNotificationFetcher(getContext(), storage);
|
||||
channelManager = new ChannelManager(getContext());
|
||||
|
||||
// Ensure notification channel exists and is properly configured
|
||||
if (!channelManager.ensureChannelExists()) {
|
||||
Log.w(TAG, "Notification channel is blocked - notifications will not appear");
|
||||
channelManager.logChannelStatus();
|
||||
}
|
||||
|
||||
// Check if recovery is needed (app startup recovery)
|
||||
checkAndPerformRecovery();
|
||||
@@ -1081,6 +1089,129 @@ public class DailyNotificationPlugin extends Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if notification channel is enabled
|
||||
*
|
||||
* @param call Plugin call
|
||||
*/
|
||||
@PluginMethod
|
||||
public void isChannelEnabled(PluginCall call) {
|
||||
try {
|
||||
Log.d(TAG, "Checking channel status");
|
||||
ensureStorageInitialized();
|
||||
|
||||
boolean enabled = channelManager.isChannelEnabled();
|
||||
int importance = channelManager.getChannelImportance();
|
||||
|
||||
JSObject result = new JSObject();
|
||||
result.put("enabled", enabled);
|
||||
result.put("importance", importance);
|
||||
result.put("channelId", channelManager.getDefaultChannelId());
|
||||
|
||||
Log.d(TAG, "Channel status - enabled: " + enabled + ", importance: " + importance);
|
||||
call.resolve(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error checking channel status", e);
|
||||
call.reject("Error checking channel status: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open notification channel settings
|
||||
*
|
||||
* @param call Plugin call
|
||||
*/
|
||||
@PluginMethod
|
||||
public void openChannelSettings(PluginCall call) {
|
||||
try {
|
||||
Log.d(TAG, "Opening channel settings");
|
||||
|
||||
boolean opened = channelManager.openChannelSettings();
|
||||
|
||||
JSObject result = new JSObject();
|
||||
result.put("opened", opened);
|
||||
|
||||
if (opened) {
|
||||
Log.d(TAG, "Channel settings opened successfully");
|
||||
} else {
|
||||
Log.w(TAG, "Could not open channel settings");
|
||||
}
|
||||
|
||||
call.resolve(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error opening channel settings", e);
|
||||
call.reject("Error opening channel settings: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive notification status including permissions and channel
|
||||
*
|
||||
* @param call Plugin call
|
||||
*/
|
||||
@PluginMethod
|
||||
public void checkStatus(PluginCall call) {
|
||||
try {
|
||||
Log.d(TAG, "Checking comprehensive notification status");
|
||||
ensureStorageInitialized();
|
||||
|
||||
// Check POST_NOTIFICATIONS permission
|
||||
boolean postNotificationsGranted = false;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
postNotificationsGranted = getContext().checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
== PackageManager.PERMISSION_GRANTED;
|
||||
} else {
|
||||
postNotificationsGranted = true; // Pre-Android 13, always granted
|
||||
}
|
||||
|
||||
// Check channel status
|
||||
boolean channelEnabled = channelManager.isChannelEnabled();
|
||||
int channelImportance = channelManager.getChannelImportance();
|
||||
|
||||
// Check exact alarm permission
|
||||
boolean exactAlarmsGranted = false;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
exactAlarmsGranted = alarmManager.canScheduleExactAlarms();
|
||||
} else {
|
||||
exactAlarmsGranted = true; // Pre-Android 12, always granted
|
||||
}
|
||||
|
||||
// Check if we can schedule now
|
||||
boolean canScheduleNow = postNotificationsGranted && channelEnabled && exactAlarmsGranted;
|
||||
|
||||
// Get next scheduled notification time (if any)
|
||||
long nextScheduledAt = -1;
|
||||
try {
|
||||
// This would need to be implemented to check actual scheduled alarms
|
||||
// For now, return -1 to indicate unknown
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Could not determine next scheduled time", e);
|
||||
}
|
||||
|
||||
JSObject result = new JSObject();
|
||||
result.put("postNotificationsGranted", postNotificationsGranted);
|
||||
result.put("channelEnabled", channelEnabled);
|
||||
result.put("channelImportance", channelImportance);
|
||||
result.put("exactAlarmsGranted", exactAlarmsGranted);
|
||||
result.put("canScheduleNow", canScheduleNow);
|
||||
result.put("nextScheduledAt", nextScheduledAt);
|
||||
result.put("channelId", channelManager.getDefaultChannelId());
|
||||
|
||||
Log.i(TAG, "Status check - canSchedule: " + canScheduleNow +
|
||||
", postNotifications: " + postNotificationsGranted +
|
||||
", channelEnabled: " + channelEnabled +
|
||||
", exactAlarms: " + exactAlarmsGranted);
|
||||
|
||||
call.resolve(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error checking status", e);
|
||||
call.reject("Error checking status: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintain rolling window (for testing or manual triggers)
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user