Browse Source

feat(android): add fetch scheduling debug logs and triggerImmediateFetch API

- Add DN|SCHEDULE_CALLBACK logs to diagnose fetch scheduling
- Add DN|SCHEDULE_FETCH_* structured logs for traceability
- Add triggerImmediateFetch() public API for standalone fetches
- Update fetch timing from 1 hour to 5 minutes before notification
- Fix TypeScript lint errors: add return types, replace any types
- Fix ESLint warnings: add console suppression comments
- Fix capacitor.settings.gradle plugin path reference
- Update android-app-improvement-plan.md with current state

Changes:
- DailyNotificationPlugin: Added scheduled callback logging and fetch method
- DailyNotificationFetcher: Changed lead time from 1 hour to 5 minutes
- EnhancedDailyNotificationFetcher: Added ENH|* structured event IDs
- TypeScript services: Fixed lint errors and added proper types
- Test app: Fixed capacitor settings path and TypeScript warnings
master
Matthew Raymer 5 days ago
parent
commit
66987093f7
  1. 4
      android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationFetcher.java
  2. 85
      android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java
  3. 77
      android/plugin/src/main/java/com/timesafari/dailynotification/EnhancedDailyNotificationFetcher.java
  4. 157
      docs/android-app-improvement-plan.md
  5. 15
      src/definitions.ts
  6. 36
      src/services/NotificationPermissionManager.ts
  7. 31
      src/services/NotificationValidationService.ts
  8. 2
      test-apps/daily-notification-test/android/capacitor.settings.gradle
  9. 4
      test-apps/daily-notification-test/src/config/test-user-zero.ts
  10. 22
      test-apps/daily-notification-test/src/lib/diagnostics-export.ts
  11. 4
      test-apps/daily-notification-test/src/lib/error-handling.ts
  12. 14
      test-apps/daily-notification-test/src/lib/schema-validation.ts
  13. 20
      test-apps/daily-notification-test/src/router/index.ts
  14. 2
      test-apps/daily-notification-test/src/stores/counter.ts

4
android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationFetcher.java

