fix(android): resolve SharedPreferences mismatch and document cross-platform storage pattern
- 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.
This commit is contained in:
262
docs/CROSS_PLATFORM_STORAGE_PATTERN.md
Normal file
262
docs/CROSS_PLATFORM_STORAGE_PATTERN.md
Normal file
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user