9.6 KiB
						
					
					
				
			
		
		
		
			
			
			
				
					
				
				
					
				
			
		
		
	
	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
- TypeScript → Plugin: Plan IDs are passed via Capacitor bridge from Vue/React components
 - Plugin → Native Storage: Plugin stores to platform-specific storage (SharedPreferences/UserDefaults)
 - 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()):
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()):
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()):
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()):
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)
@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)
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:
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 toUserDefaults.standardwith key"starredPlanIds" - Native fetcher 
getStarredPlanIds()reads fromUserDefaults.standardwith 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:
- Vue/TypeScript (Foreground): 
DailyNotification.updateStarredPlans({ planIds: [...] }) - Capacitor Bridge: Transfers data from JS → Native (Android/iOS)
 - Plugin Native Code: Receives planIds, stores to platform storage
- Android: 
SharedPreferences.put("starredPlanIds", json) - iOS: 
UserDefaults.set("starredPlanIds", json) 
 - Android: 
 - Background Worker: Reads directly from platform storage (NO bridge)
- Android: 
SharedPreferences.get("starredPlanIds") - iOS: 
UserDefaults.string(forKey: "starredPlanIds") 
 - Android: 
 
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.