Browse Source
- 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.master
4 changed files with 298 additions and 7 deletions
@ -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. |
||||
|
|
||||
Loading…
Reference in new issue