From f256113ed9ad8135237e1abbd0a2eb4f7f4946f0 Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Fri, 31 Oct 2025 13:02:30 +0000 Subject: [PATCH] fix(android): resolve SharedPreferences mismatch and document cross-platform storage pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix TestNativeFetcher to read from same SharedPreferences as plugin - Changed PREFS_NAME from 'DailyNotificationPrefs' to 'daily_notification_timesafari' - Changed KEY_STARRED_PLAN_IDS from 'starred_plan_ids' to 'starredPlanIds' - Updated getStarredPlanIds() to read from plugin's SharedPreferences location - Added diagnostic logging for plan ID loading - Add updateStarredPlans() call in App.vue mounted() hook - Ensures starred plan IDs are persisted to native storage on app startup - Allows native fetcher to read plan IDs from SharedPreferences - Added diagnostic logging for configuration flow - Document cross-platform storage pattern - Created docs/CROSS_PLATFORM_STORAGE_PATTERN.md with architecture flow - Documented TypeScript → Capacitor bridge → Plugin → Native storage → Native fetcher flow - Added iOS implementation checklist with code examples - Clarified why native storage is needed (background workers can't use bridge) - Add JWT generation logging to test-user-zero.ts - Log JWT algorithm (ES256K) and DID when token is generated - Helps diagnose JWT verification issues Fixes: - Empty planIds array in native fetcher requests - SharedPreferences key mismatch between plugin and native fetcher - Missing documentation for iOS implementation All changes maintain backward compatibility. --- docs/CROSS_PLATFORM_STORAGE_PATTERN.md | 262 ++++++++++++++++++ .../test/TestNativeFetcher.java | 25 +- test-apps/daily-notification-test/src/App.vue | 14 + .../src/config/test-user-zero.ts | 4 + 4 files changed, 298 insertions(+), 7 deletions(-) create mode 100644 docs/CROSS_PLATFORM_STORAGE_PATTERN.md diff --git a/docs/CROSS_PLATFORM_STORAGE_PATTERN.md b/docs/CROSS_PLATFORM_STORAGE_PATTERN.md new file mode 100644 index 0000000..7ab9d0f --- /dev/null +++ b/docs/CROSS_PLATFORM_STORAGE_PATTERN.md @@ -0,0 +1,262 @@ +# Cross-Platform Storage Pattern for Starred Plans + +**Author**: Matthew Raymer +**Date**: 2025-10-31 +**Status**: 🎯 **REFERENCE** - Pattern for platform-specific storage consistency + +## Problem + +Native fetcher implementations need to read starred plan IDs that are stored by the plugin. However: +- **Android**: Uses `SharedPreferences` +- **iOS**: Uses `UserDefaults` +- **Background workers**: Cannot use Capacitor bridge (unreliable in background) + +This creates a requirement that **both plugin and native fetcher** use the **same platform storage mechanism** on each platform. + +## Architecture Flow + +### How Plan IDs Flow Through the System + +``` +TypeScript/Vue (Foreground) + │ + ├─► DailyNotification.updateStarredPlans({ planIds: [...] }) + │ └─► Capacitor Bridge (JS → Native) + │ │ + │ └─► Plugin.updateStarredPlans() (Native Code) + │ │ + │ ├─► Android: SharedPreferences.put("starredPlanIds", json) + │ └─► iOS: UserDefaults.set("starredPlanIds", json) + │ +Background Worker (WorkManager/BGTaskScheduler) + │ + └─► Native Fetcher.fetchContent(context) + │ + ├─► Android: SharedPreferences.get("starredPlanIds") + └─► iOS: UserDefaults.string(forKey: "starredPlanIds") +``` + +### Key Points + +1. **TypeScript → Plugin**: Plan IDs are passed **via Capacitor bridge** from Vue/React components +2. **Plugin → Native Storage**: Plugin stores to **platform-specific storage** (SharedPreferences/UserDefaults) +3. **Native Fetcher → Native Storage**: Native fetcher **reads directly** from platform storage (NO bridge needed) + +### Why This Architecture? + +- **Foreground (TypeScript)**: Can reliably use Capacitor bridge → Plugin +- **Background Workers**: **Cannot** use Capacitor bridge (unreliable/blocking) → Must read from native storage directly +- **Storage as Bridge**: Native storage acts as the "communication layer" between foreground and background + +## Solution: Platform-Specific Storage with Consistent Keys + +### Pattern + +Each platform uses its native storage API, but with **consistent key names** across plugin and native fetcher: + +| Platform | Storage API | SharedPreferences Name | Key Name | +|----------|------------|------------------------|----------| +| **Android** | `SharedPreferences` | `"daily_notification_timesafari"` | `"starredPlanIds"` | +| **iOS** | `UserDefaults` | `UserDefaults.standard` (app suite) | `"starredPlanIds"` | + +### Android Implementation + +**Plugin Storage** (`DailyNotificationPlugin.updateStarredPlans()`): +```java +SharedPreferences preferences = getContext() + .getSharedPreferences("daily_notification_timesafari", Context.MODE_PRIVATE); + +org.json.JSONArray jsonArray = new org.json.JSONArray(); +for (String planId : planIds) { + jsonArray.put(planId); +} + +preferences.edit() + .putString("starredPlanIds", jsonArray.toString()) + .putLong("starredPlansUpdatedAt", System.currentTimeMillis()) + .apply(); +``` + +**Native Fetcher Reading** (`TestNativeFetcher.getStarredPlanIds()`): +```java +SharedPreferences pluginPrefs = appContext.getSharedPreferences( + "daily_notification_timesafari", Context.MODE_PRIVATE); +String idsJson = pluginPrefs.getString("starredPlanIds", "[]"); +// Parse JSON array... +``` + +### iOS Implementation (Required) + +**Plugin Storage** (`DailyNotificationPlugin.updateStarredPlans()`): +```swift +func updateStarredPlans(call: CAPPluginCall) { + guard let planIds = call.getArray("planIds", String.self) else { + call.reject("planIds is required") + return + } + + // Store in UserDefaults with same key as Android + let jsonData = try? JSONSerialization.data(withJSONObject: planIds) + let jsonString = String(data: jsonData!, encoding: .utf8) + + UserDefaults.standard.set(jsonString, forKey: "starredPlanIds") + UserDefaults.standard.set(Date().timeIntervalSince1970 * 1000, + forKey: "starredPlansUpdatedAt") + + call.resolve(["success": true, "planIdsCount": planIds.count]) +} +``` + +**Native Fetcher Reading** (`TimeSafariNativeFetcher.getStarredPlanIds()`): +```swift +private func getStarredPlanIds() -> [String] { + guard let jsonString = UserDefaults.standard.string(forKey: "starredPlanIds"), + let jsonData = jsonString.data(using: .utf8), + let planIds = try? JSONSerialization.jsonObject(with: jsonData) as? [String] else { + return [] + } + return planIds +} +``` + +## iOS Implementation Checklist + +When implementing iOS, follow this exact pattern: + +### Step 1: Add Plugin Method (`DailyNotificationPlugin.swift`) + +```swift +@objc func updateStarredPlans(_ call: CAPPluginCall) { + guard let planIds = call.getArray("planIds", String.self) else { + call.reject("planIds is required") + return + } + + // Store in UserDefaults with same key as Android + do { + let jsonData = try JSONSerialization.data(withJSONObject: planIds) + let jsonString = String(data: jsonData, encoding: .utf8) ?? "[]" + + UserDefaults.standard.set(jsonString, forKey: "starredPlanIds") + UserDefaults.standard.set(Date().timeIntervalSince1970 * 1000, + forKey: "starredPlansUpdatedAt") + + print("DNP|UPDATE_STARRED_PLANS count=\(planIds.count)") + + call.resolve([ + "success": true, + "planIdsCount": planIds.count, + "updatedAt": Int64(Date().timeIntervalSince1970 * 1000) + ]) + } catch { + call.reject("Failed to serialize plan IDs: \(error.localizedDescription)") + } +} + +@objc func getStarredPlans(_ call: CAPPluginCall) { + guard let jsonString = UserDefaults.standard.string(forKey: "starredPlanIds"), + let jsonData = jsonString.data(using: .utf8), + let planIds = try? JSONSerialization.jsonObject(with: jsonData) as? [String] else { + call.resolve([ + "planIds": [], + "count": 0, + "updatedAt": 0 + ]) + return + } + + let updatedAt = UserDefaults.standard.double(forKey: "starredPlansUpdatedAt") + + call.resolve([ + "planIds": planIds, + "count": planIds.count, + "updatedAt": Int64(updatedAt) + ]) +} +``` + +### Step 2: Add Native Fetcher Reading (`TimeSafariNativeFetcher.swift`) + +```swift +private func getStarredPlanIds() -> [String] { + guard let jsonString = UserDefaults.standard.string(forKey: "starredPlanIds"), + let jsonData = jsonString.data(using: .utf8), + let planIds = try? JSONSerialization.jsonObject(with: jsonData) as? [String] else { + print("TestNativeFetcher: No starred plan IDs found in UserDefaults") + return [] + } + + print("TestNativeFetcher: Loaded \(planIds.count) starred plan IDs from UserDefaults") + return planIds +} +``` + +### Step 3: Register Plugin Methods + +Add to `DailyNotificationPlugin.swift` `load()` method: +```swift +override public func load() { + super.load() + setupBackgroundTasks() + // Register methods + NotificationCenter.default.addObserver( + self, + selector: #selector(updateStarredPlans), + name: NSNotification.Name("DailyNotification.updateStarredPlans"), + object: nil + ) +} +``` + +## Key Consistency Rules + +### ✅ DO: +- Use **same SharedPreferences name** on Android: `"daily_notification_timesafari"` +- Use **same UserDefaults key** on iOS: `"starredPlanIds"` (same key name!) +- Store as **JSON array string** for cross-language compatibility +- Use **same key names** across plugin and native fetcher +- **TypeScript passes planIds via Capacitor bridge** → Plugin stores to native storage → Native fetcher reads from native storage + +### ❌ DON'T: +- Use different storage locations for plugin vs native fetcher +- Use Capacitor Preferences API in native fetchers (background workers can't access bridge) +- Store in different formats (e.g., JSON vs array) between plugin and fetcher +- Try to use Capacitor bridge in background workers (unreliable/blocking) + +## Testing Checklist + +When implementing iOS native fetcher: + +- [ ] Plugin `updateStarredPlans()` stores to `UserDefaults.standard` with key `"starredPlanIds"` +- [ ] Native fetcher `getStarredPlanIds()` reads from `UserDefaults.standard` with key `"starredPlanIds"` +- [ ] Both use same JSON array string format +- [ ] Storage format matches Android (JSON array string) +- [ ] TypeScript `updateStarredPlans()` call works via Capacitor bridge +- [ ] Background worker can read plan IDs without Capacitor bridge + +## Migration Notes + +If storage format changes: +- Both Android and iOS implementations must be updated together +- Migration code should handle both platforms consistently +- Test plan ID persistence across app restarts on both platforms + +## Summary + +**Question**: Are planIds passed via TypeScript in Vue over the Capacitor bridge? + +**Answer**: **YES** - Here's the flow: + +1. **Vue/TypeScript** (Foreground): `DailyNotification.updateStarredPlans({ planIds: [...] })` +2. **Capacitor Bridge**: Transfers data from JS → Native (Android/iOS) +3. **Plugin Native Code**: Receives planIds, stores to platform storage + - Android: `SharedPreferences.put("starredPlanIds", json)` + - iOS: `UserDefaults.set("starredPlanIds", json)` +4. **Background Worker**: Reads directly from platform storage (NO bridge) + - Android: `SharedPreferences.get("starredPlanIds")` + - iOS: `UserDefaults.string(forKey: "starredPlanIds")` + +**Key Insight**: The Capacitor bridge is used **only for foreground operations** (TypeScript → Plugin). Background workers **cannot use the bridge**, so they read from the same native storage where the plugin wrote the data. + +This is why **consistent key names** are critical - it's the "contract" between plugin storage and native fetcher reading. + 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 833cd36..32486a6 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 @@ -50,9 +50,11 @@ public class TestNativeFetcher implements NativeNotificationContentFetcher { 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"; + // NOTE: Must match plugin's SharedPreferences name and keys for starred plans + // Plugin uses "daily_notification_timesafari" (see DailyNotificationPlugin.updateStarredPlans) + private static final String PREFS_NAME = "daily_notification_timesafari"; + private static final String KEY_STARRED_PLAN_IDS = "starredPlanIds"; // Matches plugin key + private static final String KEY_LAST_ACKED_JWT_ID = "last_acked_jwt_id"; // Plugin doesn't use this yet private final Gson gson = new Gson(); private final Context appContext; @@ -304,12 +306,17 @@ public class TestNativeFetcher implements NativeNotificationContentFetcher { */ private List getStarredPlanIds() { try { - String idsJson = prefs.getString(KEY_STARRED_PLAN_IDS, "[]"); + // Use the same SharedPreferences as the plugin (not the instance variable 'prefs') + // Plugin stores in "daily_notification_timesafari" with key "starredPlanIds" + SharedPreferences pluginPrefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + String idsJson = pluginPrefs.getString(KEY_STARRED_PLAN_IDS, "[]"); + if (idsJson == null || idsJson.isEmpty() || idsJson.equals("[]")) { + Log.d(TAG, "TestNativeFetcher: No starred plan IDs found in SharedPreferences"); return new ArrayList<>(); } - // Parse JSON array + // Parse JSON array (plugin stores as JSON string) JsonParser parser = new JsonParser(); JsonArray jsonArray = parser.parse(idsJson).getAsJsonArray(); List planIds = new ArrayList<>(); @@ -318,11 +325,15 @@ public class TestNativeFetcher implements NativeNotificationContentFetcher { planIds.add(jsonArray.get(i).getAsString()); } - Log.d(TAG, "TestNativeFetcher: Loaded " + planIds.size() + " starred plan IDs"); + Log.i(TAG, "TestNativeFetcher: Loaded " + planIds.size() + " starred plan IDs from SharedPreferences"); + if (planIds.size() > 0) { + Log.d(TAG, "TestNativeFetcher: First plan ID: " + + planIds.get(0).substring(0, Math.min(30, planIds.get(0).length())) + "..."); + } return planIds; } catch (Exception e) { - Log.e(TAG, "TestNativeFetcher: Error loading starred plan IDs", e); + Log.e(TAG, "TestNativeFetcher: Error loading starred plan IDs from SharedPreferences", e); return new ArrayList<>(); } } diff --git a/test-apps/daily-notification-test/src/App.vue b/test-apps/daily-notification-test/src/App.vue index 11c4b5b..9bf3615 100644 --- a/test-apps/daily-notification-test/src/App.vue +++ b/test-apps/daily-notification-test/src/App.vue @@ -94,6 +94,20 @@ class App extends Vue { apiBaseUrl: apiBaseUrl.substring(0, 50) + '...', activeDid: TEST_USER_ZERO_CONFIG.identity.did.substring(0, 30) + '...' }) + + // Update starred plan IDs from config so native fetcher can use them + console.log('🔧 App.vue: Updating starred plan IDs...') + const planIds = [...TEST_USER_ZERO_CONFIG.starredProjects.planIds] + console.log('🔧 App.vue: Plan IDs to update:', planIds.length, 'plans') + + const updateResult = await DailyNotification.updateStarredPlans({ + planIds: planIds + }) + + console.log('✅ App.vue: Starred plans updated:', { + count: updateResult.planIdsCount, + updatedAt: new Date(updateResult.updatedAt).toISOString() + }) } catch (error) { console.error('❌ App.vue: Failed to configure native fetcher:', error) console.error('❌ App.vue: Error details:', error instanceof Error ? error.stack : String(error)) 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 d3fdc7c..05623fa 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 @@ -267,6 +267,10 @@ export async function generateEndorserJWT(): Promise { 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"); + return jwt; } catch (error) { const log = await getLogger();