You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

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

  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()):

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