Browse Source

chore: commit to move to laptop

master
Matthew Raymer 1 day ago
parent
commit
01b7dae5df
  1. 30
      BUILDING.md
  2. 3
      android/app/capacitor.build.gradle
  3. 20
      android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java
  4. 9
      android/plugin/src/main/java/com/timesafari/dailynotification/NativeNotificationContentFetcher.java
  5. 76
      scripts/fix-capacitor-build.sh
  6. 11
      src/definitions.ts
  7. 141
      test-apps/daily-notification-test/IMPLEMENTATION_COMPLETE.md
  8. 301
      test-apps/daily-notification-test/INVESTIGATION_JWT_ALGORITHM.md
  9. 220
      test-apps/daily-notification-test/INVESTIGATION_JWT_ALGORITHM_RESULTS.md
  10. 20
      test-apps/daily-notification-test/README.md
  11. 677
      test-apps/daily-notification-test/TODO_NATIVE_FETCHER.md
  12. 1
      test-apps/daily-notification-test/android/app/src/main/AndroidManifest.xml
  13. 6
      test-apps/daily-notification-test/android/app/src/main/java/com/timesafari/dailynotification/test/TestApplication.java
  14. 330
      test-apps/daily-notification-test/android/app/src/main/java/com/timesafari/dailynotification/test/TestNativeFetcher.java
  15. 33
      test-apps/daily-notification-test/android/app/src/main/res/xml/network_security_config.xml
  16. 2
      test-apps/daily-notification-test/android/capacitor.settings.gradle
  17. 30
      test-apps/daily-notification-test/docs/BUILD_QUICK_REFERENCE.md
  18. 7
      test-apps/daily-notification-test/docs/PLUGIN_DETECTION_GUIDE.md
  19. 220
      test-apps/daily-notification-test/package-lock.json
  20. 10
      test-apps/daily-notification-test/package.json
  21. 76
      test-apps/daily-notification-test/scripts/fix-capacitor-plugins.js
  22. 67
      test-apps/daily-notification-test/src/config/test-user-zero.ts
  23. 4
      test-apps/daily-notification-test/src/router/index.ts
  24. 46
      test-apps/daily-notification-test/src/views/HomeView.vue
  25. 2
      test-apps/daily-notification-test/src/views/UserZeroView.vue

30
BUILDING.md

@ -879,6 +879,36 @@ rm -rf ~/.gradle/caches ~/.gradle/daemon
./scripts/build-native.sh --platform android
```
#### Capacitor Settings Path Fix (Test App)
**Problem**: `capacitor.settings.gradle` is auto-generated with incorrect plugin path.
The plugin module is in `android/plugin/` but Capacitor generates a path to `android/`.
**Automatic Solution** (Test App Only):
```bash
# Use the wrapper script that auto-fixes after sync:
npm run cap:sync
# This automatically:
# 1. Runs npx cap sync android
# 2. Fixes capacitor.settings.gradle path (android -> android/plugin/)
# 3. Fixes capacitor.plugins.json registration
```
**Manual Fix** (if needed):
```bash
# After running npx cap sync android directly:
node scripts/fix-capacitor-plugins.js
# Or for plugin development (root project):
./scripts/fix-capacitor-build.sh
```
**Automatic Fix on Install**:
The test app has a `postinstall` hook that automatically fixes these issues after `npm install`.
**Note**: The fix script is idempotent - it only changes what's needed and won't break correct configurations.
#### Android Studio Issues
```bash
# Problem: Android Studio can't find SDK

3
android/app/capacitor.build.gradle

