# 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.