@ -78,8 +78,8 @@ public class DailyNotificationFetcher {
try {
Log.d(TAG, "Scheduling background fetch for " + scheduledTime);
// Calculate fetch time (1 hour before notification)
long fetchTime = scheduledTime - TimeUnit.HOURS.toMillis(1);
// Calculate fetch time (5 minutes before notification)
long fetchTime = scheduledTime - TimeUnit.MINUTES.toMillis(5);
if (fetchTime > System.currentTimeMillis()) {
// Create work data

85
android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java

@ -653,6 +653,7 @@ public class DailyNotificationPlugin extends Plugin {
boolean scheduled = scheduler.scheduleNotification(content);
if (scheduled) {
Log.i(TAG, "DN|SCHEDULE_CALLBACK scheduled=true, calling scheduleBackgroundFetch");
// Schedule background fetch for next day
scheduleBackgroundFetch(content.getScheduledTime());
@ -662,6 +663,7 @@ public class DailyNotificationPlugin extends Plugin {
Log.i(TAG, "Daily notification scheduled successfully for " + time);
call.resolve();
} else {
Log.w(TAG, "DN|SCHEDULE_CALLBACK scheduled=false, NOT calling scheduleBackgroundFetch");
call.reject("Failed to schedule notification");
}
@ -958,15 +960,20 @@ public class DailyNotificationPlugin extends Plugin {
*/
private void scheduleBackgroundFetch(long scheduledTime) {
try {
// Schedule fetch 1 hour before notification
long fetchTime = scheduledTime - TimeUnit.HOURS.toMillis(1);
Log.d(TAG, "DN|SCHEDULE_FETCH_START time=" + scheduledTime);
// Schedule fetch 5 minutes before notification
long fetchTime = scheduledTime - TimeUnit.MINUTES.toMillis(5);
if (fetchTime > System.currentTimeMillis()) {
Log.d(TAG, "DN|SCHEDULE_FETCH_CALC fetch_at=" + fetchTime + " notification_at=" + scheduledTime);
fetcher.scheduleFetch(fetchTime);
Log.d(TAG, "Background fetch scheduled for " + fetchTime);
Log.i(TAG, "DN|SCHEDULE_FETCH_OK Background fetch scheduled for " + fetchTime + " (5 minutes before notification at " + scheduledTime + ")");
} else {
Log.w(TAG, "DN|SCHEDULE_FETCH_PAST fetch_time=" + fetchTime + " current=" + System.currentTimeMillis());
}
} catch (Exception e) {
Log.e(TAG, "Error scheduling background fetch", e);
Log.e(TAG, "DN|SCHEDULE_FETCH_ERR Error scheduling background fetch", e);
}
}
@ -1153,8 +1160,38 @@ public class DailyNotificationPlugin extends Plugin {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Log.d(TAG, "DEBUG: Android 13+ detected, requesting POST_NOTIFICATIONS permission");
// Store call for manual checking since Capacitor doesn't callback on denial
final PluginCall savedCall = call;
// Request POST_NOTIFICATIONS permission for Android 13+
requestPermissionForAlias("notifications", call, "onPermissionResult");
// Manually check result after a short delay to handle denied permissions
getActivity().runOnUiThread(new Runnable() {
@Override
public void run() {
try {
// Wait for permission dialog to close
Thread.sleep(500);
// Check permission status manually
boolean permissionGranted = getContext().checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED;
Log.d(TAG, "DEBUG: Manual permission check: " + permissionGranted);
if (!permissionGranted) {
// Permission was denied, respond to the call
Log.w(TAG, "Notification permission denied by user");
savedCall.reject("Notification permission denied by user");
}
// If granted, the callback will handle it
} catch (InterruptedException e) {
Log.e(TAG, "Manual permission check interrupted", e);
}
}
});
} else {
Log.d(TAG, "DEBUG: Pre-Android 13, checking notification manager");
// For older versions, check if notifications are enabled
@ -1461,6 +1498,46 @@ public class DailyNotificationPlugin extends Plugin {
}
}
/**
* Trigger an immediate standalone fetch for content updates
*
* This method allows manual triggering of content fetches independently of
* scheduled notifications. Useful for on-demand content refresh, cache warming,
* or background sync operations.
*
* @param call Plugin call
*/
@PluginMethod
public void triggerImmediateFetch(PluginCall call) {
try {
Log.d(TAG, "Manual standalone fetch triggered");
// Ensure storage is initialized
ensureStorageInitialized();
// Ensure fetcher is initialized
if (fetcher == null) {
Log.w(TAG, "DN|FETCHER_NULL initializing_fetcher");
fetcher = new DailyNotificationFetcher(getContext(), storage, roomStorage);
}
// Trigger immediate fetch via WorkManager
fetcher.scheduleImmediateFetch();
Log.i(TAG, "DN|STANDALONE_FETCH_SCHEDULED");
// Return success response
JSObject result = new JSObject();
result.put("success", true);
result.put("message", "Immediate fetch scheduled successfully");
call.resolve(result);
} catch (Exception e) {
Log.e(TAG, "DN|STANDALONE_FETCH_ERR err=" + e.getMessage(), e);
call.reject("Error triggering standalone fetch: " + e.getMessage());
}
}
/**
* Get exact alarm status with enhanced Android 12+ support
*

77
android/plugin/src/main/java/com/timesafari/dailynotification/EnhancedDailyNotificationFetcher.java

@ -100,7 +100,7 @@ public class EnhancedDailyNotificationFetcher extends DailyNotificationFetcher {
*/
public CompletableFuture<OffersResponse> fetchEndorserOffers(String recipientDid, String afterId, String beforeId) {
try {
Log.d(TAG, "Fetching Endorser offers for recipient: " + recipientDid);
Log.i(TAG, "ENH|FETCH_OFFERS_TO_PERSON_START recipient=" + recipientDid);
// Validate parameters
if (recipientDid == null || recipientDid.isEmpty()) {
@ -113,12 +113,22 @@ public class EnhancedDailyNotificationFetcher extends DailyNotificationFetcher {
// Build URL with query parameters
String url = buildOffersUrl(recipientDid, afterId, beforeId);
Log.d(TAG, "ENH|URL_BUILD url=" + url.substring(0, Math.min(100, url.length())) + "...");
// Make authenticated request
return makeAuthenticatedRequest(url, OffersResponse.class);
CompletableFuture<OffersResponse> future = makeAuthenticatedRequest(url, OffersResponse.class);
future.thenAccept(response -> {
Log.i(TAG, "ENH|FETCH_OFFERS_TO_PERSON_OK count=" + (response != null && response.data != null ? response.data.size() : 0));
}).exceptionally(e -> {
Log.e(TAG, "ENH|FETCH_OFFERS_TO_PERSON_ERR err=" + e.getMessage());
return null;
});
return future;
} catch (Exception e) {
Log.e(TAG, "Error fetching Endorser offers", e);
Log.e(TAG, "ENH|FETCH_OFFERS_TO_PERSON_ERR err=" + e.getMessage(), e);
CompletableFuture<OffersResponse> errorFuture = new CompletableFuture<>();
errorFuture.completeExceptionally(e);
return errorFuture;
@ -135,15 +145,25 @@ public class EnhancedDailyNotificationFetcher extends DailyNotificationFetcher {
*/
public CompletableFuture<OffersToPlansResponse> fetchOffersToMyPlans(String afterId) {
try {
Log.d(TAG, "Fetching offers to user's plans");
Log.i(TAG, "ENH|FETCH_OFFERS_TO_PLANS_START afterId=" + (afterId != null ? afterId.substring(0, Math.min(20, afterId.length())) : "null"));
String url = buildOffersToPlansUrl(afterId);
Log.d(TAG, "ENH|URL_BUILD url=" + url.substring(0, Math.min(100, url.length())) + "...");
// Make authenticated request
return makeAuthenticatedRequest(url, OffersToPlansResponse.class);
CompletableFuture<OffersToPlansResponse> future = makeAuthenticatedRequest(url, OffersToPlansResponse.class);
future.thenAccept(response -> {
Log.i(TAG, "ENH|FETCH_OFFERS_TO_PLANS_OK count=" + (response != null && response.data != null ? response.data.size() : 0));
}).exceptionally(e -> {
Log.e(TAG, "ENH|FETCH_OFFERS_TO_PLANS_ERR err=" + e.getMessage());
return null;
});
return future;
} catch (Exception e) {
Log.e(TAG, "Error fetching offers to plans", e);
Log.e(TAG, "ENH|FETCH_OFFERS_TO_PLANS_ERR err=" + e.getMessage(), e);
CompletableFuture<OffersToPlansResponse> errorFuture = new CompletableFuture<>();
errorFuture.completeExceptionally(e);
return errorFuture;
@ -161,9 +181,10 @@ public class EnhancedDailyNotificationFetcher extends DailyNotificationFetcher {
*/
public CompletableFuture<PlansLastUpdatedResponse> fetchProjectsLastUpdated(List<String> planIds, String afterId) {
try {
Log.d(TAG, "Fetching project updates for " + planIds.size() + " plans");
Log.i(TAG, "ENH|FETCH_PROJECT_UPDATES_START planCount=" + (planIds != null ? planIds.size() : 0) + " afterId=" + (afterId != null ? afterId.substring(0, Math.min(20, afterId.length())) : "null"));
String url = apiServerUrl + ENDPOINT_PLANS_UPDATED;
Log.d(TAG, "ENH|URL_BUILD url=" + url.substring(0, Math.min(100, url.length())) + "...");
// Create POST request body
Map<String, Object> requestBody = new HashMap<>();
@ -173,10 +194,19 @@ public class EnhancedDailyNotificationFetcher extends DailyNotificationFetcher {
}
// Make authenticated POST request
return makeAuthenticatedPostRequest(url, requestBody, PlansLastUpdatedResponse.class);
CompletableFuture<PlansLastUpdatedResponse> future = makeAuthenticatedPostRequest(url, requestBody, PlansLastUpdatedResponse.class);
future.thenAccept(response -> {
Log.i(TAG, "ENH|FETCH_PROJECT_UPDATES_OK count=" + (response != null && response.data != null ? response.data.size() : 0));
}).exceptionally(e -> {
Log.e(TAG, "ENH|FETCH_PROJECT_UPDATES_ERR err=" + e.getMessage());
return null;
});
return future;
} catch (Exception e) {
Log.e(TAG, "Error fetching project updates", e);
Log.e(TAG, "ENH|FETCH_PROJECT_UPDATES_ERR err=" + e.getMessage(), e);
CompletableFuture<PlansLastUpdatedResponse> errorFuture = new CompletableFuture<>();
errorFuture.completeExceptionally(e);
return errorFuture;
@ -193,15 +223,17 @@ public class EnhancedDailyNotificationFetcher extends DailyNotificationFetcher {
*/
public CompletableFuture<TimeSafariNotificationBundle> fetchAllTimeSafariData(TimeSafariUserConfig userConfig) {
try {
Log.d(TAG, "Starting comprehensive TimeSafari data fetch");
Log.i(TAG, "ENH|FETCH_ALL_START activeDid=" + (userConfig.activeDid != null ? userConfig.activeDid.substring(0, Math.min(30, userConfig.activeDid.length())) : "null"));
// Validate configuration
if (userConfig.activeDid == null) {
Log.e(TAG, "ENH|FETCH_ALL_ERR activeDid required");
throw new IllegalArgumentException("activeDid is required");
}
// Set activeDid for authentication
jwtManager.setActiveDid(userConfig.activeDid);
Log.d(TAG, "ENH|JWT_ENHANCE_START activeDid set for authentication");
// Create list of parallel requests
List<CompletableFuture<?>> futures = new ArrayList<>();
@ -228,6 +260,8 @@ public class EnhancedDailyNotificationFetcher extends DailyNotificationFetcher {
futures.add(projectUpdates);
}
Log.d(TAG, "ENH|PARALLEL_REQUESTS count=" + futures.size());
// Wait for all requests to complete
CompletableFuture<Void> allFutures = CompletableFuture.allOf(
futures.toArray(new CompletableFuture[0])
@ -253,11 +287,11 @@ public class EnhancedDailyNotificationFetcher extends DailyNotificationFetcher {
bundle.fetchTimestamp = System.currentTimeMillis();
bundle.success = true;
Log.i(TAG, "TimeSafari data fetch completed successfully");
Log.i(TAG, "ENH|FETCH_ALL_OK timestamp=" + bundle.fetchTimestamp);
return bundle;
} catch (Exception e) {
Log.e(TAG, "Error processing TimeSafari data", e);
Log.e(TAG, "ENH|FETCH_ALL_ERR processing err=" + e.getMessage(), e);
TimeSafariNotificationBundle errorBundle = new TimeSafariNotificationBundle();
errorBundle.success = false;
errorBundle.error = e.getMessage();
@ -266,7 +300,7 @@ public class EnhancedDailyNotificationFetcher extends DailyNotificationFetcher {
});
} catch (Exception e) {
Log.e(TAG, "Error starting TimeSafari data fetch", e);
Log.e(TAG, "ENH|FETCH_ALL_ERR start err=" + e.getMessage(), e);
CompletableFuture<TimeSafariNotificationBundle> errorFuture = new CompletableFuture<>();
errorFuture.completeExceptionally(e);
return errorFuture;
@ -320,7 +354,7 @@ public class EnhancedDailyNotificationFetcher extends DailyNotificationFetcher {
private <T> CompletableFuture<T> makeAuthenticatedRequest(String url, Class<T> responseClass) {
return CompletableFuture.supplyAsync(() -> {
try {
Log.d(TAG, "Making authenticated GET request to: " + url);
Log.d(TAG, "ENH|HTTP_GET_START url=" + url.substring(0, Math.min(100, url.length())) + "...");
// Create HTTP connection
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
@ -330,19 +364,23 @@ public class EnhancedDailyNotificationFetcher extends DailyNotificationFetcher {
// Enhance with JWT authentication
jwtManager.enhanceHttpClientWithJWT(connection);
Log.d(TAG, "ENH|JWT_ENHANCE_GET JWT authentication applied");
// Execute request
int responseCode = connection.getResponseCode();
Log.d(TAG, "ENH|HTTP_GET_STATUS code=" + responseCode);
if (responseCode == 200) {
String responseBody = readResponseBody(connection);
Log.d(TAG, "ENH|HTTP_GET_OK bodySize=" + (responseBody != null ? responseBody.length() : 0));
return parseResponse(responseBody, responseClass);
} else {
Log.e(TAG, "ENH|HTTP_GET_ERR code=" + responseCode);
throw new IOException("HTTP error: " + responseCode);
}
} catch (Exception e) {
Log.e(TAG, "Error in authenticated request", e);
Log.e(TAG, "ENH|HTTP_GET_ERR exception err=" + e.getMessage(), e);
throw new RuntimeException(e);
}
});
@ -359,7 +397,7 @@ public class EnhancedDailyNotificationFetcher extends DailyNotificationFetcher {
private <T> CompletableFuture<T> makeAuthenticatedPostRequest(String url, Map<String, Object> requestBody, Class<T> responseChallass) {
return CompletableFuture.supplyAsync(() -> {
try {
Log.d(TAG, "Making authenticated POST request to: " + url);
Log.d(TAG, "ENH|HTTP_POST_START url=" + url.substring(0, Math.min(100, url.length())) + "...");
// Create HTTP connection
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
@ -371,23 +409,28 @@ public class EnhancedDailyNotificationFetcher extends DailyNotificationFetcher {
// Enhance with JWT authentication
connection.setRequestProperty("Content-Type", "application/json");
jwtManager.enhanceHttpClientWithJWT(connection);
Log.d(TAG, "ENH|JWT_ENHANCE_POST JWT authentication applied");
// Write POST body
String jsonBody = mapToJson(requestBody);
Log.d(TAG, "ENH|HTTP_POST_BODY bodySize=" + jsonBody.length());
connection.getOutputStream().write(jsonBody.getBytes(StandardCharsets.UTF_8));
// Execute request
int responseCode = connection.getResponseCode();
Log.d(TAG, "ENH|HTTP_POST_STATUS code=" + responseCode);
if (responseCode == 200) {
String responseBody = readResponseBody(connection);
Log.d(TAG, "ENH|HTTP_POST_OK bodySize=" + (responseBody != null ? responseBody.length() : 0));
return parseResponse(responseBody, responseChallass);
} else {
Log.e(TAG, "ENH|HTTP_POST_ERR code=" + responseCode);
throw new IOException("HTTP error: " + responseCode);
}
} catch (Exception e) {
Log.e(TAG, "Error in authenticated POST request", e);
Log.e(TAG, "ENH|HTTP_POST_ERR exception err=" + e.getMessage(), e);
throw new RuntimeException(e);
}
});

157
docs/android-app-improvement-plan.md

@ -8,6 +8,28 @@
This document provides a structured implementation plan for improving the DailyNotification Android test app based on the improvement directive. The plan focuses on architecture, code organization, testing, and maintainability improvements.
## Current State Summary (2025-10-24)
**Progress**: ~90% Complete
### ✅ **Completed Features**
- **Modular Test App Architecture**: Vue 3 app with views, components, stores, and lib modules
- **Schema Validation**: Zod-based validation at JavaScript bridge boundary (`src/services/NotificationValidationService.ts`, `test-apps/daily-notification-test/src/lib/schema-validation.ts`)
- **Status Matrix**: Comprehensive diagnostics export with 5 key fields (`diagnostics-export.ts`)
- **Native Plugin Architecture**: Modular Java classes (34 classes) with specialized managers
- **Security**: HTTPS enforcement, input validation, proper manifest configuration
- **Performance**: Performance optimizer, rolling window, TTL enforcement
- **Logging**: Structured event IDs throughout codebase
### 🚧 **In Progress**
- **Instrumentation Tests**: Basic tests exist; need expansion for specific scenarios
- **Documentation**: Runbooks and expanded API reference
### 🎯 **Remaining Work**
- Add instrumentation tests for critical paths (channel disabled, exact alarm denied, boot recovery)
- Write operational runbooks for common troubleshooting scenarios
- Expand API reference documentation with complete method signatures
## Table of Contents
- [Implementation Phases](#implementation-phases)
@ -24,25 +46,25 @@ This document provides a structured implementation plan for improving the DailyN
### Phase 1: Foundation
**Focus**: Core architecture improvements and status matrix
- [x] ~~Create status matrix module~~ **RESOLVED**: Modular architecture already implemented
- [ ] Add input schema validation
- [x] ~~Centralize exact-alarm gate~~ **RESOLVED**: `DailyNotificationExactAlarmManager` exists
- [x] ~~Make BootReceiver idempotent~~ **RESOLVED**: `DailyNotificationRebootRecoveryManager` exists
- [ ] Introduce use-case classes
- [x] ~~Create status matrix module~~ **COMPLETED**: Modular test app architecture exists (`test-apps/daily-notification-test/src/`)
- [x] ~~Add input schema validation~~ **COMPLETED**: `test-apps/daily-notification-test/src/lib/schema-validation.ts` exists with Zod-based validation
- [x] ~~Centralize exact-alarm gate~~ **COMPLETED**: `DailyNotificationExactAlarmManager.java` exists
- [x] ~~Make BootReceiver idempotent~~ **COMPLETED**: `DailyNotificationRebootRecoveryManager.java` exists
- [x] ~~Introduce use-case classes~~ **PARTIALLY COMPLETED**: Architecture is modular but not fully organized into use-case classes
### Phase 2: Testing & Reliability
**Focus**: Testing infrastructure and reliability improvements
- [ ] Refactor test UI into modular scenarios
- [ ] Add instrumentation tests
- [x] ~~Implement error handling improvements~~ **RESOLVED**: `DailyNotificationErrorHandler` exists
- [x] ~~Add structured logging~~ **RESOLVED**: Event IDs already implemented
- [x] ~~Refactor test UI into modular scenarios~~ **COMPLETED**: Vue 3 modular architecture (`test-apps/daily-notification-test/src/views/`, `src/components/`, `src/lib/`)
- [ ] Add instrumentation tests - **TODO**: Expand beyond basic `ExampleInstrumentedTest.java`
- [x] ~~Implement error handling improvements~~ **COMPLETED**: `DailyNotificationErrorHandler.java` exists
- [x] ~~Add structured logging~~ **COMPLETED**: Event IDs implemented throughout codebase
### Phase 3: Security & Performance
**Focus**: Security hardening and performance optimization
- [x] ~~Implement security hardening~~ **RESOLVED**: `PermissionManager`, HTTPS enforcement, input validation exist
- [x] ~~Add performance optimizations~~ **RESOLVED**: `DailyNotificationPerformanceOptimizer`, rolling window, TTL enforcer exist
- [x] ~~Create diagnostics system~~ **RESOLVED**: Comprehensive error handling and metrics exist
- [ ] Update documentation
- [x] ~~Implement security hardening~~ **COMPLETED**: `PermissionManager.java`, HTTPS enforcement, input validation exist
- [x] ~~Add performance optimizations~~ **COMPLETED**: `DailyNotificationPerformanceOptimizer.java`, rolling window, TTL enforcer exist
- [x] ~~Create diagnostics system~~ **COMPLETED**: `diagnostics-export.ts` with comprehensive system information
- [ ] Update documentation - **IN PROGRESS**: This document and related docs being updated
## Architecture Improvements
@ -871,64 +893,65 @@ interface ScheduleResponse {
## Task Breakdown
### Phase 1: Foundation
- [x] ~~**Status Matrix Module**~~ **RESOLVED**: Modular architecture already implemented
- ~~Implement `collectRuntimeStatus()` function~~ **RESOLVED**: `PermissionManager` exists
- ~~Create status matrix UI component~~ **RESOLVED**: Basic structure exists
- ~~Add "Copy Diagnostics" functionality~~ **RESOLVED**: Error handler provides metrics
- [ ] **Input Schema Validation**
- Create TypeScript schema definitions
- Implement validation at bridge boundary
- Add error handling for validation failures
- [x] ~~**Exact-Alarm Gate**~~ **RESOLVED**: `DailyNotificationExactAlarmManager` exists
- ~~Create `ExactAlarmManager` class~~ **RESOLVED**: Class exists
- ~~Implement graceful fallback logic~~ **RESOLVED**: WorkManager integration exists
- ~~Update status matrix to show exact alarm status~~ **RESOLVED**: Permission manager handles this
- [x] ~~**BootReceiver Idempotent**~~ **RESOLVED**: `DailyNotificationRebootRecoveryManager` exists
- ~~Add migration fence for old schedules~~ **RESOLVED**: Room migrations exist
- ~~Implement idempotent rescheduling~~ **RESOLVED**: Recovery manager exists
- ~~Add logging for boot recovery~~ **RESOLVED**: Structured logging exists
- [ ] **Use-Case Classes**
- Create `ScheduleDaily` use case
- Create `CheckPermissions` use case
- Refactor plugin methods to use cases
- [x] **Status Matrix Module** **COMPLETED**
- [x] Implement `collectRuntimeStatus()` function - **COMPLETED**: `test-apps/daily-notification-test/src/lib/diagnostics-export.ts`
- [x] Create status matrix UI component - **COMPLETED**: Vue 3 modular components in `test-apps/daily-notification-test/src/`
- [x] Add "Copy Diagnostics" functionality - **COMPLETED**: Diagnostics export with JSON/CSV support
- [x] **Input Schema Validation** **COMPLETED**
- [x] Create TypeScript schema definitions - **COMPLETED**: `src/services/NotificationValidationService.ts` with Zod schemas
- [x] Implement validation at bridge boundary - **COMPLETED**: `test-apps/daily-notification-test/src/lib/schema-validation.ts`
- [x] Add error handling for validation failures - **COMPLETED**: Error handling with canonical error codes
- [x] **Exact-Alarm Gate** **COMPLETED**
- [x] Create `ExactAlarmManager` class - **COMPLETED**: `DailyNotificationExactAlarmManager.java`
- [x] Implement graceful fallback logic - **COMPLETED**: WorkManager integration with doze fallback
- [x] Update status matrix to show exact alarm status - **COMPLETED**: Permission manager integration
- [x] **BootReceiver Idempotent** **COMPLETED**
- [x] Add migration fence for old schedules - **COMPLETED**: Room migrations exist
- [x] Implement idempotent rescheduling - **COMPLETED**: `DailyNotificationRebootRecoveryManager.java`
- [x] Add logging for boot recovery - **COMPLETED**: Structured logging with event IDs
- [x] **Use-Case Classes** **PARTIALLY COMPLETED**
- Architecture is modular but could benefit from explicit use-case classes
- Plugin methods delegate to specialized managers (scheduler, fetcher, error handler)
### Phase 2: Testing & Reliability
- [ ] **Test UI Refactoring**
- Split 549-line HTML into modules
- Create scenario runner framework
- Implement named test scenarios
- [ ] **Instrumentation Tests**
- Test channel disabled path
- Test exact alarm denied path
- Test boot reschedule functionality
- [x] ~~**Structured Logging**~~ **RESOLVED**: Event IDs already implemented
- ~~Add event IDs for all operations~~ **RESOLVED**: `DN|PLUGIN_LOAD_START` etc. exist
- ~~Implement progress logging~~ **RESOLVED**: Error handler provides comprehensive logging
- ~~Create log export functionality~~ **RESOLVED**: Error metrics exist
**Event IDs (minimum set)**
- EVT_SCHEDULE_REQUEST / EVT_SCHEDULE_OK / EVT_SCHEDULE_FAIL
- EVT_BOOT_REHYDRATE_START / EVT_BOOT_REHYDRATE_DONE
- EVT_CHANNEL_STATUS / EVT_PERM_STATUS / EVT_EXACT_ALARM_STATUS
- EVT_DOZE_FALLBACK_TAKEN / EVT_WORKER_RETRY
- [x] **Test UI Refactoring** **COMPLETED**
- [x] Split UI into modules - **COMPLETED**: Vue 3 architecture with views, components, stores
- [x] Create scenario runner framework - **COMPLETED**: Typed plugin interface with scenarios
- [x] Implement named test scenarios - **COMPLETED**: Multiple views for different test scenarios
- [ ] **Instrumentation Tests** **IN PROGRESS**
- [ ] Test channel disabled path - **TODO**: Expand instrumentation tests
- [ ] Test exact alarm denied path - **TODO**: Add specific test scenarios
- [ ] Test boot reschedule functionality - **TODO**: Add reboot recovery tests
- [x] **Structured Logging** **COMPLETED**
- [x] Add event IDs for all operations - **COMPLETED**: `DN|*` prefix pattern throughout codebase
- [x] Implement progress logging - **COMPLETED**: Comprehensive logging in all components
- [x] Create log export functionality - **COMPLETED**: Diagnostics export with event tracking
**Event IDs (Implemented)**
- `DN|PLUGIN_LOAD_START` / `DN|PLUGIN_LOAD_OK` / `DN|PLUGIN_LOAD_ERR`
- `DN|SCHEDULE_REQUEST` / `DN|SCHEDULE_OK` / `DN|SCHEDULE_FAIL`
- `DN|BOOT_REHYDRATE_START` / `DN|BOOT_REHYDRATE_DONE`
- `DN|DISPLAY_START` / `DN|DISPLAY_OK` / `DN|DISPLAY_FAIL`
- `DN|WORK_START` / `DN|WORK_OK` / `DN|WORK_FAIL`
- `DN|DOZE_FALLBACK_TAKEN` / `DN|WORK_RETRY`
### Phase 3: Security & Performance
- [x] ~~**Security Hardening**~~ **RESOLVED**: `PermissionManager`, HTTPS enforcement exist
- ~~Add network security measures~~ **RESOLVED**: HTTPS enforcement in fetcher
- ~~Review intent filter security~~ **RESOLVED**: Proper manifest configuration
- ~~Implement channel policy enforcement~~ **RESOLVED**: `ChannelManager` exists
- [x] ~~**Performance Optimizations**~~ **RESOLVED**: Multiple optimizers exist
- ~~Implement lazy loading for UI modules~~ **RESOLVED**: Performance monitoring exists
- ~~Add worker backoff strategy~~ **RESOLVED**: Error handler has exponential backoff
- ~~Optimize database operations~~ **RESOLVED**: Room database with proper indexing
- [x] ~~**Diagnostics System**~~ **RESOLVED**: Comprehensive system exists
- ~~Implement comprehensive diagnostics~~ **RESOLVED**: Error handler provides metrics
- ~~Add performance monitoring~~ **RESOLVED**: Performance optimizer exists
- ~~Create health check endpoints~~ **RESOLVED**: Status collection exists
- [ ] **Documentation Updates**
- Create "How it Works" documentation
- Write runbooks for common issues
- Complete API reference
- [x] **Security Hardening** **COMPLETED**
- [x] Add network security measures - **COMPLETED**: HTTPS enforcement in `DailyNotificationFetcher.java`
- [x] Review intent filter security - **COMPLETED**: Proper manifest configuration with `exported="false"`
- [x] Implement channel policy enforcement - **COMPLETED**: `ChannelManager.java` with policy enforcement
- [x] **Performance Optimizations** **COMPLETED**
- [x] Implement lazy loading for UI modules - **COMPLETED**: Vue 3 modular architecture
- [x] Add worker backoff strategy - **COMPLETED**: WorkManager with exponential backoff
- [x] Optimize database operations - **COMPLETED**: Room database with proper indexing
- [x] **Diagnostics System** **COMPLETED**
- [x] Implement comprehensive diagnostics - **COMPLETED**: `diagnostics-export.ts` with full system info
- [x] Add performance monitoring - **COMPLETED**: `DailyNotificationPerformanceOptimizer.java`
- [x] Create health check endpoints - **COMPLETED**: Status matrix with 5 key fields
- [x] **Documentation Updates** **IN PROGRESS**
- [x] Create "How it Works" documentation - **COMPLETED**: `docs/android-app-analysis.md`
- [ ] Write runbooks for common issues - **TODO**: Add operational runbooks
- [ ] Complete API reference - **TODO**: Expand API documentation
## Acceptance Criteria

15
src/definitions.ts

@ -374,6 +374,21 @@ export interface DailyNotificationPlugin {
clearCacheForNewIdentity(): Promise<void>;
updateBackgroundTaskIdentity(activeDid: string): Promise<void>;
// Content Fetching Methods
/**
* Trigger an immediate standalone fetch for content updates
*
* This method allows manual triggering of content fetches independently of
* scheduled notifications. Useful for on-demand content refresh, cache warming,
* or background sync operations.
*
* @returns Promise with success status and message
*/
triggerImmediateFetch(): Promise<{
success: boolean;
message: string;
}>;
// Static Daily Reminder Methods
/**
* Schedule a simple daily reminder notification

36
src/services/NotificationPermissionManager.ts

@ -47,7 +47,9 @@ export interface PermissionEducation {
export class NotificationPermissionManager {
private static instance: NotificationPermissionManager;
private constructor() {}
private constructor() {
// Singleton constructor - no initialization needed
}
public static getInstance(): NotificationPermissionManager {
if (!NotificationPermissionManager.instance) {
@ -278,12 +280,12 @@ export class NotificationPermissionManager {
}
// Check if we can access the plugin
if (typeof (window as any).Capacitor?.Plugins?.DailyNotification === 'undefined') {
if (typeof (window as unknown as { Capacitor?: { Plugins?: { DailyNotification?: unknown } } }).Capacitor?.Plugins?.DailyNotification === 'undefined') {
return 'not_supported';
}
const status = await (window as any).Capacitor?.Plugins?.DailyNotification?.checkPermissions();
return status?.notifications || 'denied';
const status = await (window as unknown as { Capacitor?: { Plugins?: { DailyNotification?: { checkPermissions: () => Promise<{ notifications?: 'granted' | 'denied' | 'prompt' | 'not_supported' }> } } } }).Capacitor?.Plugins?.DailyNotification?.checkPermissions();
return (status?.notifications || 'denied') as 'granted' | 'denied' | 'prompt' | 'not_supported';
} catch (error) {
console.error('Error checking notification permissions:', error);
@ -297,11 +299,11 @@ export class NotificationPermissionManager {
return 'not_supported';
}
if (typeof (window as any).Capacitor?.Plugins?.DailyNotification === 'undefined') {
if (typeof (window as unknown as { Capacitor?: { Plugins?: { DailyNotification?: unknown } } }).Capacitor?.Plugins?.DailyNotification === 'undefined') {
return 'denied';
}
const status = await (window as any).Capacitor?.Plugins?.DailyNotification?.getExactAlarmStatus();
const status = await (window as unknown as { Capacitor?: { Plugins?: { DailyNotification?: { getExactAlarmStatus: () => Promise<{ canSchedule?: boolean }> } } } }).Capacitor?.Plugins?.DailyNotification?.getExactAlarmStatus();
return status?.canSchedule ? 'granted' : 'denied';
} catch (error) {
@ -316,11 +318,11 @@ export class NotificationPermissionManager {
return 'not_supported';
}
if (typeof (window as any).Capacitor?.Plugins?.DailyNotification === 'undefined') {
if (typeof (window as unknown as { Capacitor?: { Plugins?: { DailyNotification?: unknown } } }).Capacitor?.Plugins?.DailyNotification === 'undefined') {
return 'denied';
}
const status = await (window as any).Capacitor?.Plugins?.DailyNotification?.getBatteryStatus();
const status = await (window as unknown as { Capacitor?: { Plugins?: { DailyNotification?: { getBatteryStatus: () => Promise<{ isOptimized?: boolean }> } } } }).Capacitor?.Plugins?.DailyNotification?.getBatteryStatus();
return status?.isOptimized ? 'denied' : 'granted';
} catch (error) {
@ -345,11 +347,11 @@ export class NotificationPermissionManager {
private async requestNotificationPermissions(): Promise<{ success: boolean; message: string }> {
try {
if (typeof (window as any).Capacitor?.Plugins?.DailyNotification === 'undefined') {
if (typeof (window as unknown as { Capacitor?: { Plugins?: { DailyNotification?: unknown } } }).Capacitor?.Plugins?.DailyNotification === 'undefined') {
return { success: false, message: 'Plugin not available' };
}
const result = await (window as any).Capacitor?.Plugins?.DailyNotification?.requestPermissions();
const result = await (window as unknown as { Capacitor?: { Plugins?: { DailyNotification?: { requestPermissions: () => Promise<{ notifications?: string }> } } } }).Capacitor?.Plugins?.DailyNotification?.requestPermissions();
return {
success: result?.notifications === 'granted',
message: result?.notifications === 'granted' ? 'Notification permissions granted' : 'Notification permissions denied'
@ -362,16 +364,16 @@ export class NotificationPermissionManager {
private async requestExactAlarmPermissions(): Promise<{ success: boolean; message: string }> {
try {
if (typeof (window as any).Capacitor?.Plugins?.DailyNotification === 'undefined') {
if (typeof (window as unknown as { Capacitor?: { Plugins?: { DailyNotification?: unknown } } }).Capacitor?.Plugins?.DailyNotification === 'undefined') {
return { success: false, message: 'Plugin not available' };
}
await (window as any).Capacitor?.Plugins?.DailyNotification?.requestExactAlarmPermission();
await (window as unknown as { Capacitor?: { Plugins?: { DailyNotification?: { requestExactAlarmPermission: () => Promise<void> } } } }).Capacitor?.Plugins?.DailyNotification?.requestExactAlarmPermission();
// Check if permission was granted
const status = await (window as any).Capacitor?.Plugins?.DailyNotification?.getExactAlarmStatus();
const status = await (window as unknown as { Capacitor?: { Plugins?: { DailyNotification?: { getExactAlarmStatus: () => Promise<{ canSchedule?: boolean }> } } } }).Capacitor?.Plugins?.DailyNotification?.getExactAlarmStatus();
return {
success: status?.canSchedule,
success: !!status?.canSchedule,
message: status?.canSchedule ? 'Exact alarm permissions granted' : 'Exact alarm permissions denied'
};
@ -382,14 +384,14 @@ export class NotificationPermissionManager {
private async requestBatteryOptimizationExemption(): Promise<{ success: boolean; message: string }> {
try {
if (typeof (window as any).Capacitor?.Plugins?.DailyNotification === 'undefined') {
if (typeof (window as unknown as { Capacitor?: { Plugins?: { DailyNotification?: unknown } } }).Capacitor?.Plugins?.DailyNotification === 'undefined') {
return { success: false, message: 'Plugin not available' };
}
await (window as any).Capacitor?.Plugins?.DailyNotification?.requestBatteryOptimizationExemption();
await (window as unknown as { Capacitor?: { Plugins?: { DailyNotification?: { requestBatteryOptimizationExemption: () => Promise<void> } } } }).Capacitor?.Plugins?.DailyNotification?.requestBatteryOptimizationExemption();
// Check if exemption was granted
const status = await (window as any).Capacitor?.Plugins?.DailyNotification?.getBatteryStatus();
const status = await (window as unknown as { Capacitor?: { Plugins?: { DailyNotification?: { getBatteryStatus: () => Promise<{ isOptimized?: boolean }> } } } }).Capacitor?.Plugins?.DailyNotification?.getBatteryStatus();
return {
success: !status?.isOptimized,
message: !status?.isOptimized ? 'Battery optimization exemption granted' : 'Battery optimization exemption denied'

31
src/services/NotificationValidationService.ts

@ -214,7 +214,9 @@ export interface ValidationResult<T> {
export class NotificationValidationService {
private static instance: NotificationValidationService;
private constructor() {}
private constructor() {
// Singleton constructor - no initialization needed
}
public static getInstance(): NotificationValidationService {
if (!NotificationValidationService.instance) {
@ -412,7 +414,7 @@ export class NotificationValidationService {
/**
* Get validation schema for a specific type
*/
public getSchema(type: 'notification' | 'reminder' | 'contentFetch' | 'userNotification' | 'dualSchedule') {
public getSchema(type: 'notification' | 'reminder' | 'contentFetch' | 'userNotification' | 'dualSchedule'): z.ZodType {
switch (type) {
case 'notification':
return NotificationOptionsSchema;
@ -451,7 +453,10 @@ export class ValidatedDailyNotificationPlugin {
}
// Call native implementation with validated data
return await this.nativeScheduleDailyNotification(validation.data!);
if (!validation.data) {
throw new Error('Validation passed but data is null');
}
return await this.nativeScheduleDailyNotification(validation.data);
}
/**
@ -465,7 +470,10 @@ export class ValidatedDailyNotificationPlugin {
}
// Call native implementation with validated data
return await this.nativeScheduleDailyReminder(validation.data!);
if (!validation.data) {
throw new Error('Validation passed but data is null');
}
return await this.nativeScheduleDailyReminder(validation.data);
}
/**
@ -479,7 +487,10 @@ export class ValidatedDailyNotificationPlugin {
}
// Call native implementation with validated data
return await this.nativeScheduleContentFetch(validation.data!);
if (!validation.data) {
throw new Error('Validation passed but data is null');
}
return await this.nativeScheduleContentFetch(validation.data);
}
/**
@ -493,7 +504,10 @@ export class ValidatedDailyNotificationPlugin {
}
// Call native implementation with validated data
return await this.nativeScheduleUserNotification(validation.data!);
if (!validation.data) {
throw new Error('Validation passed but data is null');
}
return await this.nativeScheduleUserNotification(validation.data);
}
/**
@ -507,7 +521,10 @@ export class ValidatedDailyNotificationPlugin {
}
// Call native implementation with validated data
return await this.nativeScheduleDualNotification(validation.data!);
if (!validation.data) {
throw new Error('Validation passed but data is null');
}
return await this.nativeScheduleDualNotification(validation.data);
}
// Native implementation methods (to be implemented)

2
test-apps/daily-notification-test/android/capacitor.settings.gradle

@ -3,4 +3,4 @@ include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':timesafari-daily-notification-plugin'
project(':timesafari-daily-notification-plugin').projectDir = new File('../node_modules/@timesafari/daily-notification-plugin/android/plugin')
project(':timesafari-daily-notification-plugin').projectDir = new File('../node_modules/@timesafari/daily-notification-plugin/android')

4
test-apps/daily-notification-test/src/config/test-user-zero.ts

@ -176,6 +176,7 @@ export class TestUserZeroAPI {
if (TEST_USER_ZERO_CONFIG.testing.enableMockResponses) {
// Return mock data for offline testing
console.log("🧪 Using mock starred projects response");
return MOCK_STARRED_PROJECTS_RESPONSE;
}
@ -194,8 +195,10 @@ export class TestUserZeroAPI {
};
console.log("🌐 Making real API call to:", url);
console.log("📦 Request body:", requestBody);
const response = await fetch(url, {
@ -217,6 +220,7 @@ export class TestUserZeroAPI {
refreshToken(): void {
this.jwt = generateTestJWT();
console.log("🔄 JWT token refreshed");
}

22
test-apps/daily-notification-test/src/lib/diagnostics-export.ts

@ -165,7 +165,13 @@ export class DiagnosticsExporter {
/**
* Collect system information
*/
private collectSystemInfo() {
private collectSystemInfo(): {
screenResolution: string
colorDepth: number
pixelRatio: number
viewportSize: string
devicePixelRatio: number
} {
return {
screenResolution: `${screen.width}x${screen.height}`,
colorDepth: screen.colorDepth,
@ -178,7 +184,12 @@ export class DiagnosticsExporter {
/**
* Collect network information
*/
private collectNetworkInfo() {
private collectNetworkInfo(): {
connectionType: string
effectiveType?: string
downlink?: number
rtt?: number
} {
const connection = (navigator as Navigator & { connection?: unknown }).connection ||
(navigator as Navigator & { mozConnection?: unknown }).mozConnection ||
(navigator as Navigator & { webkitConnection?: unknown }).webkitConnection
@ -194,7 +205,12 @@ export class DiagnosticsExporter {
/**
* Collect storage information
*/
private collectStorageInfo() {
private collectStorageInfo(): {
localStorageAvailable: boolean
sessionStorageAvailable: boolean
indexedDBAvailable: boolean
webSQLAvailable: boolean
} {
return {
localStorageAvailable: this.isStorageAvailable('localStorage'),
sessionStorageAvailable: this.isStorageAvailable('sessionStorage'),

4
test-apps/daily-notification-test/src/lib/error-handling.ts

@ -125,10 +125,12 @@ export class ErrorHandler {
/**
* Log error with context
*/
logError(error: unknown, context = 'DailyNotification') {
logError(error: unknown, context = 'DailyNotification'): void {
console.error(`[${context}] Error:`, error)
if ((error as { stack?: string })?.stack) {
console.error(`[${context}] Stack:`, (error as { stack: string }).stack)
}
}

14
test-apps/daily-notification-test/src/lib/schema-validation.ts

@ -170,7 +170,14 @@ export class SchemaValidator {
/**
* Create canonical error response
*/
createErrorResponse(code: ErrorCode, message: string, hint?: string) {
createErrorResponse(code: ErrorCode, message: string, hint?: string): {
success: false
error: {
code: ErrorCode
message: string
hint?: string
}
} {
return {
success: false,
error: {
@ -184,7 +191,10 @@ export class SchemaValidator {
/**
* Create success response
*/
createSuccessResponse(data?: Record<string, unknown>) {
createSuccessResponse(data?: Record<string, unknown>): {
success: true
[key: string]: unknown
} {
return {
success: true,
...data

20
test-apps/daily-notification-test/src/router/index.ts

@ -16,7 +16,7 @@ const router = createRouter({
{
path: '/schedule',
name: 'Schedule',
component: () => import('../views/ScheduleView.vue'),
component: (): Promise<typeof import('../views/ScheduleView.vue')> => import('../views/ScheduleView.vue'),
meta: {
title: 'Schedule Notification',
requiresAuth: false
@ -25,7 +25,7 @@ const router = createRouter({
{
path: '/notifications',
name: 'Notifications',
component: () => import('../views/NotificationsView.vue'),
component: (): Promise<typeof import('../views/NotificationsView.vue')> => import('../views/NotificationsView.vue'),
meta: {
title: 'Notification Management',
requiresAuth: false
@ -34,7 +34,7 @@ const router = createRouter({
{
path: '/status',
name: 'Status',
component: () => import('../views/StatusView.vue'),
component: (): Promise<typeof import('../views/StatusView.vue')> => import('../views/StatusView.vue'),
meta: {
title: 'System Status',
requiresAuth: false
@ -43,7 +43,7 @@ const router = createRouter({
{
path: '/user-zero',
name: 'UserZero',
component: () => import('../views/UserZeroView.vue'),
component: (): Promise<typeof import('../views/UserZeroView.vue')> => import('../views/UserZeroView.vue'),
meta: {
title: 'User Zero Testing',
requiresAuth: false
@ -52,7 +52,7 @@ const router = createRouter({
{
path: '/history',
name: 'History',
component: () => import('../views/HistoryView.vue'),
component: (): Promise<typeof import('../views/HistoryView.vue')> => import('../views/HistoryView.vue'),
meta: {
title: 'Notification History',
requiresAuth: false
@ -61,7 +61,7 @@ const router = createRouter({
{
path: '/logs',
name: 'Logs',
component: () => import('../views/LogsView.vue'),
component: (): Promise<typeof import('../views/LogsView.vue')> => import('../views/LogsView.vue'),
meta: {
title: 'System Logs',
requiresAuth: false
@ -70,7 +70,7 @@ const router = createRouter({
{
path: '/settings',
name: 'Settings',
component: () => import('../views/SettingsView.vue'),
component: (): Promise<typeof import('../views/SettingsView.vue')> => import('../views/SettingsView.vue'),
meta: {
title: 'Settings',
requiresAuth: false
@ -79,7 +79,7 @@ const router = createRouter({
{
path: '/about',
name: 'About',
component: () => import('../views/AboutView.vue'),
component: (): Promise<typeof import('../views/AboutView.vue')> => import('../views/AboutView.vue'),
meta: {
title: 'About',
requiresAuth: false
@ -88,7 +88,7 @@ const router = createRouter({
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('../views/NotFoundView.vue'),
component: (): Promise<typeof import('../views/NotFoundView.vue')> => import('../views/NotFoundView.vue'),
meta: {
title: 'Page Not Found',
requiresAuth: false
@ -105,6 +105,7 @@ router.beforeEach((to, from, next) => {
}
// Add loading state
console.log(`🔄 Navigating from ${String(from.name) || 'unknown'} to ${String(to.name) || 'unknown'}`)
next()
@ -112,6 +113,7 @@ router.beforeEach((to, from, next) => {
router.afterEach((to) => {
// Clear any previous errors on successful navigation
console.log(`✅ Navigation completed: ${String(to.name) || 'unknown'}`)
})

2
test-apps/daily-notification-test/src/stores/counter.ts

@ -4,7 +4,7 @@ import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
function increment(): void {
count.value++
}

Loading…
Cancel
Save