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