diff --git a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationScheduler.java b/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationScheduler.java index 37072f0..abdfe7a 100644 --- a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationScheduler.java +++ b/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationScheduler.java @@ -36,6 +36,10 @@ public class DailyNotificationScheduler { private final AlarmManager alarmManager; private final ConcurrentHashMap scheduledAlarms; + // Track scheduled times to prevent duplicate alarms for same time + // Maps scheduledTime (ms) -> notificationId that has alarm scheduled + private final ConcurrentHashMap scheduledTimeToId; + // PendingIntent management private PendingIntentManager pendingIntentManager; @@ -55,6 +59,7 @@ public class DailyNotificationScheduler { this.context = context; this.alarmManager = alarmManager; this.scheduledAlarms = new ConcurrentHashMap<>(); + this.scheduledTimeToId = new ConcurrentHashMap<>(); this.pendingIntentManager = new PendingIntentManager(context); Log.d(TAG, "DailyNotificationScheduler initialized with PendingIntentManager"); @@ -120,6 +125,36 @@ public class DailyNotificationScheduler { Log.d(TAG, "Phase 3: Cancelling existing alarm for notification: " + content.getId()); cancelNotification(content.getId()); + // Get scheduled time and check for duplicate alarms at same time + long triggerTime = content.getScheduledTime(); + long toleranceMs = 60 * 1000; // 1 minute tolerance for DST/clock adjustments + + // Cancel any existing alarm for the same scheduled time (within tolerance) + // This prevents multiple notifications scheduled for same time from creating duplicate alarms + // Check all scheduled times to find any within tolerance + java.util.List duplicateIds = new java.util.ArrayList<>(); + for (java.util.Map.Entry entry : scheduledTimeToId.entrySet()) { + Long scheduledTime = entry.getKey(); + String existingId = entry.getValue(); + + // Skip if it's the same notification ID or time difference is too large + if (existingId.equals(content.getId()) || + Math.abs(scheduledTime - triggerTime) > toleranceMs) { + continue; + } + + // Found an alarm scheduled for a time very close to this one + duplicateIds.add(existingId); + } + + // Cancel any duplicate alarms found + for (String duplicateId : duplicateIds) { + Log.w(TAG, "Phase 3: Cancelling duplicate alarm for time " + + formatTime(triggerTime) + " (existing ID: " + duplicateId + + ", new ID: " + content.getId() + ")"); + cancelNotification(duplicateId); + } + // Create intent for the notification Intent intent = new Intent(context, DailyNotificationReceiver.class); intent.setAction(ACTION_NOTIFICATION); @@ -143,8 +178,10 @@ public class DailyNotificationScheduler { // Store the pending intent scheduledAlarms.put(content.getId(), pendingIntent); + // Track scheduled time to notification ID mapping + scheduledTimeToId.put(triggerTime, content.getId()); + // Schedule the alarm - long triggerTime = content.getScheduledTime(); boolean scheduled = scheduleAlarm(pendingIntent, triggerTime); if (scheduled) { @@ -421,6 +458,17 @@ public class DailyNotificationScheduler { pendingIntentManager.cancelAlarm(pendingIntent); pendingIntent.cancel(); Log.d(TAG, "Cancelled existing alarm for notification: " + notificationId); + + // Remove from time-to-ID mapping by finding and removing the entry + java.util.List timesToRemove = new java.util.ArrayList<>(); + for (java.util.Map.Entry entry : scheduledTimeToId.entrySet()) { + if (entry.getValue().equals(notificationId)) { + timesToRemove.add(entry.getKey()); + } + } + for (Long time : timesToRemove) { + scheduledTimeToId.remove(time); + } } else { Log.d(TAG, "No existing alarm found to cancel for notification: " + notificationId); } @@ -441,6 +489,7 @@ public class DailyNotificationScheduler { } scheduledAlarms.clear(); + scheduledTimeToId.clear(); Log.i(TAG, "All notifications cancelled"); } catch (Exception e) { diff --git a/test-apps/daily-notification-test/android/app/src/main/java/com/timesafari/dailynotification/test/TestNativeFetcher.java b/test-apps/daily-notification-test/android/app/src/main/java/com/timesafari/dailynotification/test/TestNativeFetcher.java index 32486a6..c9ba55b 100644 --- a/test-apps/daily-notification-test/android/app/src/main/java/com/timesafari/dailynotification/test/TestNativeFetcher.java +++ b/test-apps/daily-notification-test/android/app/src/main/java/com/timesafari/dailynotification/test/TestNativeFetcher.java @@ -95,8 +95,26 @@ public class TestNativeFetcher implements NativeNotificationContentFetcher { 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")); + + // Enhanced logging for JWT diagnostic purposes + Log.i(TAG, "TestNativeFetcher: Configured with API: " + apiBaseUrl); + if (activeDid != null) { + Log.i(TAG, "TestNativeFetcher: ActiveDID: " + activeDid.substring(0, Math.min(30, activeDid.length())) + + (activeDid.length() > 30 ? "..." : "")); + } else { + Log.w(TAG, "TestNativeFetcher: ActiveDID is NULL"); + } + + if (jwtToken != null) { + Log.i(TAG, "TestNativeFetcher: JWT token received - Length: " + jwtToken.length() + " chars"); + // Log first and last 10 chars for verification (not full token for security) + String tokenPreview = jwtToken.length() > 20 + ? jwtToken.substring(0, 10) + "..." + jwtToken.substring(jwtToken.length() - 10) + : jwtToken.substring(0, Math.min(jwtToken.length(), 20)) + "..."; + Log.d(TAG, "TestNativeFetcher: JWT preview: " + tokenPreview); + } else { + Log.e(TAG, "TestNativeFetcher: JWT token is NULL - API calls will fail"); + } } @Override @@ -141,6 +159,19 @@ public class TestNativeFetcher implements NativeNotificationContentFetcher { connection.setReadTimeout(READ_TIMEOUT_MS); connection.setRequestMethod("POST"); connection.setRequestProperty("Content-Type", "application/json"); + + // Diagnostic logging for JWT usage + if (jwtToken != null) { + String jwtPreview = jwtToken.length() > 20 + ? jwtToken.substring(0, 10) + "..." + jwtToken.substring(jwtToken.length() - 10) + : jwtToken; + Log.d(TAG, "TestNativeFetcher: Using JWT for API call - Length: " + jwtToken.length() + + ", Preview: " + jwtPreview + ", ActiveDID: " + + (activeDid != null ? activeDid.substring(0, Math.min(30, activeDid.length())) + "..." : "null")); + } else { + Log.e(TAG, "TestNativeFetcher: JWT token is NULL when making API call!"); + } + connection.setRequestProperty("Authorization", "Bearer " + jwtToken); connection.setDoOutput(true); diff --git a/test-apps/daily-notification-test/scripts/find-user-zero-key.js b/test-apps/daily-notification-test/scripts/find-user-zero-key.js new file mode 100644 index 0000000..7cfca39 --- /dev/null +++ b/test-apps/daily-notification-test/scripts/find-user-zero-key.js @@ -0,0 +1,66 @@ +/** + * Script to find the correct derivation path for User Zero's DID + * + * User Zero DID: did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F + * Target address: 0x0000694B58C2cC69658993A90D3840C560f2F51F + */ + +import { HDNodeWallet, Mnemonic } from 'ethers'; + +const seedPhrase = "rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage"; +const targetAddress = "0x0000694B58C2cC69658993A90D3840C560f2F51F".toLowerCase(); + +console.log('Target address:', targetAddress); +console.log('Searching for matching derivation path...\n'); + +const mnemonic = Mnemonic.fromPhrase(seedPhrase); +let found = false; + +// Try standard Ethereum derivation paths +for (let account = 0; account < 50; account++) { + try { + const path = `m/44'/60'/0'/0/${account}`; + const wallet = HDNodeWallet.fromPhrase(mnemonic.phrase, path); + const address = wallet.address.toLowerCase(); + + if (address === targetAddress) { + console.log(`✅✅✅ MATCH FOUND: ${path}`); + console.log('Address:', wallet.address); + console.log('Private key (hex, no 0x prefix):', wallet.privateKey.slice(2)); + console.log('\nUse this path in generateEndorserJWT():'); + console.log(`const wallet = HDNodeWallet.fromPhrase(mnemonic.phrase, "${path}");`); + found = true; + break; + } + + if (account < 10) { + console.log(`Path ${path}: ${address}`); + } + } catch (e) { + console.error(`Error with path m/44'/60'/0'/0/${account}:`, e.message); + } +} + +// Also try default path (no account index specified) +if (!found) { + try { + const wallet = HDNodeWallet.fromMnemonic(mnemonic); + const address = wallet.address.toLowerCase(); + console.log(`\nDefault path (fromMnemonic): ${address}`); + if (address === targetAddress) { + console.log('✅✅✅ MATCH FOUND: Default path (fromMnemonic)'); + found = true; + } + } catch (e) { + console.error('Error with default path:', e.message); + } +} + +if (!found) { + console.log('\n❌ No match found in first 50 account indices'); + console.log('Possible causes:'); + console.log('1. Different derivation path (not m/44\'/60\'/0\'/0/X)'); + console.log('2. Wrong seed phrase'); + console.log('3. User Zero registered with different key source'); +} + diff --git a/test-apps/daily-notification-test/src/config/test-user-zero.ts b/test-apps/daily-notification-test/src/config/test-user-zero.ts index 244943e..80e302b 100644 --- a/test-apps/daily-notification-test/src/config/test-user-zero.ts +++ b/test-apps/daily-notification-test/src/config/test-user-zero.ts @@ -11,8 +11,8 @@ // Lazy import logger to avoid ES module issues when loaded by Capacitor CLI (CommonJS) // Logger is only used inside functions, not at module scope -let logger: { error: (...args: unknown[]) => void; custom: (emoji: string, ...args: unknown[]) => void } | null = null; -const getLogger = async (): Promise<{ error: (...args: unknown[]) => void; custom: (emoji: string, ...args: unknown[]) => void } | null> => { +let logger: { error: (...args: unknown[]) => void; custom: (emoji: string, ...args: unknown[]) => void; info: (...args: unknown[]) => void } | null = null; +const getLogger = async (): Promise<{ error: (...args: unknown[]) => void; custom: (emoji: string, ...args: unknown[]) => void; info: (...args: unknown[]) => void } | null> => { if (!logger) { const loggerModule = await import('../lib/logger'); logger = loggerModule.logger; @@ -25,7 +25,14 @@ export const TEST_USER_ZERO_CONFIG = { identity: { did: "did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F", name: "User Zero", - seedPhrase: "rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage" + seedPhrase: "rigid shrug mobile smart veteran half all pond toilet brave review universe ship congress found yard skate elite apology jar uniform subway slender luggage", + // User Zero's known public key for verification + // Public Key (hex): 03b231e73413f1d36b3327763ad9a44bb81fc91480a14977ab3cfd32df0564af1d + // Public Key (base64): A7Ix5zQT8dNrMyd2OtmkS7gfyRSAoUl3qzz9Mt8FZK8d + // Derivation Path: m/84737769'/0'/0'/0' + expectedPublicKeyHex: "03b231e73413f1d36b3327763ad9a44bb81fc91480a14977ab3cfd32df0564af1d", + expectedPublicKeyBase64: "A7Ix5zQT8dNrMyd2OtmkS7gfyRSAoUl3qzz9Mt8FZK8d", + derivationPath: "m/84737769'/0'/0'/0'" }, // API Configuration @@ -226,10 +233,15 @@ export const MOCK_STARRED_PROJECTS_RESPONSE = { /** * 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). + * This function generates a JWT token signed with User Zero's private key, + * using User Zero's DID and seed phrase from TEST_USER_ZERO_CONFIG. * - * @returns Promise ES256K signed JWT token + * Uses TimeSafari's approach (mimics createEndorserJwtForKey() function): + * - did-jwt library with ES256K algorithm (DID-based signing) + * - Ethereum private key derived from User Zero's seed phrase + * - JWT payload includes User Zero's DID as issuer and subject + * + * @returns Promise ES256K signed JWT token for User Zero */ export async function generateEndorserJWT(): Promise { try { @@ -237,44 +249,148 @@ export async function generateEndorserJWT(): Promise { 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); + // Derive Ethereum private key from User Zero's seed phrase + // Using User Zero's specific derivation path: m/84737769'/0'/0'/0' + // This is the path used when User Zero was registered on the server + // NOTE: Must use fromMnemonic() with the Mnemonic object, not fromPhrase() with phrase string + const userZeroSeedPhrase = TEST_USER_ZERO_CONFIG.identity.seedPhrase; + const mnemonic = Mnemonic.fromPhrase(userZeroSeedPhrase); + const derivationPath = "m/84737769'/0'/0'/0'"; + const wallet = HDNodeWallet.fromMnemonic(mnemonic, derivationPath); const privateKeyHex = wallet.privateKey.slice(2); // Remove '0x' prefix - // Create signer for ES256K (Ethereum secp256k1 curve) + // Extract public key from wallet in compressed format + // ethers.js wallet.signingKey.compressedPublicKey provides the compressed format + // Compressed format: 33 bytes (0x02 or 0x03 prefix + 32-byte x-coordinate) + const signingKey = wallet.signingKey; + const compressedPublicKey = signingKey.compressedPublicKey; // This should be the compressed public key + const publicKeyHexRaw = compressedPublicKey ? compressedPublicKey.slice(2).toLowerCase() : null; + + // Fallback: if compressed public key not available, use first 66 hex chars from uncompressed + // Note: This is a workaround - we should have compressedPublicKey from signingKey + const publicKeyHexFinal = publicKeyHexRaw || wallet.publicKey.slice(2).substring(0, 66).toLowerCase(); + + // Verify derived address matches User Zero's DID + const derivedAddress = wallet.address.toLowerCase(); + const expectedAddress = TEST_USER_ZERO_CONFIG.identity.did.replace('did:ethr:', '').toLowerCase(); + const logger = await getLogger(); + + // Verify public key matches expected (if provided) + const expectedPublicKeyHex = TEST_USER_ZERO_CONFIG.identity.expectedPublicKeyHex; + + // Comprehensive verification logging + if (derivedAddress !== expectedAddress) { + logger.error('⚠️ Key derivation address mismatch!', { + derivedAddress: derivedAddress, + expectedAddress: expectedAddress, + derivationPath: derivationPath, + note: 'JWT signing may fail if server expects different key' + }); + logger.error('⚠️ CRITICAL: Derived address does not match User Zero DID!', { + derived: derivedAddress, + expected: expectedAddress, + path: derivationPath, + warning: 'JWT verification will likely fail on server' + }); + } else { + logger.custom('✅', 'Key derivation verified - address matches User Zero DID'); + } + + // Verify public key if expected value is provided + if (expectedPublicKeyHex && publicKeyHexFinal) { + // Compare the derived public key with expected (both should be compressed format) + const publicKeyMatchesActual = publicKeyHexFinal.toLowerCase() === expectedPublicKeyHex.toLowerCase(); + + if (!publicKeyMatchesActual) { + logger.error('⚠️ CRITICAL: Derived public key does not match expected!', { + derivedPublicKey: publicKeyHexFinal, + expectedPublicKey: expectedPublicKeyHex, + derivationPath: derivationPath, + warning: 'Server will not recognize this public key - JWT verification will fail' + }); + logger.error('⚠️ Public Key Mismatch Details:', { + derived: publicKeyHexFinal.substring(0, 10) + '...' + publicKeyHexFinal.substring(publicKeyHexFinal.length - 10), + expected: expectedPublicKeyHex.substring(0, 10) + '...' + expectedPublicKeyHex.substring(expectedPublicKeyHex.length - 10), + derivedLength: publicKeyHexFinal.length, + expectedLength: expectedPublicKeyHex.length, + derivedPrefix: publicKeyHexFinal.substring(0, 2), // Should be 02 or 03 + expectedPrefix: expectedPublicKeyHex.substring(0, 2), + note: 'Verify derivation path and seed phrase are correct' + }); + } else { + logger.custom('✅', 'Public key verified - matches expected User Zero public key'); + logger.info('✅ Public Key Verification:', { + derived: publicKeyHexFinal.substring(0, 20) + '...' + publicKeyHexFinal.substring(publicKeyHexFinal.length - 20), + expected: expectedPublicKeyHex.substring(0, 20) + '...' + expectedPublicKeyHex.substring(expectedPublicKeyHex.length - 20), + match: true + }); + } + } + + // Log comprehensive key derivation diagnostics + logger.info('🔐 JWT Key Derivation: Complete diagnostics', { + address: derivedAddress, + addressMatch: derivedAddress === expectedAddress, + derivationPath: derivationPath, + publicKeyHex: publicKeyHexFinal ? (publicKeyHexFinal.substring(0, 20) + '...' + publicKeyHexFinal.substring(publicKeyHexFinal.length - 20)) : 'N/A', + expectedPublicKeyHex: expectedPublicKeyHex ? (expectedPublicKeyHex.substring(0, 20) + '...' + expectedPublicKeyHex.substring(expectedPublicKeyHex.length - 20)) : 'N/A', + publicKeyMatch: expectedPublicKeyHex && publicKeyHexFinal ? (publicKeyHexFinal.toLowerCase() === expectedPublicKeyHex.toLowerCase()) : null, + publicKeyLength: publicKeyHexFinal?.length || 0, + expectedPublicKeyLength: expectedPublicKeyHex?.length || 0, + hasCompressedPublicKey: !!compressedPublicKey + }); + + // Create signer for ES256K (Ethereum secp256k1 curve) using User Zero's private key const signer = SimpleSigner(privateKeyHex); - // Create JWT payload with standard claims + // Create JWT payload with User Zero's DID as issuer and subject const nowEpoch = Math.floor(Date.now() / 1000); const expiresIn = TEST_USER_ZERO_CONFIG.api.jwtExpirationMinutes * 60; + const userZeroDid = TEST_USER_ZERO_CONFIG.identity.did; const payload = { - // Standard JWT claims + // Standard JWT claims - all using User Zero's DID 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" + iss: userZeroDid, // User Zero's DID as issuer + sub: userZeroDid, // User Zero's DID as subject + // NOTE: aud (audience) claim is omitted because server's did-jwt verifyJWT() + // requires audience option when aud is present, but server isn't configured + // to validate it. Server will reject JWTs with aud claim until it adds support. }; - // Create ES256K signed JWT (ES256K is the default algorithm for did-jwt) + // Create ES256K signed JWT using User Zero's DID and private key + // ES256K is the default algorithm for did-jwt + // NOTE: sub claim is included in payload; aud is omitted because server's + // did-jwt verifyJWT() requires audience option when aud is present, but + // server isn't configured to validate it const jwt = await createJWT(payload, { - issuer: TEST_USER_ZERO_CONFIG.identity.did, - signer: signer, + issuer: userZeroDid, // User Zero's DID (overwrites payload.iss) + signer: signer, // User Zero's private key signer expiresIn: expiresIn }); - const log = await getLogger(); - log.custom("🔐", "JWT generated - Algorithm: ES256K, DID:", TEST_USER_ZERO_CONFIG.identity.did.substring(0, 30) + "..."); - log.custom("🔐", "JWT length:", jwt.length, "characters"); + logger.custom("🔐", "JWT generated for User Zero - Algorithm: ES256K, DID:", userZeroDid.substring(0, 30) + "..."); + logger.custom("🔐", "JWT length:", jwt.length, "characters"); + + // Enhanced diagnostic logging + logger.info('🔐 JWT Generation Diagnostics:', { + algorithm: 'ES256K', + issuerDid: userZeroDid, + issuerAddress: userZeroDid.replace('did:ethr:', '').toLowerCase(), + derivedAddress: derivedAddress, + derivationPath: derivationPath, + addressMatch: derivedAddress === expectedAddress, + jwtLength: jwt.length, + jwtPreview: jwt.substring(0, 20) + '...' + jwt.substring(jwt.length - 20), + expiresIn: expiresIn, + nowEpoch: nowEpoch + }); return jwt; } catch (error) { - const log = await getLogger(); - log.error('Failed to generate ES256K JWT:', error); + const logger = await getLogger(); + logger.error('Failed to generate ES256K JWT:', error); throw new Error(`JWT generation failed: ${error instanceof Error ? error.message : String(error)}`); } } diff --git a/test-apps/daily-notification-test/src/views/HomeView.vue b/test-apps/daily-notification-test/src/views/HomeView.vue index bd2718c..72b1550 100644 --- a/test-apps/daily-notification-test/src/views/HomeView.vue +++ b/test-apps/daily-notification-test/src/views/HomeView.vue @@ -450,7 +450,9 @@ const configureNativeFetcher = async (): Promise => { try { console.log('🚀 HomeView: Starting native fetcher configuration...') - logger.info('Configuring native fetcher from HomeView...') + console.log('👤 HomeView: Using User Zero identity:', TEST_USER_ZERO_CONFIG.identity.name) + console.log('🔑 HomeView: User Zero DID:', TEST_USER_ZERO_CONFIG.identity.did) + logger.info('Configuring native fetcher from HomeView using User Zero identity...') // Get API server URL const apiBaseUrl = TEST_USER_ZERO_CONFIG.getApiServerUrl() @@ -465,22 +467,25 @@ const configureNativeFetcher = async (): Promise => { return } - console.log('🔧 HomeView: Generating ES256K JWT token...') - // Generate JWT token for authentication + console.log('🔧 HomeView: Generating ES256K JWT token for User Zero...') + // Generate JWT token for authentication using User Zero's DID and seed phrase + // This uses TEST_USER_ZERO_CONFIG.identity.did and TEST_USER_ZERO_CONFIG.identity.seedPhrase const jwtToken = await generateEndorserJWT() - console.log('✅ HomeView: JWT token generated, length:', jwtToken.length) + console.log('✅ HomeView: JWT token generated for User Zero, length:', jwtToken.length) - console.log('🔧 HomeView: Calling configureNativeFetcher with:', { + console.log('🔧 HomeView: Configuring native fetcher with User Zero credentials:', { apiBaseUrl, - activeDid: TEST_USER_ZERO_CONFIG.identity.did.substring(0, 30) + '...', + activeDid: TEST_USER_ZERO_CONFIG.identity.did, + userZeroName: TEST_USER_ZERO_CONFIG.identity.name, jwtTokenLength: jwtToken.length }) - // Configure native fetcher with credentials + // Configure native fetcher with User Zero's credentials + // This passes User Zero's DID and JWT token (signed with User Zero's private key) await DailyNotification.configureNativeFetcher({ apiBaseUrl: apiBaseUrl, - activeDid: TEST_USER_ZERO_CONFIG.identity.did, - jwtToken: jwtToken + activeDid: TEST_USER_ZERO_CONFIG.identity.did, // User Zero's DID + jwtToken: jwtToken // JWT signed with User Zero's private key derived from seed phrase }) console.log('✅ HomeView: Native fetcher configured successfully!')