@ -7,7 +7,8 @@ android {
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
// Plugin development project - no Capacitor integration files needed
// apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {

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

@ -182,10 +182,15 @@ public class DailyNotificationPlugin extends Plugin {
* await DailyNotification.configureNativeFetcher({
* apiBaseUrl: 'http://10.0.2.2:3000',
* activeDid: 'did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F',
* jwtSecret: 'test-jwt-secret-for-development'
* jwtToken: 'eyJhbGciOiJFUzI1Nksi...' // Pre-generated JWT token
* });
* }</pre>
*
* <p><b>Architecture Note:</b> JWT tokens should be generated in TypeScript using
* TimeSafari's {@code createEndorserJwtForKey()} function (which uses DID-based ES256K
* signing), then passed to this method. This avoids the complexity of implementing
* DID-based JWT signing in Java.</p>
*
* @param call Plugin call containing configuration parameters:
* <ul>
* <li>{@code apiBaseUrl} (required): Base URL for API server.
@ -194,8 +199,9 @@ public class DailyNotificationPlugin extends Plugin {
* Production: "https://api.timesafari.com"</li>
* <li>{@code activeDid} (required): Active DID for authentication.
* Format: "did:ethr:0x..."</li>
* <li>{@code jwtSecret} (required): JWT secret for signing tokens.
* Keep secure in production.</li>
* <li>{@code jwtToken} (required): Pre-generated JWT token (ES256K signed).
* Generated in TypeScript using TimeSafari's {@code createEndorserJwtForKey()}
* function. Token format: "Bearer {token}" will be added automatically.</li>
* </ul>
*
* @throws PluginException if configuration fails (rejected via call.reject())
@ -207,10 +213,10 @@ public class DailyNotificationPlugin extends Plugin {
try {
String apiBaseUrl = call.getString("apiBaseUrl");
String activeDid = call.getString("activeDid");
String jwtSecret = call.getString("jwtSecret");
String jwtToken = call.getString("jwtToken");
if (apiBaseUrl == null || activeDid == null || jwtSecret == null) {
call.reject("Missing required parameters: apiBaseUrl, activeDid, and jwtSecret are required");
if (apiBaseUrl == null || activeDid == null || jwtToken == null) {
call.reject("Missing required parameters: apiBaseUrl, activeDid, and jwtToken are required");
return;
}
@ -225,7 +231,7 @@ public class DailyNotificationPlugin extends Plugin {
"... activeDid: " + activeDid.substring(0, Math.min(30, activeDid.length())) + "...");
// Call configure on the native fetcher (defaults to no-op if not implemented)
fetcher.configure(apiBaseUrl, activeDid, jwtSecret);
fetcher.configure(apiBaseUrl, activeDid, jwtToken);
Log.i(TAG, "SPI: Native fetcher configured successfully");
call.resolve();

9
android/plugin/src/main/java/com/timesafari/dailynotification/NativeNotificationContentFetcher.java

@ -129,12 +129,15 @@ public interface NativeNotificationContentFetcher {
* - Production: "https://api.timesafari.com"
* @param activeDid Active DID (Decentralized Identifier) for authentication.
* Used as the JWT issuer/subject. Format: "did:ethr:0x..."
* @param jwtSecret JWT secret key for signing authentication tokens.
* Keep this secure - consider using secure storage for production.
* @param jwtToken Pre-generated JWT token (ES256K signed) from TypeScript.
* This token is generated in the host app using TimeSafari's
* {@code createEndorserJwtForKey()} function. The native fetcher
* should use this token directly in the Authorization header as
* "Bearer {jwtToken}". No JWT generation or signing is needed in Java.
*
* @see DailyNotificationPlugin#configureNativeFetcher(PluginCall)
*/
default void configure(String apiBaseUrl, String activeDid, String jwtSecret) {
default void configure(String apiBaseUrl, String activeDid, String jwtToken) {
// Default no-op implementation - fetchers that need config can override
// This allows fetchers that don't need TypeScript-provided configuration
// to ignore this method without implementing an empty body.

76
scripts/fix-capacitor-build.sh

@ -1,45 +1,50 @@
#!/bin/bash
# =============================================================================
# FIX SCRIPT: capacitor.build.gradle for Plugin Development Projects
# FIX SCRIPT: Capacitor Auto-Generated Files for Plugin Development Projects
# =============================================================================
#
# PURPOSE: This script fixes a common issue in Capacitor plugin development
# projects where the auto-generated capacitor.build.gradle file tries to load
# a file that doesn't exist, causing build failures.
# PURPOSE: This script fixes common issues in Capacitor plugin development
# projects where auto-generated files have incorrect paths or references.
#
# WHEN TO USE:
# - After running 'npx cap sync'
# - After running 'npx cap update'
# - After running 'npx cap add android'
# - After any Capacitor CLI command that regenerates integration files
# - When you get build errors about missing cordova.variables.gradle
# - When you get build errors about missing files or incorrect paths
#
# WHAT IT DOES:
# - Finds the problematic line in capacitor.build.gradle
# - Comments it out with an explanatory comment
# - Prevents build failures in plugin development projects
# 1. Fixes capacitor.build.gradle (missing cordova.variables.gradle)
# 2. Fixes capacitor.settings.gradle (incorrect plugin path in test-apps)
#
# FIX 1: capacitor.build.gradle
# THE PROBLEM:
# Capacitor generates this line in capacitor.build.gradle:
# apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
# But plugin development projects don't have this file, causing:
# "Could not read script '.../cordova.variables.gradle' as it does not exist"
#
# THE FIX:
# Comments out the problematic line and adds explanation:
# // Plugin development project - no Capacitor integration files needed
# // apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
# FIX 2: capacitor.settings.gradle (Test App)
# THE PROBLEM:
# Capacitor generates this path in test-apps:
# project(':timesafari-daily-notification-plugin').projectDir = new File('../node_modules/@timesafari/daily-notification-plugin/android')
# But the plugin module is actually in android/plugin/, not android root, causing:
# "No matching variant of project :timesafari-daily-notification-plugin was found"
#
# =============================================================================
set -e
CAPACITOR_BUILD_GRADLE="android/app/capacitor.build.gradle"
CAPACITOR_SETTINGS_GRADLE="android/capacitor.settings.gradle"
echo "🔧 DailyNotification Plugin - Capacitor Build Fix Script"
echo "========================================================"
# =============================================================================
# FIX 1: capacitor.build.gradle
# =============================================================================
if [ -f "$CAPACITOR_BUILD_GRADLE" ]; then
echo "📁 Found capacitor.build.gradle at: $CAPACITOR_BUILD_GRADLE"
@ -75,6 +80,53 @@ else
echo " - The file hasn't been generated yet"
fi
# =============================================================================
# FIX 2: capacitor.settings.gradle (Test App Plugin Path)
# =============================================================================
if [ -f "$CAPACITOR_SETTINGS_GRADLE" ]; then
echo ""
echo "📁 Checking capacitor.settings.gradle at: $CAPACITOR_SETTINGS_GRADLE"
# Check if the path points to android instead of android/plugin
# Look for the line without the /plugin suffix
if grep -q "project(':timesafari-daily-notification-plugin').projectDir = new File('../node_modules/@timesafari/daily-notification-plugin/android')$" "$CAPACITOR_SETTINGS_GRADLE"; then
echo "🔧 Applying fix to capacitor.settings.gradle..."
echo " Problem: Path points to 'android' but plugin module is in 'android/plugin'"
echo " Solution: Updating path to 'android/plugin'"
# Apply the fix by updating the path - simpler approach
# Handle macOS vs Linux sed differences
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS sed - replace android')$ with android/plugin')
sed -i '' "s|/android')$|/android/plugin')|" "$CAPACITOR_SETTINGS_GRADLE"
# Add comment before the line if it doesn't already exist
if ! grep -q "NOTE: Plugin module is in android/plugin" "$CAPACITOR_SETTINGS_GRADLE"; then
sed -i '' "/^include ':timesafari-daily-notification-plugin'/a\\
// NOTE: Plugin module is in android/plugin/ subdirectory, not android root\\
// This file is auto-generated by Capacitor, but must be manually corrected for this plugin structure\\
" "$CAPACITOR_SETTINGS_GRADLE"
fi
else
# Linux sed - replace android')$ with android/plugin')
sed -i "s|/android')$|/android/plugin')|" "$CAPACITOR_SETTINGS_GRADLE"
# Add comment before the line if it doesn't already exist
if ! grep -q "NOTE: Plugin module is in android/plugin" "$CAPACITOR_SETTINGS_GRADLE"; then
sed -i "/^include ':timesafari-daily-notification-plugin'/a\\// NOTE: Plugin module is in android/plugin/ subdirectory, not android root\\n// This file is auto-generated by Capacitor, but must be manually corrected for this plugin structure" "$CAPACITOR_SETTINGS_GRADLE"
fi
fi
echo "✅ Fix applied successfully!"
echo "💡 The path has been updated to point to android/plugin/"
echo "⚠️ Note: This fix will be lost if you run Capacitor CLI commands again"
elif grep -q "android/plugin" "$CAPACITOR_SETTINGS_GRADLE"; then
echo "ℹ️ capacitor.settings.gradle already has the correct path (android/plugin)"
else
echo "ℹ️ capacitor.settings.gradle doesn't reference the plugin or uses a different structure"
fi
else
echo "ℹ️ capacitor.settings.gradle not found (may not be a test-app)"
fi
echo ""
echo "📚 For more information, see:"
echo " - BUILDING.md (troubleshooting section)"

11
src/definitions.ts

@ -365,8 +365,13 @@ export interface DailyNotificationPlugin {
* - Production: `"https://api.timesafari.com"`
* - `activeDid` (required): Active DID for authentication.
* Format: `"did:ethr:0x..."`. Used as JWT issuer/subject.
* - `jwtSecret` (required): JWT secret for signing tokens.
* **Keep secure in production!** Consider using secure storage.
* - `jwtToken` (required): Pre-generated JWT token (ES256K signed).
* Generated in TypeScript using TimeSafari's `createEndorserJwtForKey()` function.
* **Note**: Token should be ES256K signed (DID-based), not HS256.
*
* **Architecture Note**: JWT tokens should be generated in TypeScript using TimeSafari's
* `createEndorserJwtForKey()` function (which uses DID-based ES256K signing), then passed
* to this method. This avoids the complexity of implementing DID-based JWT signing in Java.
*
* @throws {Error} If configuration fails (missing params, no fetcher registered, etc.)
*
@ -376,7 +381,7 @@ export interface DailyNotificationPlugin {
configureNativeFetcher(options: {
apiBaseUrl: string;
activeDid: string;
jwtSecret: string;
jwtToken: string; // Pre-generated JWT token (ES256K signed) from TypeScript
}): Promise<void>;
// Rolling window management

141
test-apps/daily-notification-test/IMPLEMENTATION_COMPLETE.md

@ -0,0 +1,141 @@
# Implementation Complete Summary
**Date**: 2025-10-31
**Status**: ✅ **ALL HIGH-PRIORITY ITEMS COMPLETE**
---
## ✅ Completed Implementation
### 1. ES256K JWT Token Generation (Item #1) - **COMPLETE**
**Implementation**:
- Added `did-jwt@^7.0.0` and `ethers@^6.0.0` dependencies
- Implemented `generateEndorserJWT()` function in `test-user-zero.ts`
- Derives Ethereum private key from seed phrase using `ethers.HDNodeWallet`
- Uses `did-jwt.SimpleSigner` for ES256K signing
- Creates proper ES256K signed JWTs matching TimeSafari's pattern
**Files Modified**:
- `test-apps/daily-notification-test/src/config/test-user-zero.ts` - Added `generateEndorserJWT()` function
- `test-apps/daily-notification-test/src/views/HomeView.vue` - Updated to use `generateEndorserJWT()`
- `test-apps/daily-notification-test/package.json` - Added dependencies
**Architecture**:
- JWT generation happens in TypeScript (no Java DID libraries needed)
- Native fetcher receives pre-generated tokens
- Matches TimeSafari's production pattern
---
### 2. Network Security Configuration (Item #15) - **COMPLETE**
**Implementation**:
- Created `network_security_config.xml` allowing cleartext HTTP traffic to `10.0.2.2`, `localhost`, and `127.0.0.1`
- Updated `AndroidManifest.xml` to reference the network security config
**Files Created/Modified**:
- `test-apps/daily-notification-test/android/app/src/main/res/xml/network_security_config.xml` (created)
- `test-apps/daily-notification-test/android/app/src/main/AndroidManifest.xml` (line 10)
**Purpose**:
- Enables HTTP connections to `http://10.0.2.2:3000` from Android emulator
- Required for localhost testing (emulator's special IP for host machine)
---
### 3. TypeScript/Java Interface Updates - **COMPLETE**
**Changes**:
- Updated `configureNativeFetcher()` to accept `jwtToken` instead of `jwtSecret`
- Updated all TypeScript definitions, Java plugin, and native interface
- Removed `as any` type assertion from `HomeView.vue`
- Rebuilt plugin to update `dist/definitions.d.ts`
**Files Modified**:
- `src/definitions.ts` - Updated method signature and JSDoc
- `android/plugin/.../DailyNotificationPlugin.java` - Updated parameter handling
- `android/plugin/.../NativeNotificationContentFetcher.java` - Updated interface
- `test-apps/.../TestNativeFetcher.java` - Simplified (removed JWT generation)
- `test-apps/.../HomeView.vue` - Updated to use new token-based approach
- `dist/definitions.d.ts` - Rebuilt with updated types
---
### 4. TestNativeFetcher Simplification - **COMPLETE**
**Changes**:
- Removed entire `generateJWTToken()` method (HMAC-SHA256 implementation)
- Removed unused imports (`Mac`, `SecretKeySpec`, `Base64`, `MessageDigest`)
- Changed `jwtSecret` field → `jwtToken`
- Updated to use pre-generated token directly in Authorization header
**Files Modified**:
- `test-apps/daily-notification-test/android/app/src/main/java/com/timesafari/dailynotification/test/TestNativeFetcher.java`
---
## 🚀 Ready for Testing
### Prerequisites Complete:
- ✅ ES256K JWT generation implemented
- ✅ Network security config in place
- ✅ All interfaces updated
- ✅ Plugin rebuilt with correct types
### Next Steps for Testing:
1. **Enable Real API Calls**:
```typescript
// In test-apps/daily-notification-test/src/config/test-user-zero.ts
// Change line 28:
serverMode: "localhost" as "localhost" | "staging" | "production" | "mock" | "custom",
```
2. **Build and Run Android Test App**:
```bash
cd test-apps/daily-notification-test
npm run build
npx cap sync android
npx cap run android
```
3. **Verify JWT Generation**:
- Check console logs for JWT generation
- Verify token is passed to native fetcher
- Check logcat for native fetcher configuration
4. **Test API Calls**:
- Schedule a notification
- Verify prefetch occurs
- Check that API calls succeed with ES256K tokens
- Verify notifications appear
---
## 📋 Remaining Optional Tasks
### Medium Priority:
- **Item #4**: Add `root.has("data")` validation in API response parser
- **Item #5**: Enhance logging with timing and structured tags
### Low Priority:
- Items #7-11: Unit tests, JWT utility extraction, interceptors, caching, metrics
### Documentation:
- Update `docs/NATIVE_FETCHER_CONFIGURATION.md` to reflect token-based approach
- Document ES256K JWT generation pattern for TimeSafari integration
---
## 🔗 Related Files
- **TODO Document**: `test-apps/daily-notification-test/TODO_NATIVE_FETCHER.md`
- **Investigation Results**: `test-apps/daily-notification-test/INVESTIGATION_JWT_ALGORITHM_RESULTS.md`
- **Test Config**: `test-apps/daily-notification-test/src/config/test-user-zero.ts`
- **Network Config**: `test-apps/daily-notification-test/android/app/src/main/res/xml/network_security_config.xml`
---
**All critical implementation work is complete. Ready for testing!**

301
test-apps/daily-notification-test/INVESTIGATION_JWT_ALGORITHM.md

@ -0,0 +1,301 @@
# JWT Algorithm Investigation Guide
**Created**: 2025-10-31
**Purpose**: Systematically investigate which JWT signing algorithm endorser-ch and TimeSafari actually use
---
## Investigation Checklist
### Phase 1: TimeSafari Repository (Most Authoritative)
**Repository**: `ssh://git@173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa.git`
#### Step 1: Find `createEndorserJwtForDid` Function
```bash
# Clone or navigate to the repository
cd /path/to/crowd-funder-for-time-pwa
# Search for the function
grep -r "createEndorserJwtForDid" .
grep -r "createEndorserJWT" .
grep -r "createEndorser.*JWT" .
```
**What to look for:**
- Function implementation showing how JWT is generated
- Import statements (e.g., `did-jwt`, `jsonwebtoken`, `jose`, etc.)
- Algorithm parameter (e.g., `alg: 'HS256'`, `algorithm: 'HS256'`, etc.)
- Key/secret usage (shared secret vs DID private key)
#### Step 2: Find Endpoint Usage
```bash
# Search for endpoint usage
grep -r "plansLastUpdatedBetween" .
grep -r "/api/v2/report" .
```
**What to look for:**
- How the JWT is attached to requests (Authorization header)
- What function generates the JWT for this endpoint
- Any configuration or secret management
#### Step 3: Check JWT Generation Code
**Files to examine:**
- Any files matching `*jwt*.ts`, `*jwt*.js`, `*auth*.ts`, `*auth*.js`
- Configuration files (`.env`, `config/*.ts`, `src/config/*.ts`)
- API client files that call endorser endpoints
**Key questions:**
1. Does it use a shared secret (string) or DID private key?
2. What library is used? (`jsonwebtoken`, `did-jwt`, `jose`, etc.)
3. What algorithm is specified in the code?
---
### Phase 2: endorser-ch Repository (Server-Side Verification)
**Repository**: `https://github.com/trentlarson/endorser-ch`
#### Step 1: Find Endpoint Handler
```bash
# Clone or navigate to the repository
cd /path/to/endorser-ch
# Search for endpoint handler
grep -r "plansLastUpdatedBetween" .
grep -r "/api/v2/report" .
```
**Files likely to contain:**
- `src/server.js` or `server/server.js`
- `src/routes.js` or `routes/*.js`
- `src/api/` or `src/controllers/`
#### Step 2: Find Authentication Middleware
```bash
# Search for JWT verification
grep -r "verifyJWT" .
grep -r "verify.*JWT" .
grep -r "Authorization.*Bearer" .
grep -r "did-jwt" .
grep -r "jsonwebtoken" .
```
**What to look for:**
- Middleware that processes `Authorization: Bearer {token}` header
- JWT verification logic
- Algorithm used for verification
- Secret/key used for verification
#### Step 3: Check Configuration for JWT Secret
```bash
# Search for JWT secret configuration
grep -r "JWT_SECRET" .
grep -r "API_SECRET" .
grep -r "jwtSecret" .
grep -r "jwt.*secret" -i .
```
**Files to check:**
- `.env` files
- `config/*.js` or `conf/*.js`
- `package.json` (for dependencies)
**Key questions:**
1. Is there a JWT_SECRET environment variable?
2. Does it use `did-jwt.verifyJWT()` with DID resolution?
3. Or does it use `jsonwebtoken.verify()` with a shared secret?
---
## Expected Findings & Decision Matrix
### Scenario A: HS256 with Shared Secret ✅ (Most Likely)
**Indicators:**
- ✅ TimeSafari code uses `jsonwebtoken.sign()` or similar with a secret string
- ✅ endorser-ch uses `jsonwebtoken.verify()` or similar with a secret
- ✅ Environment variable `JWT_SECRET` exists
- ✅ Code explicitly sets `algorithm: 'HS256'` or `alg: 'HS256'`
**Action**: Our current HMAC-SHA256 implementation is **CORRECT**
### Scenario B: DID-based (ES256K) ⚠️
**Indicators:**
- ⚠️ TimeSafari code uses `did-jwt.createJWT()` or Veramo JWT creation
- ⚠️ endorser-ch uses `did-jwt.verifyJWT()` with DID resolver
- ⚠️ Code uses Ethereum private keys or DID document keys
- ⚠️ No shared secret configuration found
**Action**: Need to implement DID-based signing with ES256K algorithm
### Scenario C: Hybrid Approach 🔄
**Indicators:**
- 🔄 Different algorithms for different endpoints
- 🔄 API auth uses HS256, but claim verification uses DID-based
- 🔄 Multiple authentication methods supported
**Action**: Use HS256 for `/api/v2/report/plansLastUpdatedBetween` endpoint
---
## Investigation Commands Summary
### TimeSafari Repository Investigation
```bash
cd /path/to/crowd-funder-for-time-pwa
# Find JWT generation
grep -r "createEndorserJwtForDid\|createEndorserJWT" . --include="*.ts" --include="*.js"
# Find endpoint usage
grep -r "plansLastUpdatedBetween" . --include="*.ts" --include="*.js"
# Check for JWT libraries
grep -r "jsonwebtoken\|did-jwt\|jose" package.json
grep -r "from.*jsonwebtoken\|from.*did-jwt\|import.*jose" . --include="*.ts" --include="*.js"
# Check for secret usage
grep -r "jwtSecret\|JWT_SECRET\|shared.*secret" . --include="*.ts" --include="*.js" -i
```
### endorser-ch Repository Investigation
```bash
cd /path/to/endorser-ch
# Find endpoint handler
grep -r "plansLastUpdatedBetween" . --include="*.js" --include="*.ts"
# Find authentication middleware
grep -r "Authorization.*Bearer\|verifyJWT\|verify.*JWT" . --include="*.js" --include="*.ts"
# Check for JWT libraries
grep -r "jsonwebtoken\|did-jwt\|jose" package.json
# Check for secret configuration
grep -r "JWT_SECRET\|API_SECRET\|jwt.*secret" . -i --include="*.js" --include="*.env*"
```
---
## Documentation References to Review
### TimeSafari Repository
**Look for:**
- API client documentation
- Authentication/authorization documentation
- JWT generation examples
- Configuration documentation
**Files to check:**
- `README.md`
- `docs/api.md` or `docs/auth.md`
- `src/api/` or `src/services/` directories
- Configuration files in `src/config/` or root
### endorser-ch Repository
**Look for:**
- API documentation
- Authentication documentation
- README.md (already mentions `did-jwt` in JWT verification section)
- Route handler documentation
**Files to check:**
- `README.md` (check JWT verification section mentioned)
- `src/` or `server/` directories
- Route files
- Authentication middleware files
---
## Quick Decision Guide
After investigation, use this decision matrix:
| Finding | Algorithm | Implementation Status |
|---------|-----------|----------------------|
| `jsonwebtoken` + `JWT_SECRET` | HS256 | ✅ Already implemented |
| `did-jwt` + DID resolution | ES256K | ❌ Need to implement |
| Both found (hybrid) | HS256 for API | ✅ Already implemented |
| Can't determine | Test both | ⚠️ Implement both options |
---
## Next Steps After Investigation
1. **If HS256 confirmed:**
- ✅ Mark TODO item #1 as complete
- ✅ Current implementation is correct
- ✅ Test against API
2. **If DID-based confirmed:**
- ❌ Need to implement DID-based signing
- Add dependency: `did-jwt-java` or `web3j` for Ethereum signing
- Update `generateJWTToken()` to use DID private keys
- Update TODO with findings
3. **If hybrid or unclear:**
- Document findings
- Implement both if needed
- Add configuration option to switch between methods
---
## Investigation Results Template
After investigation, fill out:
```markdown
## Investigation Results
**Date**: YYYY-MM-DD
**Investigator**: [Name]
### TimeSafari Repository Findings
**createEndorserJwtForDid function:**
- Location: `[file path]`
- Library used: `[jsonwebtoken/did-jwt/etc]`
- Algorithm: `[HS256/ES256K/etc]`
- Key type: `[shared secret/DID private key]`
- Code snippet: `[paste relevant code]`
**Endpoint usage:**
- Location: `[file path]`
- How JWT is generated: `[description]`
- Code snippet: `[paste relevant code]`
### endorser-ch Repository Findings
**Endpoint handler:**
- Location: `[file path]`
- Authentication middleware: `[description]`
- Verification method: `[jsonwebtoken.verify/did-jwt.verifyJWT/etc]`
- Algorithm: `[HS256/ES256K/etc]`
- Secret/key source: `[environment variable/DID resolution/etc]`
- Code snippet: `[paste relevant code]`
### Conclusion
**Algorithm**: [HS256/ES256K/Hybrid/Unclear]
**Action Required**: [Update implementation/Mark complete/etc]
```
---
**Status**: Ready for investigation
**Priority**: CRITICAL - Blocks API authentication

220
test-apps/daily-notification-test/INVESTIGATION_JWT_ALGORITHM_RESULTS.md

@ -0,0 +1,220 @@
# JWT Algorithm Investigation Results
**Date**: 2025-10-31
**Investigator**: Auto (AI Assistant)
**Status**: ✅ **INVESTIGATION COMPLETE**
---
## 🔴 CRITICAL FINDING: DID-Based JWT Signing Required (ES256K)
### Conclusion
**The endorser-ch API expects DID-based JWTs signed with ES256K (or ES256K-R), NOT HMAC-SHA256 (HS256).**
The current implementation in `TestNativeFetcher.java` using HMAC-SHA256 is **INCORRECT** and will fail authentication.
---
## Investigation Details
### TimeSafari Repository Findings
**Location**: `~/projects/timesafari/crowd-funder-for-time-pwa/src/libs/crypto/vc/index.ts`
**Function**: `createEndorserJwtForKey()`
**Implementation**:
```typescript
export async function createEndorserJwtForKey(
account: KeyMetaWithPrivate,
payload: object,
expiresIn?: number,
) {
if (account?.identity) {
const identity: IIdentifier = JSON.parse(account.identity!);
const privateKeyHex = identity.keys[0].privateKeyHex;
const signer = await SimpleSigner(privateKeyHex as string);
const options = {
// alg: "ES256K", // "K" is the default, "K-R" is used by the server in tests
issuer: account.did,
signer: signer,
expiresIn: undefined as number | undefined,
};
if (expiresIn) {
options.expiresIn = expiresIn;
}
return didJwt.createJWT(payload, options);
}
// ... passkey handling ...
}
```
**Key Points**:
- Uses `did-jwt.createJWT()` library
- Uses `SimpleSigner(privateKeyHex)` - signs with Ethereum private key
- Algorithm is **ES256K** (default)
- Signs with DID private key, NOT a shared secret
**Library Used**: `did-jwt` (DID-based JWT library)
---
### endorser-ch Repository Findings
**Location**: `~/projects/timesafari/endorser-ch/src/api/services/vc/index.js`
**Function**: `decodeAndVerifyJwt()`
**Implementation**:
```javascript
export async function decodeAndVerifyJwt(jwt) {
const pieces = jwt.split('.')
const header = JSON.parse(base64url.decode(pieces[0]))
const payload = JSON.parse(base64url.decode(pieces[1]))
const issuerDid = payload.iss
if (issuerDid.startsWith(ETHR_DID_PREFIX)) {
try {
const verifiedResult = await didJwt.verifyJWT(jwt, {resolver})
return verifiedResult
} catch (e) {
return Promise.reject({
clientError: {
message: `JWT failed verification: ` + e.toString(),
code: JWT_VERIFY_FAILED_CODE
}
})
}
}
// ... other DID methods ...
}
```
**Key Points**:
- Uses `did-jwt.verifyJWT(jwt, {resolver})` - DID-based verification
- Verifies signature using DID resolver (resolves DID to public key)
- **NO shared secret used** - uses DID public key from resolver
- Algorithm: **ES256K** (implicit from did-jwt library)
**Middleware Location**: `~/projects/timesafari/endorser-ch/src/common/server.js`
**Authentication Flow**:
```javascript
decodeAndVerifyJwt(authorizationJwt)
.then((result) => {
const { header, issuer, payload, verified } = result
if (!verified) {
res.status(400).json({
error: {
message: "Signature failed validation.",
code: ERROR_CODES.JWT_VERIFY_FAILED
}
}).end()
} else {
res.locals.authTokenIssuer = issuer
next()
}
})
```
---
## Impact on Current Implementation
### Current Implementation (WRONG)
**File**: `test-apps/daily-notification-test/android/app/src/main/java/com/timesafari/dailynotification/test/TestNativeFetcher.java`
**Current Approach**: HMAC-SHA256 with shared secret
```java
Mac hmac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKey = new SecretKeySpec(
jwtSecret.getBytes(StandardCharsets.UTF_8),
"HmacSHA256"
);
hmac.init(secretKey);
byte[] signatureBytes = hmac.doFinal(unsignedToken.getBytes(StandardCharsets.UTF_8));
```
**Problem**: Server expects ES256K with DID private key, NOT HMAC-SHA256 with shared secret
---
## Required Changes
### Action Required
1. **Remove HMAC-SHA256 implementation** - This is completely wrong
2. **Implement DID-based signing (ES256K)** - Sign with Ethereum private key
3. **Access DID private key** - Need to retrieve private key from DID/account storage
4. **Use did-jwt-java or web3j** - Java library for DID-based JWT signing
### Implementation Options
#### Option 1: Use did-jwt-java Library (Recommended)
```java
// Add dependency to build.gradle
implementation 'io.uport:uport-did-jwt:3.1.0'
// Sign with DID private key
import io.uport.sdk.did.jwt.DIDJWT;
import io.uport.sdk.did.jwt.SimpleSigner;
String privateKeyHex = getPrivateKeyForDid(activeDid); // Need to implement
SimpleSigner signer = new SimpleSigner(privateKeyHex);
String jwt = DIDJWT.createJWT(payload, signer, issuer: activeDid);
```
#### Option 2: Use web3j for Ethereum Signing
```java
// Add dependency
implementation 'org.web3j:core:4.9.8'
import org.web3j.crypto.ECKeyPair;
import org.web3j.crypto.Sign;
ECKeyPair keyPair = getKeyPairForDid(activeDid); // Need to implement
Sign.SignatureData signature = Sign.signMessage(
unsignedToken.getBytes(StandardCharsets.UTF_8),
keyPair
);
// Then encode signature according to ES256K format
```
---
## Next Steps
1. **Remove `jwtSecret` parameter** - No longer needed (shared secret not used)
2. **Add DID private key retrieval** - Need mechanism to get private key for `activeDid`
3. **Implement ES256K signing** - Using did-jwt-java or web3j
4. **Update `configureNativeFetcher()`** - Remove `jwtSecret`, add private key retrieval mechanism
5. **Test with real API** - Verify JWTs are accepted by endorser-ch server
---
## References
- **TimeSafari Implementation**: `~/projects/timesafari/crowd-funder-for-time-pwa/src/libs/crypto/vc/index.ts`
- **endorser-ch Verification**: `~/projects/timesafari/endorser-ch/src/api/services/vc/index.js`
- **did-jwt Library**: https://github.com/decentralized-identity/did-jwt
- **did-jwt-java**: https://github.com/uport-project/uport-did-jwt (if available) or alternative Java DID libraries
---
## Evidence Summary
| Component | Finding | Evidence |
|-----------|---------|----------|
| **TimeSafari JWT Creation** | ✅ DID-based (ES256K) | Uses `didJwt.createJWT()` with `SimpleSigner(privateKeyHex)` |
| **endorser-ch JWT Verification** | ✅ DID-based (ES256K) | Uses `didJwt.verifyJWT(jwt, {resolver})` |
| **Current TestNativeFetcher** | ❌ HMAC-SHA256 | Uses `Mac.getInstance("HmacSHA256")` with shared secret |
| **Shared Secret Config** | ❌ Not Used | No `JWT_SECRET` found in endorser-ch, no shared secret in TimeSafari |
---
**Status**: Investigation complete. Implementation changes required.

20
test-apps/daily-notification-test/README.md

@ -29,6 +29,26 @@ See [Vite Configuration Reference](https://vite.dev/config/).
npm install
```
**Note**: The `postinstall` script automatically fixes Capacitor configuration files after installation.
### Capacitor Sync (Android)
**Important**: Use the wrapper script instead of `npx cap sync` directly to automatically fix plugin paths:
```sh
npm run cap:sync
```
This will:
1. Run `npx cap sync android`
2. Automatically fix `capacitor.settings.gradle` (corrects plugin path from `android/` to `android/plugin/`)
3. Ensure `capacitor.plugins.json` has the correct plugin registration
If you run `npx cap sync android` directly, you can manually fix afterward:
```sh
node scripts/fix-capacitor-plugins.js
```
### Compile and Hot-Reload for Development
```sh

677
test-apps/daily-notification-test/TODO_NATIVE_FETCHER.md

@ -0,0 +1,677 @@
# TODO: Test App Native Fetcher Improvements
**Created**: 2025-10-31
**Updated**: 2025-10-31 (implementation complete: ES256K JWT generation and network security config)
**Context**: Review of `TestNativeFetcher.java` implementation for endorser API integration
**Status**: ✅ **IMPLEMENTATION COMPLETE** - All high-priority items complete. Item #1 (ES256K JWT generation) implemented using `did-jwt` and `ethers`. Item #15 (Network Security Config) created. All TypeScript/Java interfaces updated. Plugin rebuilt with updated definitions.
---
## 📊 Progress Summary
### ✅ Completed High-Priority Items
- **Item #1**: JWT Algorithm Investigation - ✅ **COMPLETE** (ES256K requirement confirmed, implementation pending)
- **Item #2**: SharedPreferences Persistence - ✅ **COMPLETE**
- **Item #3**: Error Handling and Retry Logic - ✅ **COMPLETE**
- **Item #6**: Context Management - ✅ **COMPLETE**
### ✅ Critical Implementation Complete (Item #1)
- **ES256K JWT Token Generation** - ✅ **COMPLETE**: Implemented `generateEndorserJWT()` function using `did-jwt` and `ethers` libraries. Generates proper ES256K signed JWTs from test user zero's seed phrase. Native fetcher now receives pre-generated tokens, avoiding Java DID library complexity.
### 🟡 Medium Priority Status
- **Item #4**: API Response Structure Validation - ✅ **VERIFIED** (structure verified, validation improvements needed - see details below)
- **Item #5**: Comprehensive Logging - Partial (basic logging exists, more comprehensive needed)
### 🟢 Low Priority
- Items #7-11: All pending
### 📝 Configuration Tasks
- **Item #13**: Real API Calls - Ready (can now enable localhost mode, ES256K JWT generation complete)
- **Item #14**: TypeScript Types - ✅ **COMPLETE** (plugin rebuilt, types updated, `as any` removed)
- **Item #15**: Network Security Config - ✅ **COMPLETE** (config created, AndroidManifest updated)
---
## 🔴 High Priority
### 1. ⚠️ VERIFY: Determine Correct JWT Signing Algorithm (CRITICAL)
- [x] **Status**: ✅ **INVESTIGATION COMPLETE** - See `INVESTIGATION_JWT_ALGORITHM_RESULTS.md`
**CRITICAL FINDING**: Endorser-ch API expects **DID-based JWTs (ES256K)**, NOT HMAC-SHA256. Current implementation is WRONG and must be replaced.
**Current State**: Uses SHA-256 hash with `jwtSecret:unsignedToken` format, header claims `"alg": "HS256"`
**Location**: `TestNativeFetcher.java``generateJWTToken()`
**Investigation Results** (See `INVESTIGATION_JWT_ALGORITHM_RESULTS.md` for full details):
**✅ CONFIRMED**: Endorser-ch API uses **DID-based JWTs (ES256K)**, NOT HMAC-SHA256.
**TimeSafari Implementation** (`~/projects/timesafari/crowd-funder-for-time-pwa/src/libs/crypto/vc/index.ts`):
- Uses `did-jwt.createJWT()` with `SimpleSigner(privateKeyHex)`
- Signs with Ethereum private key from DID identity
- Algorithm: **ES256K** (default for did-jwt library)
**endorser-ch Verification** (`~/projects/timesafari/endorser-ch/src/api/services/vc/index.js`):
- Uses `did-jwt.verifyJWT(jwt, {resolver})` for verification
- Verifies signature using DID resolver (resolves DID to public key)
- **NO shared secret used** - authentication is DID-based
**Current Implementation Problem**:
- Currently uses HMAC-SHA256 with shared secret (`jwtSecret`)
- Server will **reject** these tokens as invalid
- Must be replaced with DID-based ES256K signing
**Required Implementation Changes:**
1. **Remove HMAC-SHA256 implementation** - Completely incorrect approach
2. **Remove `jwtSecret` parameter** - No longer needed (shared secret not used)
3. **Implement DID-based ES256K signing** - Sign with Ethereum private key
4. **Add DID private key retrieval** - Need mechanism to get private key for `activeDid`
5. **Use Java DID library** - did-jwt-java or web3j for ES256K signing
**Implementation Options:**
**Option 1: Use did-jwt-java Library (Recommended)**
```java
// Add dependency to build.gradle
implementation 'io.uport:uport-did-jwt:3.1.0' // or equivalent
// Sign with DID private key
import io.uport.sdk.did.jwt.DIDJWT;
import io.uport.sdk.did.jwt.SimpleSigner;
String privateKeyHex = getPrivateKeyForDid(activeDid); // Need to implement
SimpleSigner signer = new SimpleSigner(privateKeyHex);
String jwt = DIDJWT.createJWT(payload, signer, issuer: activeDid);
```
**Option 2: Use web3j for Ethereum Signing**
```java
// Add dependency
implementation 'org.web3j:core:4.9.8'
import org.web3j.crypto.ECKeyPair;
import org.web3j.crypto.Sign;
ECKeyPair keyPair = getKeyPairForDid(activeDid); // Need to implement
Sign.SignatureData signature = Sign.signMessage(
unsignedToken.getBytes(StandardCharsets.UTF_8),
keyPair
);
// Then encode signature according to ES256K format for JWT
```
**References:**
- **Investigation Results**: See `INVESTIGATION_JWT_ALGORITHM_RESULTS.md` for complete findings
- **TimeSafari Implementation**: `~/projects/timesafari/crowd-funder-for-time-pwa/src/libs/crypto/vc/index.ts` - `createEndorserJwtForKey()` function
- **endorser-ch Verification**: `~/projects/timesafari/endorser-ch/src/api/services/vc/index.js` - `decodeAndVerifyJwt()` function
- [did-jwt library](https://github.com/decentralized-identity/did-jwt) - DID-based JWT library (JavaScript reference)
- [did-jwt-java](https://github.com/uport-project/uport-did-jwt) - Java implementation (if available) or alternative Java DID libraries
**Impact**: **CRITICAL** - Current HMAC-SHA256 implementation will be rejected by API server. All authentication will fail until DID-based ES256K signing is implemented.
**✅ ARCHITECTURAL DECISION**: Generate JWT in TypeScript (host app), pass token to native fetcher
**Rationale**:
- TimeSafari already has `did-jwt` library available in TypeScript
- TimeSafari can access DID private keys via Capacitor SQLite (`retrieveFullyDecryptedAccount()`)
- Plugin SPI pattern is designed for host app to provide implementations
- Avoids complexity of finding/implementing Java DID libraries
- Keeps cryptography in TypeScript where libraries are mature
**Updated Implementation Approach**:
**Option A: Generate JWT in TypeScript, Pass Token (RECOMMENDED)**
```typescript
// In HomeView.vue or TimeSafari app
import { createEndorserJwtForDid } from '@/libs/endorserServer';
// Generate JWT in TypeScript using existing TimeSafari infrastructure
const account = await retrieveFullyDecryptedAccount(activeDid);
const jwtToken = await createEndorserJwtForDid(activeDid, {
exp: Math.floor(Date.now() / 1000) + 60,
iat: Math.floor(Date.now() / 1000),
iss: activeDid
});
// Pass JWT token to native fetcher (no private key needed)
await DailyNotification.configureNativeFetcher({
apiBaseUrl: 'http://10.0.2.2:3000',
activeDid: activeDid,
jwtToken: jwtToken // Pre-generated token, not secret
});
```
```java
// In TestNativeFetcher.java - Just use the token, don't generate it
private volatile String jwtToken; // Pre-generated token from TypeScript
public void configure(String apiBaseUrl, String activeDid, String jwtToken) {
this.apiBaseUrl = apiBaseUrl;
this.activeDid = activeDid;
this.jwtToken = jwtToken; // Use pre-generated token
}
private String getAuthorizationHeader() {
return "Bearer " + jwtToken;
}
```
**Benefits**:
- ✅ No Java DID library needed
- ✅ Uses existing TimeSafari infrastructure (`did-jwt`, `retrieveFullyDecryptedAccount`)
- ✅ Keeps cryptography in TypeScript (where it's proven to work)
- ✅ Native fetcher just uses token (simple, no crypto complexity)
- ✅ Token can be refreshed in TypeScript when needed
**Next Actions**:
- [ ] Update `configureNativeFetcher()` TypeScript signature to accept `jwtToken` instead of `jwtSecret`
- [ ] Update Java `configureNativeFetcher()` to accept `jwtToken` parameter
- [ ] Remove JWT generation code from `TestNativeFetcher.generateJWTToken()`
- [ ] Use pre-generated token in `getAuthorizationHeader()` method
- [ ] Update `HomeView.vue` to generate JWT using TimeSafari's `createEndorserJwtForDid()`
- [ ] For TimeSafari integration: Generate JWT in host app using account data from SQLite
- [ ] Test with real endorser-ch API to verify tokens are accepted
**Testing**:
- [x] ✅ Verified algorithm with endorser-ch source code - ES256K confirmed
- [x] ✅ Generate JWT in TypeScript using `did-jwt` library (ES256K signing)
- [x] ✅ Pass pre-generated JWT token to native fetcher via `configureNativeFetcher()`
- [ ] ⚠️ Verify tokens are accepted by the endorser API endpoint (pending real API testing)
- [ ] Implement token refresh mechanism in TypeScript when tokens expire (60 seconds)
**Implementation Checklist**:
**1. Update TypeScript Interface (`src/definitions.ts`)**:
- [x] ✅ Change `jwtSecret: string``jwtToken: string` in `configureNativeFetcher()` signature
- [x] ✅ Update JSDoc to reflect that token is pre-generated, not a secret
- [x] ✅ Rebuild plugin to update `dist/definitions.d.ts`
**2. Update Java Plugin (`android/plugin/.../DailyNotificationPlugin.java`)**:
- [x] ✅ Change `String jwtSecret = call.getString("jwtSecret")``String jwtToken = call.getString("jwtToken")`
- [x] ✅ Update validation message: "Missing required parameters: apiBaseUrl, activeDid, and jwtToken are required"
- [x] ✅ Update `fetcher.configure(apiBaseUrl, activeDid, jwtToken)` call
- [x] ✅ Update Javadoc to reflect token-based approach
**3. Update Native Interface (`android/plugin/.../NativeNotificationContentFetcher.java`)**:
- [x] ✅ Change `default void configure(String apiBaseUrl, String activeDid, String jwtSecret)`
`default void configure(String apiBaseUrl, String activeDid, String jwtToken)`
- [x] ✅ Update Javadoc to explain token is pre-generated by TypeScript
**4. Update TestNativeFetcher (`test-apps/.../TestNativeFetcher.java`)**:
- [x] ✅ Change `private volatile String jwtSecret;``private volatile String jwtToken;`
- [x] ✅ Update `configure()` method signature and implementation
- [x] ✅ Remove entire `generateJWTToken()` method (HMAC-SHA256 implementation removed)
- [x] ✅ Update `fetchContentWithRetry()` to use `jwtToken` directly
- [x] ✅ Remove JWT-related constants (`JWT_EXPIRATION_MINUTES`) - no longer needed
- [x] ✅ Remove HMAC-SHA256 imports (`Mac`, `SecretKeySpec`, `Base64`, `MessageDigest`) - not used elsewhere
**5. Update HomeView.vue (`test-apps/.../HomeView.vue`)**:
- [x] ✅ Implement ES256K JWT generation using `did-jwt` and `ethers` libraries
- [x] ✅ Generate JWT before calling `configureNativeFetcher()` using `generateEndorserJWT()`
- [x] ✅ Update `configureNativeFetcher()` call to use `jwtToken` instead of `jwtSecret`
- [x] ✅ Remove `as any` type assertion after plugin rebuild
**6. ES256K JWT Generation Implementation (`test-apps/.../test-user-zero.ts`)**:
- [x] ✅ Add `did-jwt@^7.0.0` and `ethers@^6.0.0` dependencies
- [x] ✅ Implement `generateEndorserJWT()` function:
- Derives Ethereum private key from seed phrase using `ethers.HDNodeWallet`
- Uses `did-jwt.SimpleSigner` for ES256K signing
- Creates proper ES256K signed JWTs matching TimeSafari's pattern
- [x] ✅ Export `generateEndorserJWT` for use in HomeView.vue
**6. For TimeSafari Production Integration**:
- [ ] In TimeSafari app, generate JWT using `createEndorserJwtForKey()` with account from SQLite
- [ ] Call `DailyNotification.configureNativeFetcher()` with generated token
- [ ] Implement token refresh logic when tokens expire (60 seconds)
- [ ] Handle token expiration errors and regenerate tokens
**7. Documentation Updates**:
- [ ] Update `docs/NATIVE_FETCHER_CONFIGURATION.md` to reflect token-based approach
- [ ] Document how to generate tokens in TypeScript using TimeSafari infrastructure
- [ ] Remove references to `jwtSecret` and HMAC-SHA256
---
### 2. Implement SharedPreferences Persistence for Configuration
- [x] **Status**: ✅ **COMPLETE**
**Current State**: ✅ Implemented - `getStarredPlanIds()` and `getLastAcknowledgedJwtId()` use SharedPreferences
**Location**: `TestNativeFetcher.java``getStarredPlanIds()`, `getLastAcknowledgedJwtId()`
**Implementation Status**: ✅ **COMPLETE**
- ✅ SharedPreferences initialized in constructor with `PREFS_NAME = "DailyNotificationPrefs"`
- ✅ `getStarredPlanIds()` - Loads JSON array from SharedPreferences
- ✅ `getLastAcknowledgedJwtId()` - Loads JWT ID from SharedPreferences
- ✅ `updateStarredPlanIds()` - Saves plan IDs to SharedPreferences
- ✅ `updateLastAckedJwtId()` - Saves JWT ID to SharedPreferences
- ✅ Context passed to constructor and stored as `appContext`
**Impact**: ✅ Proper state management and pagination support enabled
---
### 3. Add Error Handling and Retry Logic
- [x] **Status**: ✅ **COMPLETE**
**Current State**: ✅ Implemented - `fetchContentWithRetry()` with exponential backoff
**Location**: `TestNativeFetcher.java``fetchContentWithRetry()`
**Implementation Status**:
- [x] ✅ Retry logic for network failures (transient errors) - `fetchContentWithRetry()` with `MAX_RETRIES = 3`
- [x] ✅ Distinguish retryable vs non-retryable errors - `shouldRetry()` method checks response codes and retry count
- [x] ✅ Log error details for debugging - Detailed logging with retry counts and error messages
- [x] ✅ Exponential backoff for retries - `RETRY_DELAY_MS * (1 << retryCount)` implemented
**Suggested Implementation**:
```java
private static final int MAX_RETRIES = 3;
private static final int RETRY_DELAY_MS = 1000;
private CompletableFuture<List<NotificationContent>> fetchContentWithRetry(
FetchContext context, int retryCount) {
// ... existing fetch logic ...
if (responseCode == 401 || responseCode == 403) {
// Non-retryable - authentication issue
Log.e(TAG, "Authentication failed - check credentials");
return CompletableFuture.completedFuture(Collections.emptyList());
}
if (responseCode >= 500 && retryCount < MAX_RETRIES) {
// Retryable - server error
Log.w(TAG, "Server error " + responseCode + ", retrying... (" + retryCount + "/" + MAX_RETRIES + ")");
Thread.sleep(RETRY_DELAY_MS * (1 << retryCount)); // Exponential backoff
return fetchContentWithRetry(context, retryCount + 1);
}
}
```
**Impact**: Improves reliability for transient network issues
---
## 🟡 Medium Priority
### 4. Validate API Response Structure (Need to Verify Actual Structure)
- [x] **Status**: ✅ **VERIFIED** - Endpoint structure verified, parser validation needs improvement
**Current State**: Parser handles both flat and nested structures, but could add more validation
**Location**: `TestNativeFetcher.java``parseApiResponse()`
**✅ Endpoint Structure Verified:**
- [x] ✅ **Check endorser-ch endpoint implementation:**
- [x] ✅ Found `/api/v2/report/plansLastUpdatedBetween` handler in endorser-ch source: `~/projects/timesafari/endorser-ch/src/api/controllers/report-router.js`
- [x] ✅ Verified actual response structure - Returns `{ data: [...], hitLimit: boolean }` where data items have `plan` and `wrappedClaimBefore` properties
- [x] ✅ Response matches `PlanSummaryAndPreviousClaim` structure - Items have `plan.handleId`, `plan.jwtId`, and `wrappedClaimBefore` properties
- [x] ✅ **Current parser handles:**
- [x] ✅ Both flat structure (`jwtId`, `planId`) and nested structure (`plan.jwtId`, `plan.handleId`)
- [ ] ⚠️ **Missing**: Does NOT extract `wrappedClaimBefore` from response (exists in API response but not used in parser)
**✅ Verification Results:**
**Null Checks Status:**
- [x] ✅ Uses `.has()` checks before accessing fields (planId, jwtId) - Lines 502, 511
- [x] ✅ Checks `item.has("plan")` before accessing plan object - Lines 504, 513
- [x] ✅ Checks `dataArray != null` after getting it - Line 491
- [x] ✅ Handles null values safely (planId and jwtId can be null) - Lines 499-518
- [x] ✅ Wrapped in try-catch for error handling - Line 485, 570
- [ ] ⚠️ **Missing**: No check for `root.has("data")` before calling `getAsJsonArray("data")` (line 490) - Could throw `IllegalStateException` if "data" field missing or wrong type
- [ ] ⚠️ **Potential Issue**: `getAsJsonObject("plan")` (lines 505, 514) could throw if "plan" exists but is not a JsonObject (currently caught by try-catch)
**Structure Validation Status:**
- [ ] ⚠️ **Missing**: No validation that root has "data" field before accessing (line 490)
- [ ] ⚠️ **Missing**: No warning logged if "data" field is missing or wrong type (would be caught by try-catch, but no specific warning)
- [x] ✅ Handles missing/null data gracefully (creates default notification if contents empty) - Line 554-568
- [x] ✅ Handles parse errors gracefully (returns empty list on exception) - Line 570-573
**Nested Structure Handling:**
- [x] ✅ Handle nested structures correctly - Parser handles both `item.plan.handleId` and `item.planId` formats
- [x] ✅ Handles both flat (`jwtId`, `planId`) and nested (`plan.jwtId`, `plan.handleId`) structures
**Remaining Work:**
- [ ] Add `root.has("data")` check before accessing data array (line 490 - currently could throw if "data" missing)
- [ ] Add null check for `plan` object before accessing nested fields (line 505, 514 - currently relies on try-catch)
- [ ] Log warnings for unexpected response formats (missing "data" field, wrong types)
- [ ] Extract `wrappedClaimBefore` from response items (API provides this but parser doesn't use it)
**Suggested Improvements**:
```java
private List<NotificationContent> parseApiResponse(String responseBody, FetchContext context) {
try {
JsonParser parser = new JsonParser();
JsonObject root = parser.parse(responseBody).getAsJsonObject();
// Validate response structure
if (!root.has("data")) {
Log.w(TAG, "API response missing 'data' field");
return Collections.emptyList();
}
JsonArray dataArray = root.getAsJsonArray("data");
if (dataArray == null) {
Log.w(TAG, "API response 'data' field is not an array");
return Collections.emptyList();
}
// ... rest of parsing with null checks ...
}
}
```
**Impact**: Prevents crashes on unexpected API responses
---
### 5. Add Comprehensive Logging for Debugging
- [ ] **Status**: Partial - Basic logging exists, more comprehensive logging needed
**Current State**: Basic logging present (Log.d/i/e/w), but could be more detailed and structured
**Location**: Throughout `TestNativeFetcher.java`
**Current Implementation**:
- ✅ Uses Log.d/i/e/w for different severity levels
- ✅ Some structured tags (e.g., "TestNativeFetcher")
- ✅ Logs error details and retry information
**Required Changes**:
- [ ] Add more comprehensive log levels (DEBUG, INFO, WARN, ERROR) - Basic levels exist but could be more systematic
- [ ] Log request/response details (truncated for sensitive data) - Some logging exists, could be more comprehensive
- [ ] Log timing information for performance monitoring - Not currently implemented (timing would be helpful)
- [ ] Add structured logging tags for filtering - Basic tags exist, could be more structured with consistent prefixes
**Suggested Logging Points**:
- Configuration received
- Fetch start/end with timing
- HTTP request details (URL, method, headers - sanitized)
- Response code and size
- Parse success/failure
- Number of notifications created
**Example**:
```java
Log.d(TAG, String.format("FETCH_START trigger=%s scheduledTime=%d fetchTime=%d",
context.trigger, context.scheduledTime, context.fetchTime));
long startTime = System.currentTimeMillis();
// ... radius logic ...
long duration = System.currentTimeMillis() - startTime;
Log.i(TAG, String.format("FETCH_COMPLETE duration=%dms items=%d",
duration, contents.size()));
```
---
### 6. Implement Proper Context Management
- [x] **Status**: ✅ **COMPLETE**
**Current State**: ✅ Context passed to constructor and stored
**Location**: `TestNativeFetcher.java` constructor, `TestApplication.java`
**Implementation Status**:
- [x] ✅ Pass `Application` context to `TestNativeFetcher` constructor - Done in `TestApplication.onCreate()`
- [x] ✅ Store context for SharedPreferences access - Stored as `appContext` and used for SharedPreferences
- [x] ✅ Using application context (no leaks) - `context.getApplicationContext()` used
**Example**:
```java
// In TestNativeFetcher:
private final Context appContext;
public TestNativeFetcher(Context context) {
this.appContext = context.getApplicationContext(); // Use app context
// ... initialize SharedPreferences ...
}
// In TestApplication.onCreate():
Context context = getApplicationContext();
TestNativeFetcher fetcher = new TestNativeFetcher(context);
```
---
## 🟢 Low Priority / Nice to Have
### 7. Add Unit Tests
- [ ] **Status**: Not started
**Location**: Create `TestNativeFetcher/CONTENT_FETCHERTest.java`
**Coverage Needed**:
- [ ] JWT token generation (verify signature format)
- [ ] API response parsing (valid and invalid responses)
- [ ] Error handling (network failures, invalid responses)
- [ ] Configuration updates
- [ ] SharedPreferences integration
**Test Framework**: JUnit 4/5 + Mockito
---
### 8. Extract JWT Generation to Utility Class
- [ ] **Status**: Not started
**Current State**: JWT generation code embedded in fetcher
**Benefit**: Reusable across other parts of the app, easier to test
**Location**: Create `test-apps/daily-notification-test/android/app/src/main/java/com/timesafari/dailynotification/test/JwtUtils.java`
---
### 9. Add Request/Response Interceptors for Development
- [ ] **Status**: Not started
**Purpose**: Log full request/response for API debugging
**Implementation**:
- [ ] Add methods to log full HTTP requests/responses (with data sanitization)
**Note**: Only enable in debug builds for security
---
### 10. Support API Response Caching
- [ ] **Status**: Not started
**Current State**: Always fetches from API
**Enhancement**:
- [ ] Cache successful responses with TTL
- [ ] Use cached data if available and fresh
**Use Case**: Reduce API calls when multiple notifications scheduled close together
---
### 11. Add Metrics/Telemetry
- [ ] **Status**: Not started
**Purpose**: Track fetch success rates, latency, error types
**Implementation**:
- [ ] Record metrics to SharedPreferences or analytics service
- [ ] Track: success/failure rate, average response time, error distribution
- [ ] Log summary periodically
---
### 12. ✅ MOVED TO HIGH PRIORITY: Verify API Response Structure
**This item has been moved to High Priority item #4** - Validate API Response Structure
**Action Required** (covered in item #4 above):
- [x] ✅ Check endorser-ch source code for actual endpoint response structure
- [x] ✅ Compare with `PlansLastUpdatedResponse` and `PlanSummaryWithPreviousClaim` interfaces in codebase
- [x] ✅ Update parser if structure differs - Parser handles both flat and nested structures
- [ ] Add validation for required fields - Additional validation still needed
- [ ] Test with real API responses from endorser-ch server - Pending real API testing
**Location**:
- Check endorser-ch source: `src/` or `server/` directories for endpoint handler
- Check `src/definitions.ts` in this codebase for expected interface structure
- Test with real responses from running endorser-ch server
---
## 📝 Configuration & Setup Tasks
### 13. Enable Real API Calls in Test Config
- [ ] **Status**: Ready to enable - ES256K JWT generation complete, network config ready
**Current State**: `TEST_USER_ZERO_CONFIG.api.serverMode = "mock"`
**Ready to Enable**: ES256K JWT generation is complete, network security config is in place
**Action**:
- [ ] Change `serverMode` to `"localhost"` in `test-user-zero.ts` (line 28) for emulator testing
- [ ] Test API calls to `http://10.0.2.2:3000` with real ES256K signed tokens
- [ ] Verify tokens are accepted by endorser-ch API
- [ ] Update to `"staging"` or `"production"` for real environment testing when ready
**Location**: `test-apps/daily-notification-test/src/config/test-user-zero.ts` (line 28)
**Note**: JWT generation now uses `generateEndorserJWT()` which creates proper ES256K signed tokens from the seed phrase.
---
### 14. Update TypeScript Type Definitions
- [x] **Status**: ✅ **VERIFIED** - Type definitions exist and are being used, may need updates after ES256K changes
**Current State**: Using `as any` type assertion for `configureNativeFetcher` in HomeView.vue
**Verification Results:**
- [x] ✅ `configureNativeFetcher()` method added to plugin interface (`src/definitions.ts`) - Verified: Line 376
- [x] ✅ Method signature exists with `apiBaseUrl`, `activeDid`, `jwtSecret` parameters
- [x] ✅ Method is being called in `HomeView.vue` - Line 474 (`onMounted` hook)
- [x] ✅ Currently uses `(DailyNotification as any).configureNativeFetcher` type assertion - Line 454
**Remaining Work:**
- [ ] Rebuild plugin package to ensure TypeScript definitions are up to date in `dist/`
- [ ] Remove `as any` type assertion in `HomeView.vue` (after confirming types are available)
- [ ] ⚠️ **CRITICAL**: Update method signature after ES256K implementation (remove `jwtSecret`, add private key mechanism)
**Location**: `test-apps/daily-notification-test/src/views/HomeView.vue` - Currently uses `(DailyNotification as any).configureNativeFetcher`
---
### 15. Add Network Security Configuration
- [x] **Status**: ✅ **COMPLETE** - Network security config created and AndroidManifest.xml updated
**Purpose**: Allow HTTP connections in Android emulator (for localhost:3000)
**Verification Results:**
- [x] ✅ Checked: `network_security_config.xml` does NOT exist in `android/app/src/main/res/xml/`
- [x] ✅ Checked: No `networkSecurityConfig` attribute in `AndroidManifest.xml`
- [x] ✅ Checked: No `usesCleartextTraffic` attribute in `AndroidManifest.xml`
**Implementation Status**:
- [x] ✅ Created `android/app/src/main/res/xml/network_security_config.xml` allowing cleartext traffic for `10.0.2.2`, `localhost`, and `127.0.0.1`
- [x] ✅ Added `android:networkSecurityConfig="@xml/network_security_config"` to `<application>` tag in `AndroidManifest.xml`
- [x] ✅ Documented that this is safe for development (emulator-only access)
**Files Modified**:
- `test-apps/daily-notification-test/android/app/src/main/res/xml/network_security_config.xml` (created)
- `test-apps/daily-notification-test/android/app/src/main/AndroidManifest.xml` (updated line 10)
**Note**: Required for HTTP connections (not HTTPS) to `http://10.0.2.2:3000` from Android emulator
---
## 🧪 Testing Checklist
Before considering this implementation complete:
- [ ] ⚠️ **CRITICAL**: JWT tokens are accepted by endorser API (pending ES256K implementation - current HMAC-SHA256 will fail)
- [ ] API responses are correctly parsed into notifications
- [ ] Notifications appear at scheduled times
- [ ] Prefetch occurs 5 minutes before notification
- [x] ✅ Error handling works for network failures - Retry logic implemented
- [x] ✅ SharedPreferences persists starred plan IDs - Implementation complete
- [ ] Pagination works with `afterId` parameter (SharedPreferences ready, needs API testing)
- [ ] Multiple notifications can be scheduled and fetched
- [ ] Background fetches work when app is closed
- [x] ✅ App recovers gracefully from API errors - Error handling and retry logic implemented
---
## 📚 Related Documentation
- `docs/NATIVE_FETCHER_CONFIGURATION.md` - Native fetcher configuration guide
- `src/types/content-fetcher.ts` - TypeScript type definitions
- `src/definitions.ts` - `PlansLastUpdatedResponse` and `PlanSummaryWithPreviousClaim` interfaces
- `examples/native-fetcher-android.kt` - Example Android implementation
- **Investigation Results**: `INVESTIGATION_JWT_ALGORITHM_RESULTS.md` - ✅ Complete investigation findings
- [endorser-ch Repository](https://github.com/trentlarson/endorser-ch/tree/master) - **CONFIRMED**: Uses `did-jwt.verifyJWT()` with DID resolver for ES256K verification
- JWT verification: `~/projects/timesafari/endorser-ch/src/api/services/vc/index.js` - `decodeAndVerifyJwt()`
- `/api/v2/report/plansLastUpdatedBetween` endpoint handler: `~/projects/timesafari/endorser-ch/src/api/controllers/report-router.js`
- [did-jwt Library](https://github.com/decentralized-identity/did-jwt) - DID-based JWT implementation (JavaScript reference)
- **TimeSafari Repository**: `~/projects/timesafari/crowd-funder-for-time-pwa/src/libs/crypto/vc/index.ts`
- **CONFIRMED**: Uses `createEndorserJwtForKey()` with `did-jwt.createJWT()` and ES256K signing
- Signs with Ethereum private key from DID identity
- Reference implementation for production JWT generation
---
## Notes
- **JWT Implementation**: ✅ **CONFIRMED** - TimeSafari repository investigation complete. Endorser-ch API uses **DID-based JWTs (ES256K)** for API authentication. Current HMAC-SHA256 implementation must be replaced. See `INVESTIGATION_JWT_ALGORITHM_RESULTS.md` for complete findings.
- **Context Dependency**: Consider whether `TestNativeFetcher` should be a singleton or if context should be injected differently
- **Error Strategy**: Decide on error handling strategy - should fetches fail silently (current), or propagate errors for user notification?
- **State Management**: Consider whether starred plan IDs should be managed by the app or fetched from API user profile
- **Investigation Status**: ✅ Item #1 (JWT algorithm) investigation **COMPLETE**. Confirmed ES256K requirement. Implementation changes required before proceeding with other items.
---
## ✅ Verification Summary (2025-10-31)
**Items Verified Through Code Inspection:**
1. **Item #4 (API Response Structure)**:
- ✅ Endpoint structure confirmed in endorser-ch source
- ✅ Parser handles both flat and nested structures
- ✅ Null checks exist for most fields (uses `.has()` checks)
- ⚠️ Missing: `root.has("data")` check before accessing data array
- ⚠️ Missing: `wrappedClaimBefore` extraction from response
2. **Item #14 (TypeScript Types)**:
- ✅ Method exists in `src/definitions.ts` (Line 376)
- ✅ Method signature includes `apiBaseUrl`, `activeDid`, `jwtSecret`
- ✅ Method being called in `HomeView.vue` (Line 474, `onMounted` hook)
- ⚠️ Currently uses `as any` type assertion (needs plugin rebuild for proper types)
3. **Item #15 (Network Security Config)**:
- ✅ Confirmed: `network_security_config.xml` does NOT exist
- ✅ Confirmed: No `networkSecurityConfig` attribute in AndroidManifest.xml
- ✅ Confirmed: No `usesCleartextTraffic` attribute in AndroidManifest.xml
- Needs to be created for HTTP connections to `http://10.0.2.2:3000`
---
**Last Updated**: 2025-10-31 (Checkboxes updated - Items 4, 14, 15 verified through code inspection)
**Next Review**: After ES256K JWT signing implementation is complete

1
test-apps/daily-notification-test/android/app/src/main/AndroidManifest.xml

@ -7,6 +7,7 @@
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/AppTheme">

6
test-apps/daily-notification-test/android/app/src/main/java/com/timesafari/dailynotification/test/TestApplication.java

@ -11,6 +11,7 @@
package com.timesafari.dailynotification.test;
import android.app.Application;
import android.content.Context;
import android.util.Log;
import com.timesafari.dailynotification.DailyNotificationPlugin;
import com.timesafari.dailynotification.NativeNotificationContentFetcher;
@ -28,9 +29,10 @@ public class TestApplication extends Application {
Log.i(TAG, "Initializing Daily Notification Plugin test app");
// Register test native fetcher
// Register test native fetcher with application context
Context context = getApplicationContext();
NativeNotificationContentFetcher testFetcher =
new com.timesafari.dailynotification.test.TestNativeFetcher();
new com.timesafari.dailynotification.test.TestNativeFetcher(context);
DailyNotificationPlugin.setNativeFetcher(testFetcher);
Log.i(TAG, "Test native fetcher registered: " + testFetcher.getClass().getName());

330
test-apps/daily-notification-test/android/app/src/main/java/com/timesafari/dailynotification/test/TestNativeFetcher.java

@ -10,6 +10,8 @@
package com.timesafari.dailynotification.test;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import androidx.annotation.NonNull;
import com.timesafari.dailynotification.FetchContext;
@ -25,9 +27,7 @@ import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
@ -43,6 +43,59 @@ import java.util.concurrent.CompletableFuture;
public class TestNativeFetcher implements NativeNotificationContentFetcher {
private static final String TAG = "TestNativeFetcher";
private static final String ENDORSER_ENDPOINT = "/api/v2/report/plansLastUpdatedBetween";
private static final int CONNECT_TIMEOUT_MS = 10000; // 10 seconds
private static final int READ_TIMEOUT_MS = 15000; // 15 seconds
private static final int MAX_RETRIES = 3; // Maximum number of retry attempts
private static final int RETRY_DELAY_MS = 1000; // Base delay for exponential backoff
// SharedPreferences constants
private static final String PREFS_NAME = "DailyNotificationPrefs";
private static final String KEY_STARRED_PLAN_IDS = "starred_plan_ids";
private static final String KEY_LAST_ACKED_JWT_ID = "last_acked_jwt_id";
private final Gson gson = new Gson();
private final Context appContext;
private SharedPreferences prefs;
// Volatile fields for configuration, set via configure() method
private volatile String apiBaseUrl;
private volatile String activeDid;
private volatile String jwtToken; // Pre-generated JWT token from TypeScript (ES256K signed)
/**
* Constructor
*
* @param context Application context for SharedPreferences access
*/
public TestNativeFetcher(Context context) {
this.appContext = context.getApplicationContext();
this.prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
Log.d(TAG, "TestNativeFetcher: Initialized with context");
}
/**
* Configure the native fetcher with API credentials
*
* Called by the plugin when configureNativeFetcher() is invoked from TypeScript.
* This method stores the configuration for use in background fetches.
*
* <p><b>Architecture Note:</b> The JWT token is pre-generated in TypeScript using
* TimeSafari's {@code createEndorserJwtForKey()} function (ES256K DID-based signing).
* The native fetcher just uses the token directly - no JWT generation needed.</p>
*
* @param apiBaseUrl Base URL for API server (e.g., "http://10.0.2.2:3000")
* @param activeDid Active DID for authentication (e.g., "did:ethr:0x...")
* @param jwtToken Pre-generated JWT token (ES256K signed) from TypeScript
*/
@Override
public void configure(String apiBaseUrl, String activeDid, String jwtToken) {
this.apiBaseUrl = apiBaseUrl;
this.activeDid = activeDid;
this.jwtToken = jwtToken;
Log.i(TAG, "TestNativeFetcher: Configured with API: " + apiBaseUrl +
", ActiveDID: " + (activeDid != null ? activeDid.substring(0, Math.min(20, activeDid.length())) + "..." : "null"));
}
@Override
@NonNull
@ -52,19 +105,30 @@ public class TestNativeFetcher implements NativeNotificationContentFetcher {
Log.d(TAG, "TestNativeFetcher: Fetch triggered - trigger=" + context.trigger +
", scheduledTime=" + context.scheduledTime + ", fetchTime=" + context.fetchTime);
// Start with retry count 0
return fetchContentWithRetry(context, 0);
}
/**
* Fetch content with retry logic for transient errors
*
* @param context Fetch context
* @param retryCount Current retry attempt (0 for first attempt)
* @return Future with notification contents or empty list on failure
*/
private CompletableFuture<List<NotificationContent>> fetchContentWithRetry(
@NonNull FetchContext context, int retryCount) {
return CompletableFuture.supplyAsync(() -> {
try {
// Check if configured
if (apiBaseUrl == null || activeDid == null || jwtSecret == null) {
if (apiBaseUrl == null || activeDid == null || jwtToken == null) {
Log.e(TAG, "TestNativeFetcher: Not configured. Call configureNativeFetcher() from TypeScript first.");
return Collections.emptyList();
}
Log.i(TAG, "TestNativeFetcher: Starting fetch from " + apiBaseUrl + ENDORSER_ENDPOINT);
// Generate JWT token for authentication
String jwtToken = generateJWTToken();
// Build request URL
String urlString = apiBaseUrl + ENDORSER_ENDPOINT;
URL url = new URL(urlString);
@ -113,6 +177,12 @@ public class TestNativeFetcher implements NativeNotificationContentFetcher {
// Parse response and convert to NotificationContent
List<NotificationContent> contents = parseApiResponse(responseBody, context);
// Update last acknowledged JWT ID from the response (for pagination)
if (!contents.isEmpty()) {
// Get the last JWT ID from the parsed response (stored during parsing)
updateLastAckedJwtIdFromResponse(contents, responseBody);
}
Log.i(TAG, "TestNativeFetcher: Successfully fetched " + contents.size() +
" notification(s)");
@ -120,6 +190,8 @@ public class TestNativeFetcher implements NativeNotificationContentFetcher {
} else {
// Read error response
String errorMessage = "Unknown error";
try {
BufferedReader reader = new BufferedReader(
new InputStreamReader(connection.getErrorStream(), StandardCharsets.UTF_8));
StringBuilder errorResponse = new StringBuilder();
@ -128,92 +200,217 @@ public class TestNativeFetcher implements NativeNotificationContentFetcher {
errorResponse.append(line);
}
reader.close();
errorMessage = errorResponse.toString();
} catch (Exception e) {
Log.w(TAG, "TestNativeFetcher: Could not read error stream", e);
}
Log.e(TAG, "TestNativeFetcher: API error " + responseCode + ": " + errorMessage);
Log.e(TAG, "TestNativeFetcher: API error " + responseCode +
": " + errorResponse.toString());
// Handle retryable errors (5xx server errors, network timeouts)
if (shouldRetry(responseCode, retryCount)) {
long delayMs = RETRY_DELAY_MS * (1 << retryCount); // Exponential backoff
Log.w(TAG, "TestNativeFetcher: Retryable error, retrying in " + delayMs + "ms " +
"(" + (retryCount + 1) + "/" + MAX_RETRIES + ")");
try {
Thread.sleep(delayMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
Log.e(TAG, "TestNativeFetcher: Retry delay interrupted", e);
return Collections.emptyList();
}
// Recursive retry
return fetchContentWithRetry(context, retryCount + 1).join();
}
// Non-retryable errors (4xx client errors, max retries reached)
if (responseCode >= 400 && responseCode < 500) {
Log.e(TAG, "TestNativeFetcher: Non-retryable client error " + responseCode);
} else if (retryCount >= MAX_RETRIES) {
Log.e(TAG, "TestNativeFetcher: Max retries (" + MAX_RETRIES + ") reached");
}
// Return empty list on error (fallback will be handled by worker)
return Collections.emptyList();
}
} catch (java.net.SocketTimeoutException | java.net.UnknownHostException e) {
// Network errors are retryable
Log.w(TAG, "TestNativeFetcher: Network error during fetch", e);
if (shouldRetry(0, retryCount)) { // Use 0 as response code for network errors
long delayMs = RETRY_DELAY_MS * (1 << retryCount);
Log.w(TAG, "TestNativeFetcher: Retrying after network error in " + delayMs + "ms " +
"(" + (retryCount + 1) + "/" + MAX_RETRIES + ")");
try {
Thread.sleep(delayMs);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
Log.e(TAG, "TestNativeFetcher: Retry delay interrupted", ie);
return Collections.emptyList();
}
return fetchContentWithRetry(context, retryCount + 1).join();
}
Log.e(TAG, "TestNativeFetcher: Max retries reached for network error");
return Collections.emptyList();
} catch (Exception e) {
Log.e(TAG, "TestNativeFetcher: Error during fetch", e);
// Return empty list on error (fallback will be handled by worker)
// Non-retryable errors (parsing, configuration, etc.)
return Collections.emptyList();
}
});
}
/**
* Generate JWT token for API authentication
* Simplified implementation matching test-user-zero config
* Determine if an error should be retried
*
* @param responseCode HTTP response code (0 for network errors)
* @param retryCount Current retry attempt count
* @return true if error is retryable and retry count not exceeded
*/
private String generateJWTToken() {
try {
long nowEpoch = System.currentTimeMillis() / 1000;
long expEpoch = nowEpoch + (JWT_EXPIRATION_MINUTES * 60);
private boolean shouldRetry(int responseCode, int retryCount) {
if (retryCount >= MAX_RETRIES) {
return false; // Max retries exceeded
}
// Retry on network errors (responseCode 0) or server errors (5xx)
// Don't retry on client errors (4xx) as they indicate permanent issues
if (responseCode == 0) {
return true; // Network error (timeout, unknown host, etc.)
}
if (responseCode >= 500 && responseCode < 600) {
return true; // Server error (retryable)
}
// Create JWT header
Map<String, Object> header = new HashMap<>();
header.put("alg", "HS256");
header.put("typ", "JWT");
// Some 4xx errors might be retryable (e.g., 429 Too Many Requests)
if (responseCode == 429) {
return true; // Rate limit - retry with backoff
}
// Create JWT payload
Map<String, Object> payload = new HashMap<>();
payload.put("exp", expEpoch);
payload.put("iat", nowEpoch);
payload.put("iss", activeDid);
payload.put("aud", "timesafari.notifications");
payload.put("sub", activeDid);
return false; // Other client errors (401, 403, 404, etc.) are not retryable
}
// Encode header and payload
String headerJson = gson.toJson(header);
String payloadJson = gson.toJson(payload);
/**
* Get starred plan IDs from SharedPreferences
*
* @return List of starred plan IDs, empty list if none stored
*/
private List<String> getStarredPlanIds() {
try {
String idsJson = prefs.getString(KEY_STARRED_PLAN_IDS, "[]");
if (idsJson == null || idsJson.isEmpty() || idsJson.equals("[]")) {
return new ArrayList<>();
}
String headerB64 = base64UrlEncode(headerJson.getBytes(StandardCharsets.UTF_8));
String payloadB64 = base64UrlEncode(payloadJson.getBytes(StandardCharsets.UTF_8));
// Parse JSON array
JsonParser parser = new JsonParser();
JsonArray jsonArray = parser.parse(idsJson).getAsJsonArray();
List<String> planIds = new ArrayList<>();
// Create signature
String unsignedToken = headerB64 + "." + payloadB64;
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest((unsignedToken + ":" + activeDid).getBytes(StandardCharsets.UTF_8));
String signature = base64UrlEncode(hash);
for (int i = 0; i < jsonArray.size(); i++) {
planIds.add(jsonArray.get(i).getAsString());
}
String jwt = unsignedToken + "." + signature;
Log.d(TAG, "TestNativeFetcher: Generated JWT token");
Log.d(TAG, "TestNativeFetcher: Loaded " + planIds.size() + " starred plan IDs");
return planIds;
return jwt;
} catch (Exception e) {
Log.e(TAG, "TestNativeFetcher: Error loading starred plan IDs", e);
return new ArrayList<>();
}
}
/**
* Get last acknowledged JWT ID from SharedPreferences (for pagination)
*
* @return Last acknowledged JWT ID, or null if none stored
*/
private String getLastAcknowledgedJwtId() {
try {
String jwtId = prefs.getString(KEY_LAST_ACKED_JWT_ID, null);
if (jwtId != null) {
Log.d(TAG, "TestNativeFetcher: Loaded last acknowledged JWT ID");
}
return jwtId;
} catch (Exception e) {
Log.e(TAG, "TestNativeFetcher: Error generating JWT", e);
throw new RuntimeException("Failed to generate JWT", e);
Log.e(TAG, "TestNativeFetcher: Error loading last acknowledged JWT ID", e);
return null;
}
}
/**
* Base64 URL encoding (without padding)
* Update starred plan IDs in SharedPreferences
*
* @param planIds List of plan IDs to store
*/
private String base64UrlEncode(byte[] data) {
String encoded = Base64.getEncoder().encodeToString(data);
return encoded.replace("+", "-").replace("/", "_").replace("=", "");
public void updateStarredPlanIds(List<String> planIds) {
try {
String idsJson = gson.toJson(planIds);
prefs.edit().putString(KEY_STARRED_PLAN_IDS, idsJson).apply();
Log.i(TAG, "TestNativeFetcher: Updated starred plan IDs: " + planIds.size() + " plans");
} catch (Exception e) {
Log.e(TAG, "TestNativeFetcher: Error updating starred plan IDs", e);
}
}
/**
* Get starred plan IDs (from test-user-zero config or SharedPreferences)
* Update last acknowledged JWT ID in SharedPreferences
*
* @param jwtId JWT ID to store as last acknowledged
*/
private List<String> getStarredPlanIds() {
// TODO: Load from SharedPreferences or config
// For now, return empty list (API will return all relevant plans)
return new ArrayList<>();
public void updateLastAckedJwtId(String jwtId) {
try {
prefs.edit().putString(KEY_LAST_ACKED_JWT_ID, jwtId).apply();
Log.d(TAG, "TestNativeFetcher: Updated last acknowledged JWT ID");
} catch (Exception e) {
Log.e(TAG, "TestNativeFetcher: Error updating last acknowledged JWT ID", e);
}
}
/**
* Get last acknowledged JWT ID (for pagination)
* Update last acknowledged JWT ID from the API response
* Uses the last JWT ID from the data array for pagination
*
* @param contents Parsed notification contents (may contain JWT IDs)
* @param responseBody Original response body for parsing
*/
private String getLastAcknowledgedJwtId() {
// TODO: Load from SharedPreferences
// For now, return null (fetch all updates)
return null;
private void updateLastAckedJwtIdFromResponse(List<NotificationContent> contents, String responseBody) {
try {
JsonParser parser = new JsonParser();
JsonObject root = parser.parse(responseBody).getAsJsonObject();
JsonArray dataArray = root.getAsJsonArray("data");
if (dataArray != null && dataArray.size() > 0) {
// Get the last item's JWT ID (most recent)
JsonObject lastItem = dataArray.get(dataArray.size() - 1).getAsJsonObject();
// Try to get JWT ID from different possible locations in response structure
String jwtId = null;
if (lastItem.has("jwtId")) {
jwtId = lastItem.get("jwtId").getAsString();
} else if (lastItem.has("plan")) {
JsonObject plan = lastItem.getAsJsonObject("plan");
if (plan.has("jwtId")) {
jwtId = plan.get("jwtId").getAsString();
}
}
if (jwtId != null && !jwtId.isEmpty()) {
updateLastAckedJwtId(jwtId);
Log.d(TAG, "TestNativeFetcher: Updated last acknowledged JWT ID: " +
jwtId.substring(0, Math.min(20, jwtId.length())) + "...");
}
}
} catch (Exception e) {
Log.w(TAG, "TestNativeFetcher: Could not extract JWT ID from response for pagination", e);
}
}
/**
@ -235,8 +432,27 @@ public class TestNativeFetcher implements NativeNotificationContentFetcher {
NotificationContent content = new NotificationContent();
// Extract data from API response
String planId = item.has("planId") ? item.get("planId").getAsString() : null;
String jwtId = item.has("jwtId") ? item.get("jwtId").getAsString() : null;
// Support both flat structure (jwtId, planId) and nested (plan.jwtId, plan.handleId)
String planId = null;
String jwtId = null;
if (item.has("planId")) {
planId = item.get("planId").getAsString();
} else if (item.has("plan")) {
JsonObject plan = item.getAsJsonObject("plan");
if (plan.has("handleId")) {
planId = plan.get("handleId").getAsString();
}
}
if (item.has("jwtId")) {
jwtId = item.get("jwtId").getAsString();
} else if (item.has("plan")) {
JsonObject plan = item.getAsJsonObject("plan");
if (plan.has("jwtId")) {
jwtId = plan.get("jwtId").getAsString();
}
}
// Create notification ID
String notificationId = "endorser_" + (jwtId != null ? jwtId :

33
test-apps/daily-notification-test/android/app/src/main/res/xml/network_security_config.xml

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Network Security Configuration for Daily Notification Test App
Allows cleartext HTTP traffic to localhost (10.0.2.2) for Android emulator testing.
This is required for connecting to http://10.0.2.2:3000 from the Android emulator
(which maps to host machine's localhost:3000).
Author: Matthew Raymer
Date: 2025-10-31
-->
<network-security-config>
<!--
Allow cleartext traffic to Android emulator's localhost alias (10.0.2.2).
This is safe because:
1. Only accessible from Android emulator
2. Only used for development/testing
3. Does not affect production builds (production should use HTTPS)
-->
<domain-config cleartextTrafficPermitted="true">
<!-- Android emulator's special IP for host machine's localhost -->
<domain includeSubdomains="false">10.0.2.2</domain>
<!-- Also allow direct localhost access (for some emulator configurations) -->
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
</domain-config>
<!--
Production domains should use HTTPS only (default behavior).
This configuration only adds exceptions for localhost development.
-->
</network-security-config>

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

@ -3,4 +3,6 @@ include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':timesafari-daily-notification-plugin'
// NOTE: Plugin module is in android/plugin/ subdirectory, not android root
// This file is auto-generated by Capacitor, but must be manually corrected for this plugin structure
project(':timesafari-daily-notification-plugin').projectDir = new File('../node_modules/@timesafari/daily-notification-plugin/android/plugin')

30
test-apps/daily-notification-test/docs/BUILD_QUICK_REFERENCE.md

@ -9,26 +9,26 @@
# 1. Build web assets
npm run build
# 2. Sync with native projects
npx cap sync android
# 2. Sync with native projects (automatically fixes plugin paths)
npm run cap:sync
# 3. 🔧 FIX PLUGIN REGISTRY (REQUIRED!)
node scripts/fix-capacitor-plugins.js
# 4. Build and deploy Android
# 3. Build and deploy Android
cd android
./gradlew :app:assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
adb shell am start -n com.timesafari.dailynotification.test/.MainActivity
```
## ⚠️ Why Step 3 is Critical
## ⚠️ Why `npm run cap:sync` is Important
**Problem**: `npx cap sync` overwrites `capacitor.plugins.json` and removes custom plugins.
**Problem**: `npx cap sync` overwrites `capacitor.plugins.json` and `capacitor.settings.gradle` with incorrect paths.
**Solution**: The fix script restores the DailyNotification plugin entry.
**Solution**: The `cap:sync` script automatically:
1. Runs `npx cap sync android`
2. Fixes `capacitor.settings.gradle` (corrects plugin path from `android/` to `android/plugin/`)
3. Restores the DailyNotification plugin entry in `capacitor.plugins.json`
**Without Step 3**: Plugin detection fails, "simplified dialog" appears.
**Without the fix**: Plugin detection fails, build errors occur, "simplified dialog" appears.
## 🔍 Verification Checklist
@ -52,10 +52,8 @@ echo "🔨 Building web assets..."
npm run build
echo "🔄 Syncing with native projects..."
npx cap sync android
echo "🔧 Fixing plugin registry..."
node scripts/fix-capacitor-plugins.js
npm run cap:sync
# This automatically syncs and fixes plugin paths
echo "🏗️ Building Android app..."
cd android
@ -72,7 +70,7 @@ echo "✅ Build and deploy complete!"
| Issue | Symptom | Solution |
|-------|---------|----------|
| Empty plugin registry | `capacitor.plugins.json` is `[]` | Run `node scripts/fix-capacitor-plugins.js` |
| Empty plugin registry | `capacitor.plugins.json` is `[]` | Run `npm run cap:sync` or `node scripts/fix-capacitor-plugins.js` |
| Plugin not detected | "Plugin: Not Available" (red) | Check plugin registry, rebuild |
| Click events not working | Buttons don't respond | Check Vue 3 compatibility, router config |
| Inconsistent status | Different status in different cards | Use consistent detection logic |
@ -92,4 +90,4 @@ adb shell pm list packages | grep dailynotification
---
**Remember**: Always run the fix script after `npx cap sync`!
**Remember**: Use `npm run cap:sync` instead of `npx cap sync android` directly - it automatically fixes the configuration files!

7
test-apps/daily-notification-test/docs/PLUGIN_DETECTION_GUIDE.md

@ -150,6 +150,8 @@ node scripts/fix-capacitor-plugins.js
**Solution**: Always run fix script after sync
```bash
npm run cap:sync
# Or manually:
npx cap sync android && node scripts/fix-capacitor-plugins.js
```
@ -159,9 +161,8 @@ npx cap sync android && node scripts/fix-capacitor-plugins.js
1. **Make Changes**: Edit Vue components, native code, or plugin logic
2. **Build Web**: `npm run build`
3. **Sync Native**: `npx cap sync android`
4. **Fix Plugins**: `node scripts/fix-capacitor-plugins.js`
5. **Build Android**: `cd android && ./gradlew :app:assembleDebug`
3. **Sync Native**: `npm run cap:sync` (automatically fixes plugin paths)
4. **Build Android**: `cd android && ./gradlew :app:assembleDebug`
6. **Deploy**: `adb install -r app/build/outputs/apk/debug/app-debug.apk`
7. **Test**: Launch app and verify plugin detection

220
test-apps/daily-notification-test/package-lock.json

@ -13,6 +13,8 @@
"@capacitor/core": "^6.2.1",
"@timesafari/daily-notification-plugin": "file:../../",
"date-fns": "^4.1.0",
"did-jwt": "^7.4.7",
"ethers": "^6.15.0",
"pinia": "^3.0.3",
"vue": "^3.5.22",
"vue-facing-decorator": "^3.0.4",
@ -77,6 +79,12 @@
"node": ">=18"
}
},
"node_modules/@adraffy/ens-normalize": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz",
"integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==",
"license": "MIT"
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@ -1530,6 +1538,48 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@multiformats/base-x": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@multiformats/base-x/-/base-x-4.0.1.tgz",
"integrity": "sha512-eMk0b9ReBbV23xXU693TAIrLyeO5iTgBZGSJfpqriG8UkYvr/hC9u9pyMlAakDNHWmbhMZCDs6KQO0jzKD8OTw==",
"license": "MIT"
},
"node_modules/@noble/ciphers": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.4.1.tgz",
"integrity": "sha512-QCOA9cgf3Rc33owG0AYBB9wszz+Ul2kramWN8tXG44Gyciud/tbkEqvxRF/IpqQaBpRBNi9f4jdNxqB2CQCIXg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "1.9.7",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.8.0"
},
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -1890,6 +1940,15 @@
"win32"
]
},
"node_modules/@scure/base": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@sec-ant/readable-stream": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
@ -2620,6 +2679,12 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/aes-js": {
"version": "4.0.0-beta.5",
"resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz",
"integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==",
"license": "MIT"
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@ -2915,6 +2980,15 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canonicalize": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-2.1.0.tgz",
"integrity": "sha512-F705O3xrsUtgt98j7leetNhTWPe+5S72rlL5O4jA1pKqBVQ/dT1O1D6PFxmSXvc0SUOinWS57DKx0I3CHrXJHQ==",
"license": "Apache-2.0",
"bin": {
"canonicalize": "bin/canonicalize.js"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -3148,6 +3222,29 @@
"node": ">=8"
}
},
"node_modules/did-jwt": {
"version": "7.4.7",
"resolved": "https://registry.npmjs.org/did-jwt/-/did-jwt-7.4.7.tgz",
"integrity": "sha512-Apz7nIfIHSKWIMaEP5L/K8xkwByvjezjTG0xiqwKdnNj1x8M0+Yasury5Dm/KPltxi2PlGfRPf3IejRKZrT8mQ==",
"license": "Apache-2.0",
"dependencies": {
"@noble/ciphers": "^0.4.0",
"@noble/curves": "^1.0.0",
"@noble/hashes": "^1.3.0",
"@scure/base": "^1.1.3",
"canonicalize": "^2.0.0",
"did-resolver": "^4.1.0",
"multibase": "^4.0.6",
"multiformats": "^9.6.2",
"uint8arrays": "3.1.1"
}
},
"node_modules/did-resolver": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/did-resolver/-/did-resolver-4.1.0.tgz",
"integrity": "sha512-S6fWHvCXkZg2IhS4RcVHxwuyVejPR7c+a4Go0xbQ9ps5kILa8viiYQgrM4gfTyeTjJ0ekgJH9gk/BawTpmkbZA==",
"license": "Apache-2.0"
},
"node_modules/electron-to-chromium": {
"version": "1.5.237",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
@ -3508,6 +3605,79 @@
"node": ">=0.10.0"
}
},
"node_modules/ethers": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ethers/-/ethers-6.15.0.tgz",
"integrity": "sha512-Kf/3ZW54L4UT0pZtsY/rf+EkBU7Qi5nnhonjUb8yTXcxH3cdcWrV2cRyk0Xk/4jK6OoHhxxZHriyhje20If2hQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/ethers-io/"
},
{
"type": "individual",
"url": "https://www.buymeacoffee.com/ricmoo"
}
],
"license": "MIT",
"dependencies": {
"@adraffy/ens-normalize": "1.10.1",
"@noble/curves": "1.2.0",
"@noble/hashes": "1.3.2",
"@types/node": "22.7.5",
"aes-js": "4.0.0-beta.5",
"tslib": "2.7.0",
"ws": "8.17.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/ethers/node_modules/@noble/curves": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.2"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/ethers/node_modules/@noble/hashes": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/ethers/node_modules/@types/node": {
"version": "22.7.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz",
"integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.19.2"
}
},
"node_modules/ethers/node_modules/tslib": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
"integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
"license": "0BSD"
},
"node_modules/ethers/node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"license": "MIT"
},
"node_modules/execa": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.0.tgz",
@ -4467,6 +4637,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/multibase": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/multibase/-/multibase-4.0.6.tgz",
"integrity": "sha512-x23pDe5+svdLz/k5JPGCVdfn7Q5mZVMBETiC+ORfO+sor9Sgs0smJzAjfTbM5tckeCqnaUuMYoz+k3RXMmJClQ==",
"deprecated": "This module has been superseded by the multiformats module",
"license": "MIT",
"dependencies": {
"@multiformats/base-x": "^4.0.1"
},
"engines": {
"node": ">=12.0.0",
"npm": ">=6.0.0"
}
},
"node_modules/multiformats": {
"version": "9.9.0",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz",
"integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==",
"license": "(Apache-2.0 AND MIT)"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@ -5646,6 +5836,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/uint8arrays": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.1.1.tgz",
"integrity": "sha512-+QJa8QRnbdXVpHYjLoTpJIdCTiw9Ir62nocClWuXIq2JIh4Uta0cQsTSpFL678p2CN8B+XSApwcU+pQEqVpKWg==",
"license": "MIT",
"dependencies": {
"multiformats": "^9.4.2"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@ -6269,6 +6468,27 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/wsl-utils": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",

10
test-apps/daily-notification-test/package.json

@ -12,18 +12,22 @@
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix"
"lint": "eslint . --fix",
"cap:sync": "npx cap sync android && node scripts/fix-capacitor-plugins.js",
"postinstall": "node scripts/fix-capacitor-plugins.js"
},
"dependencies": {
"@capacitor/android": "^6.2.1",
"@capacitor/cli": "^6.2.1",
"@capacitor/core": "^6.2.1",
"@timesafari/daily-notification-plugin": "file:../../",
"date-fns": "^4.1.0",
"did-jwt": "^7.4.7",
"ethers": "^6.15.0",
"pinia": "^3.0.3",
"vue": "^3.5.22",
"vue-facing-decorator": "^3.0.4",
"vue-router": "^4.5.1",
"@timesafari/daily-notification-plugin": "file:../../"
"vue-router": "^4.5.1"
},
"devDependencies": {
"@tsconfig/node22": "^22.0.2",

76
test-apps/daily-notification-test/scripts/fix-capacitor-plugins.js

@ -1,9 +1,14 @@
#!/usr/bin/env node
/**
* Post-sync script to fix capacitor.plugins.json
* This ensures the DailyNotification plugin is always registered
* even after npx cap sync overwrites the file
* Post-sync script to fix Capacitor auto-generated files
*
* Fixes:
* 1. capacitor.plugins.json - Ensures DailyNotification plugin is registered
* 2. capacitor.settings.gradle - Corrects plugin path from android/ to android/plugin/
*
* This script should run automatically after 'npx cap sync android'
* to fix issues with Capacitor's auto-generated files.
*
* @author Matthew Raymer
*/
@ -16,12 +21,16 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PLUGINS_JSON_PATH = path.join(__dirname, '../android/app/src/main/assets/capacitor.plugins.json');
const SETTINGS_GRADLE_PATH = path.join(__dirname, '../android/capacitor.settings.gradle');
const PLUGIN_ENTRY = {
name: "DailyNotification",
classpath: "com.timesafari.dailynotification.DailyNotificationPlugin"
};
/**
* Fix capacitor.plugins.json to ensure DailyNotification is registered
*/
function fixCapacitorPlugins() {
console.log('🔧 Fixing capacitor.plugins.json...');
@ -50,9 +59,66 @@ function fixCapacitorPlugins() {
}
}
/**
* Fix capacitor.settings.gradle to point to android/plugin/ instead of android/
*/
function fixCapacitorSettingsGradle() {
console.log('🔧 Fixing capacitor.settings.gradle...');
if (!fs.existsSync(SETTINGS_GRADLE_PATH)) {
console.log('ℹ️ capacitor.settings.gradle not found (may not be a test-app)');
return;
}
try {
let content = fs.readFileSync(SETTINGS_GRADLE_PATH, 'utf8');
const originalContent = content;
// Check if the path already points to android/plugin
if (content.includes('android/plugin')) {
console.log('✅ capacitor.settings.gradle already has correct path (android/plugin)');
return;
}
// Check if we need to fix the path (points to android but should be android/plugin)
if (content.includes("project(':timesafari-daily-notification-plugin').projectDir = new File('../node_modules/@timesafari/daily-notification-plugin/android')")) {
// Replace the path
content = content.replace(
"project(':timesafari-daily-notification-plugin').projectDir = new File('../node_modules/@timesafari/daily-notification-plugin/android')",
`// NOTE: Plugin module is in android/plugin/ subdirectory, not android root
// This file is auto-generated by Capacitor, but must be manually corrected for this plugin structure
project(':timesafari-daily-notification-plugin').projectDir = new File('../node_modules/@timesafari/daily-notification-plugin/android/plugin')`
);
fs.writeFileSync(SETTINGS_GRADLE_PATH, content);
console.log('✅ Fixed plugin path in capacitor.settings.gradle (android -> android/plugin)');
} else {
console.log('ℹ️ capacitor.settings.gradle doesn\'t reference the plugin or uses a different structure');
}
} catch (error) {
console.error('❌ Error fixing capacitor.settings.gradle:', error.message);
process.exit(1);
}
}
/**
* Run all fixes
*/
function fixAll() {
console.log('🔧 DailyNotification Plugin - Post-Sync Fix Script');
console.log('================================================\n');
fixCapacitorPlugins();
fixCapacitorSettingsGradle();
console.log('\n✅ All fixes applied successfully!');
console.log('💡 These fixes will persist until the next "npx cap sync android"');
}
// Run if called directly
if (import.meta.url === `file://${process.argv[1]}`) {
fixCapacitorPlugins();
fixAll();
}
export { fixCapacitorPlugins };
export { fixCapacitorPlugins, fixCapacitorSettingsGradle, fixAll };

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

@ -213,8 +213,61 @@ export const MOCK_STARRED_PROJECTS_RESPONSE = {
} as const;
/**
* Generate test JWT token for User Zero
* Mimics the crowd-master createEndorserJwtForDid function
* Generate ES256K signed JWT token for User Zero using DID-based signing
*
* This function mimics TimeSafari's createEndorserJwtForKey() function,
* using did-jwt library with ES256K algorithm (DID-based signing).
*
* @returns Promise<string> ES256K signed JWT token
*/
export async function generateEndorserJWT(): Promise<string> {
try {
// Dynamic import to avoid loading did-jwt in environments where it's not needed
const { createJWT, SimpleSigner } = await import('did-jwt');
const { HDNodeWallet, Mnemonic } = await import('ethers');
// Derive Ethereum private key from seed phrase
// Using the same derivation as TimeSafari (m/44'/60'/0'/0/0 - Ethereum standard)
const mnemonic = Mnemonic.fromPhrase(TEST_USER_ZERO_CONFIG.identity.seedPhrase);
const wallet = HDNodeWallet.fromMnemonic(mnemonic);
const privateKeyHex = wallet.privateKey.slice(2); // Remove '0x' prefix
// Create signer for ES256K (Ethereum secp256k1 curve)
const signer = SimpleSigner(privateKeyHex);
// Create JWT payload with standard claims
const nowEpoch = Math.floor(Date.now() / 1000);
const expiresIn = TEST_USER_ZERO_CONFIG.api.jwtExpirationMinutes * 60;
const payload = {
// Standard JWT claims
iat: nowEpoch,
exp: nowEpoch + expiresIn,
iss: TEST_USER_ZERO_CONFIG.identity.did,
sub: TEST_USER_ZERO_CONFIG.identity.did,
// Additional claims that endorser-ch might expect
aud: "endorser-ch"
};
// Create ES256K signed JWT (ES256K is the default algorithm for did-jwt)
const jwt = await createJWT(payload, {
issuer: TEST_USER_ZERO_CONFIG.identity.did,
signer: signer,
expiresIn: expiresIn
});
return jwt;
} catch (error) {
console.error('Failed to generate ES256K JWT:', error);
throw new Error(`JWT generation failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Generate test JWT token for User Zero (DEPRECATED - use generateEndorserJWT instead)
*
* @deprecated This function generates an unsigned test token. Use generateEndorserJWT()
* for real ES256K signed tokens that work with the endorser-ch API.
*/
export function generateTestJWT(): string {
const nowEpoch = Math.floor(Date.now() / 1000);
@ -260,7 +313,7 @@ export class TestUserZeroAPI {
*/
setBaseUrl(url: string): void {
this.baseUrl = url;
// eslint-disable-next-line no-console
console.log("🔧 API base URL updated to:", url);
}
@ -278,7 +331,7 @@ export class TestUserZeroAPI {
if (useMock) {
// Return mock data for offline testing
// eslint-disable-next-line no-console
console.log("🧪 Using mock starred projects response");
return MOCK_STARRED_PROJECTS_RESPONSE;
}
@ -297,9 +350,9 @@ export class TestUserZeroAPI {
afterId: afterId || TEST_USER_ZERO_CONFIG.starredProjects.lastAckedJwtId
};
// eslint-disable-next-line no-console
console.log("🌐 Making real API call to:", url);
// eslint-disable-next-line no-console
console.log("📦 Request body:", requestBody);
const response = await fetch(url, {
@ -320,7 +373,7 @@ export class TestUserZeroAPI {
*/
refreshToken(): void {
this.jwt = generateTestJWT();
// eslint-disable-next-line no-console
console.log("🔄 JWT token refreshed");
}

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

@ -105,7 +105,7 @@ router.beforeEach((to, from, next) => {
}
// Add loading state
// eslint-disable-next-line no-console
console.log(`🔄 Navigating from ${String(from.name) || 'unknown'} to ${String(to.name) || 'unknown'}`)
next()
@ -113,7 +113,7 @@ router.beforeEach((to, from, next) => {
router.afterEach((to) => {
// Clear any previous errors on successful navigation
// eslint-disable-next-line no-console
console.log(`✅ Navigation completed: ${String(to.name) || 'unknown'}`)
})

46
test-apps/daily-notification-test/src/views/HomeView.vue

@ -115,6 +115,7 @@ import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import ActionCard from '@/components/cards/ActionCard.vue'
import StatusCard from '@/components/cards/StatusCard.vue'
import { TEST_USER_ZERO_CONFIG, generateEndorserJWT } from '@/config/test-user-zero'
const router = useRouter()
const appStore = useAppStore()
@ -429,9 +430,54 @@ const openConsole = (): void => {
alert('📖 Console Logs\n\nOpen your browser\'s Developer Tools (F12) and check the Console tab for detailed diagnostic information.')
}
// Configure native fetcher with test user zero credentials
const configureNativeFetcher = async (): Promise<void> => {
try {
const { Capacitor } = await import('@capacitor/core')
const isNative = Capacitor.isNativePlatform()
if (isNative) {
const { DailyNotification } = await import('@timesafari/daily-notification-plugin')
// Get API server URL based on mode and platform
const apiBaseUrl = TEST_USER_ZERO_CONFIG.getApiServerUrl()
// Only configure if not in mock mode
if (TEST_USER_ZERO_CONFIG.api.serverMode !== 'mock' && apiBaseUrl !== 'mock://localhost') {
console.log('🔧 Configuring native fetcher with:', {
apiBaseUrl,
activeDid: TEST_USER_ZERO_CONFIG.identity.did.substring(0, 30) + '...',
serverMode: TEST_USER_ZERO_CONFIG.api.serverMode
})
// Generate ES256K JWT token using did-jwt library
// This mimics TimeSafari's createEndorserJwtForKey() function
// In production TimeSafari app, this would use:
// const account = await retrieveFullyDecryptedAccount(activeDid);
// const jwtToken = await createEndorserJwtForKey(account, {...});
const jwtToken = await generateEndorserJWT()
await DailyNotification.configureNativeFetcher({
apiBaseUrl: apiBaseUrl,
activeDid: TEST_USER_ZERO_CONFIG.identity.did,
jwtToken: jwtToken // Pre-generated token (ES256K signed in production)
})
console.log('✅ Native fetcher configured successfully')
} else {
console.log('⏭️ Skipping native fetcher configuration (mock mode enabled)')
}
}
} catch (error) {
console.error('❌ Failed to configure native fetcher:', error)
// Don't block app initialization if configuration fails
}
}
// Initialize system status when component mounts
onMounted(async () => {
console.log('🏠 HomeView mounted - checking initial system status...')
await configureNativeFetcher()
await checkSystemStatus()
})
</script>

2
test-apps/daily-notification-test/src/views/UserZeroView.vue

@ -19,7 +19,7 @@
</div>
<div class="config-item">
<label>API Server:</label>
<span class="config-value">{{ config.api.server }}</span>
<span class="config-value">{{ config.getApiServerUrl() }} (mode: {{ config.api.serverMode }})</span>
</div>
<div class="config-item">
<label>JWT Expiration:</label>

Loading…
Cancel
Save