Browse Source

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.
pull/214/head
Matthew Raymer 2 days ago
parent
commit
5528c44f2b
  1. 47
      android/app/src/main/java/app/timesafari/TimeSafariApplication.java
  2. 109
      docs/directives/fix-notification-dismiss-cancel.mdc
  3. 13
      src/services/platforms/CapacitorPlatformService.ts
  4. 28
      src/views/AccountViewView.vue

47
android/app/src/main/java/app/timesafari/TimeSafariApplication.java

@ -11,7 +11,10 @@
package app.timesafari; package app.timesafari;
import android.app.Application; import android.app.Application;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Context; import android.content.Context;
import android.os.Build;
import android.util.Log; import android.util.Log;
import com.timesafari.dailynotification.DailyNotificationPlugin; import com.timesafari.dailynotification.DailyNotificationPlugin;
import com.timesafari.dailynotification.NativeNotificationContentFetcher; import com.timesafari.dailynotification.NativeNotificationContentFetcher;
@ -39,6 +42,9 @@ public class TimeSafariApplication extends Application {
Log.i(TAG, "Initializing TimeSafari 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 // Register native fetcher with application context
Context context = getApplicationContext(); Context context = getApplicationContext();
NativeNotificationContentFetcher nativeFetcher = NativeNotificationContentFetcher nativeFetcher =
@ -67,5 +73,46 @@ public class TimeSafariApplication extends Application {
Log.i(TAG, "Native fetcher registered: " + nativeFetcher.getClass().getName()); 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);
}
}
} }

109
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)

13
src/services/platforms/CapacitorPlatformService.ts

@ -1693,10 +1693,21 @@ export class CapacitorPlatformService
// Step 3: Get API server from config or use default // Step 3: Get API server from config or use default
// This ensures the plugin knows where to fetch content from // This ensures the plugin knows where to fetch content from
const apiServer = let apiServer =
config.apiServer || config.apiServer ||
(await import("../../constants/app")).DEFAULT_ENDORSER_API_SERVER; (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 // Step 4: Configure plugin with credentials
// Plugin stores these in its Room database for background workers // Plugin stores these in its Room database for background workers
await DailyNotification.configureNativeFetcher({ await DailyNotification.configureNativeFetcher({

28
src/views/AccountViewView.vue

@ -825,6 +825,7 @@ import {
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin"; import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView"; import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder"; import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import { import {
@ -1547,6 +1548,33 @@ export default class AccountViewView extends Vue {
settingsSaved: true, settingsSaved: true,
timestamp: new Date().toISOString(), 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<void> { async onClickSavePartnerServer(): Promise<void> {

Loading…
Cancel
Save