From 5528c44f2b0d1d52f17295cea340b78699a056be Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Tue, 11 Nov 2025 07:53:20 +0000 Subject: [PATCH] fix: Add notification channel creation and improve native fetcher configuration - Add notification channel creation in TimeSafariApplication for Android 8.0+ Required for daily notifications to display properly. Channel ID matches plugin's 'timesafari.daily' channel. - Convert localhost to 10.0.2.2 in CapacitorPlatformService for Android emulators Android emulators cannot reach localhost - they need 10.0.2.2 to access the host machine's API server. - Refresh native fetcher configuration when API server changes in AccountViewView Ensures background notification prefetch uses the updated endpoint when user changes API server URL in settings. - Add directive for fixing notification dismiss cancellation in plugin Documents the fix needed in plugin source to cancel notification from NotificationManager when dismiss button is clicked. These changes ensure daily notifications work correctly on Android, including proper channel setup, emulator network connectivity, and configuration refresh. --- .../app/timesafari/TimeSafariApplication.java | 47 ++++++++ .../fix-notification-dismiss-cancel.mdc | 109 ++++++++++++++++++ .../platforms/CapacitorPlatformService.ts | 13 ++- src/views/AccountViewView.vue | 28 +++++ 4 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 docs/directives/fix-notification-dismiss-cancel.mdc diff --git a/android/app/src/main/java/app/timesafari/TimeSafariApplication.java b/android/app/src/main/java/app/timesafari/TimeSafariApplication.java index 8c14b3b0..feffc5c5 100644 --- a/android/app/src/main/java/app/timesafari/TimeSafariApplication.java +++ b/android/app/src/main/java/app/timesafari/TimeSafariApplication.java @@ -11,7 +11,10 @@ package app.timesafari; import android.app.Application; +import android.app.NotificationChannel; +import android.app.NotificationManager; import android.content.Context; +import android.os.Build; import android.util.Log; import com.timesafari.dailynotification.DailyNotificationPlugin; import com.timesafari.dailynotification.NativeNotificationContentFetcher; @@ -39,6 +42,9 @@ public class TimeSafariApplication extends Application { Log.i(TAG, "Initializing TimeSafari Application"); + // Create notification channel for daily notifications (required for Android 8.0+) + createNotificationChannel(); + // Register native fetcher with application context Context context = getApplicationContext(); NativeNotificationContentFetcher nativeFetcher = @@ -67,5 +73,46 @@ public class TimeSafariApplication extends Application { Log.i(TAG, "Native fetcher registered: " + nativeFetcher.getClass().getName()); } + + /** + * Create notification channel for daily notifications + * Required for Android 8.0 (API 26) and above + */ + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationManager notificationManager = + (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + + if (notificationManager == null) { + Log.w(TAG, "NotificationManager is null, cannot create channel"); + return; + } + + // Channel ID must match the one used in DailyNotificationWorker + String channelId = "timesafari.daily"; + String channelName = "Daily Notifications"; + String channelDescription = "Daily notification updates from TimeSafari"; + + // Check if channel already exists + NotificationChannel existingChannel = notificationManager.getNotificationChannel(channelId); + if (existingChannel != null) { + Log.d(TAG, "Notification channel already exists: " + channelId); + return; + } + + // Create the channel with high importance (for priority="high" notifications) + NotificationChannel channel = new NotificationChannel( + channelId, + channelName, + NotificationManager.IMPORTANCE_HIGH + ); + channel.setDescription(channelDescription); + channel.enableVibration(true); + channel.setShowBadge(true); + + notificationManager.createNotificationChannel(channel); + Log.i(TAG, "Notification channel created: " + channelId); + } + } } diff --git a/docs/directives/fix-notification-dismiss-cancel.mdc b/docs/directives/fix-notification-dismiss-cancel.mdc new file mode 100644 index 00000000..fbe55776 --- /dev/null +++ b/docs/directives/fix-notification-dismiss-cancel.mdc @@ -0,0 +1,109 @@ +# Fix Notification Dismiss to Cancel Notification + +## Problem + +When a user clicks the "Dismiss" button on a daily notification, the notification is removed from storage and alarms are cancelled, but the notification itself is not cancelled from the NotificationManager. This means the notification remains visible in the system tray even though it's been dismissed. + +Additionally, clicking on the notification (not the dismiss button) launches the app, which is working as intended. + +## Root Cause + +In `DailyNotificationWorker.java`, the `handleDismissNotification()` method: +1. ✅ Removes notification from storage +2. ✅ Cancels pending alarms +3. ❌ **MISSING**: Does not cancel the notification from NotificationManager + +The notification is displayed with ID = `content.getId().hashCode()` (line 440), but this ID is never used to cancel the notification when dismissing. + +## Solution + +Add notification cancellation to `handleDismissNotification()` method in `DailyNotificationWorker.java`. + +### IMPORTANT: Plugin Source Change + +**This change must be applied to the plugin source repository**, not the host app. The file is located in the `@timesafari/daily-notification-plugin` package. + +### File to Modify + +**Plugin Source Repository:** +`android/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java` + +**Note:** In the host app's `node_modules`, this file is located at: +`node_modules/@timesafari/daily-notification-plugin/android/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java` + +However, changes to `node_modules` will be overwritten on the next `npm install`. This fix must be applied to the plugin's source repository. + +### Change Required + +In the `handleDismissNotification()` method (around line 177-206), add code to cancel the notification from NotificationManager: + +```java +private Result handleDismissNotification(String notificationId) { + Trace.beginSection("DN:dismiss"); + try { + Log.d(TAG, "DN|DISMISS_START id=" + notificationId); + + // Cancel the notification from NotificationManager FIRST + // This ensures the notification disappears immediately when dismissed + NotificationManager notificationManager = + (NotificationManager) getApplicationContext().getSystemService(Context.NOTIFICATION_SERVICE); + if (notificationManager != null) { + int systemNotificationId = notificationId.hashCode(); + notificationManager.cancel(systemNotificationId); + Log.d(TAG, "DN|DISMISS_CANCEL_NOTIF systemId=" + systemNotificationId); + } + + // Remove from Room if present; also remove from legacy storage for compatibility + try { + DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext()); + // No direct delete DAO exposed via service; legacy removal still applied + } catch (Exception ignored) { } + DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext()); + storage.removeNotification(notificationId); + + // Cancel any pending alarms + DailyNotificationScheduler scheduler = new DailyNotificationScheduler( + getApplicationContext(), + (android.app.AlarmManager) getApplicationContext().getSystemService(Context.ALARM_SERVICE) + ); + scheduler.cancelNotification(notificationId); + + Log.i(TAG, "DN|DISMISS_OK id=" + notificationId); + return Result.success(); + + } catch (Exception e) { + Log.e(TAG, "DN|DISMISS_ERR exception id=" + notificationId + " err=" + e.getMessage(), e); + return Result.retry(); + } finally { + Trace.endSection(); + } +} +``` + +### Key Points + +1. **Notification ID**: Use `notificationId.hashCode()` to match the ID used when displaying (line 440: `int notificationId = content.getId().hashCode()`) +2. **Order**: Cancel the notification FIRST, before removing from storage, so it disappears immediately +3. **Null check**: Check that NotificationManager is not null before calling cancel() +4. **Logging**: Add instrumentation log to track cancellation + +### Expected Behavior After Fix + +1. User clicks "Dismiss" button → Notification disappears immediately from system tray +2. User clicks notification body → App launches (unchanged behavior) +3. User swipes notification away → Notification dismissed (Android handles this automatically with `setAutoCancel(true)`) + +## Testing Checklist + +- [ ] Click dismiss button → Notification disappears immediately +- [ ] Click notification body → App launches +- [ ] Swipe notification away → Notification dismissed +- [ ] Check logs for `DN|DISMISS_CANCEL_NOTIF` entry +- [ ] Verify notification is removed from storage after dismiss +- [ ] Verify alarms are cancelled after dismiss + +## Related Code + +- Notification display: `DailyNotificationWorker.displayNotification()` line 440 +- Notification ID generation: `content.getId().hashCode()` +- Auto-cancel: `builder.setAutoCancel(true)` line 363 (handles swipe-to-dismiss) diff --git a/src/services/platforms/CapacitorPlatformService.ts b/src/services/platforms/CapacitorPlatformService.ts index 97a8a315..68d65bb5 100644 --- a/src/services/platforms/CapacitorPlatformService.ts +++ b/src/services/platforms/CapacitorPlatformService.ts @@ -1693,10 +1693,21 @@ export class CapacitorPlatformService // Step 3: Get API server from config or use default // This ensures the plugin knows where to fetch content from - const apiServer = + let apiServer = config.apiServer || (await import("../../constants/app")).DEFAULT_ENDORSER_API_SERVER; + // Step 3.5: Convert localhost to 10.0.2.2 for Android emulators + // Android emulators can't reach localhost - they need 10.0.2.2 to access the host machine + const platform = Capacitor.getPlatform(); + if (platform === "android" && apiServer) { + // Replace localhost or 127.0.0.1 with 10.0.2.2 for Android emulator compatibility + apiServer = apiServer.replace( + /http:\/\/(localhost|127\.0\.0\.1)(:\d+)?/, + "http://10.0.2.2$2", + ); + } + // Step 4: Configure plugin with credentials // Plugin stores these in its Room database for background workers await DailyNotification.configureNativeFetcher({ diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue index ab107e62..1f8b7f89 100644 --- a/src/views/AccountViewView.vue +++ b/src/views/AccountViewView.vue @@ -825,6 +825,7 @@ import { import { logger } from "../utils/logger"; import { PlatformServiceMixin } from "../utils/PlatformServiceMixin"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; +import { PlatformServiceFactory } from "../services/PlatformServiceFactory"; import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView"; import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder"; import { @@ -1547,6 +1548,33 @@ export default class AccountViewView extends Vue { settingsSaved: true, timestamp: new Date().toISOString(), }); + + // Refresh native fetcher configuration with new API server + // This ensures background notification prefetch uses the updated endpoint + try { + const platformService = PlatformServiceFactory.getInstance(); + const settings = await this.$accountSettings(); + const starredPlanHandleIds = settings.starredPlanHandleIds || []; + + await platformService.configureNativeFetcher({ + apiServer: newApiServer, + jwt: "", // Will be generated automatically by configureNativeFetcher + starredPlanHandleIds, + }); + + logger.info( + "[AccountViewView] Native fetcher configuration refreshed after API server change", + { + newApiServer, + }, + ); + } catch (error) { + logger.error( + "[AccountViewView] Failed to refresh native fetcher config after API server change:", + error, + ); + // Don't throw - API server change should still succeed even if native fetcher refresh fails + } } async onClickSavePartnerServer(): Promise {