Browse Source

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.
master
Matthew Raymer 11 hours ago
parent
commit
f256113ed9
  1. 262
      docs/CROSS_PLATFORM_STORAGE_PATTERN.md
  2. 25
      test-apps/daily-notification-test/android/app/src/main/java/com/timesafari/dailynotification/test/TestNativeFetcher.java
  3. 14
      test-apps/daily-notification-test/src/App.vue
  4. 4
      test-apps/daily-notification-test/src/config/test-user-zero.ts

262
docs/CROSS_PLATFORM_STORAGE_PATTERN.md

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

25
test-apps/daily-notification-test/android/app/src/main/java/com/timesafari/dailynotification/test/TestNativeFetcher.java

@ -50,9 +50,11 @@ public class TestNativeFetcher implements NativeNotificationContentFetcher {
private static final int RETRY_DELAY_MS = 1000; // Base delay for exponential backoff
// SharedPreferences constants
private static final String PREFS_NAME = "DailyNotificationPrefs";
private static final String KEY_STARRED_PLAN_IDS = "starred_plan_ids";
private static final String KEY_LAST_ACKED_JWT_ID = "last_acked_jwt_id";
// NOTE: Must match plugin's SharedPreferences name and keys for starred plans
// Plugin uses "daily_notification_timesafari" (see DailyNotificationPlugin.updateStarredPlans)
private static final String PREFS_NAME = "daily_notification_timesafari";
private static final String KEY_STARRED_PLAN_IDS = "starredPlanIds"; // Matches plugin key
private static final String KEY_LAST_ACKED_JWT_ID = "last_acked_jwt_id"; // Plugin doesn't use this yet
private final Gson gson = new Gson();
private final Context appContext;
@ -304,12 +306,17 @@ public class TestNativeFetcher implements NativeNotificationContentFetcher {
*/
private List<String> getStarredPlanIds() {
try {
String idsJson = prefs.getString(KEY_STARRED_PLAN_IDS, "[]");
// Use the same SharedPreferences as the plugin (not the instance variable 'prefs')
// Plugin stores in "daily_notification_timesafari" with key "starredPlanIds"
SharedPreferences pluginPrefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
String idsJson = pluginPrefs.getString(KEY_STARRED_PLAN_IDS, "[]");
if (idsJson == null || idsJson.isEmpty() || idsJson.equals("[]")) {
Log.d(TAG, "TestNativeFetcher: No starred plan IDs found in SharedPreferences");
return new ArrayList<>();
}
// Parse JSON array
// Parse JSON array (plugin stores as JSON string)
JsonParser parser = new JsonParser();
JsonArray jsonArray = parser.parse(idsJson).getAsJsonArray();
List<String> planIds = new ArrayList<>();
@ -318,11 +325,15 @@ public class TestNativeFetcher implements NativeNotificationContentFetcher {
planIds.add(jsonArray.get(i).getAsString());
}
Log.d(TAG, "TestNativeFetcher: Loaded " + planIds.size() + " starred plan IDs");
Log.i(TAG, "TestNativeFetcher: Loaded " + planIds.size() + " starred plan IDs from SharedPreferences");
if (planIds.size() > 0) {
Log.d(TAG, "TestNativeFetcher: First plan ID: " +
planIds.get(0).substring(0, Math.min(30, planIds.get(0).length())) + "...");
}
return planIds;
} catch (Exception e) {
Log.e(TAG, "TestNativeFetcher: Error loading starred plan IDs", e);
Log.e(TAG, "TestNativeFetcher: Error loading starred plan IDs from SharedPreferences", e);
return new ArrayList<>();
}
}

14
test-apps/daily-notification-test/src/App.vue

@ -94,6 +94,20 @@ class App extends Vue {
apiBaseUrl: apiBaseUrl.substring(0, 50) + '...',
activeDid: TEST_USER_ZERO_CONFIG.identity.did.substring(0, 30) + '...'
})
// Update starred plan IDs from config so native fetcher can use them
console.log('🔧 App.vue: Updating starred plan IDs...')
const planIds = [...TEST_USER_ZERO_CONFIG.starredProjects.planIds]
console.log('🔧 App.vue: Plan IDs to update:', planIds.length, 'plans')
const updateResult = await DailyNotification.updateStarredPlans({
planIds: planIds
})
console.log('✅ App.vue: Starred plans updated:', {
count: updateResult.planIdsCount,
updatedAt: new Date(updateResult.updatedAt).toISOString()
})
} catch (error) {
console.error('❌ App.vue: Failed to configure native fetcher:', error)
console.error('❌ App.vue: Error details:', error instanceof Error ? error.stack : String(error))

4
test-apps/daily-notification-test/src/config/test-user-zero.ts

@ -267,6 +267,10 @@ export async function generateEndorserJWT(): Promise<string> {
expiresIn: expiresIn
});
const log = await getLogger();
log.custom("🔐", "JWT generated - Algorithm: ES256K, DID:", TEST_USER_ZERO_CONFIG.identity.did.substring(0, 30) + "...");
log.custom("🔐", "JWT length:", jwt.length, "characters");
return jwt;
} catch (error) {
const log = await getLogger();

Loading…
Cancel
Save