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.