feat(configureNativeFetcher): optional JWT pool for background native fetch

Add jwtTokens / jwtTokenPoolJson to the TypeScript API, parse and validate
(max 128) on Android and iOS, persist jwtTokenPool with native_fetcher_config
when persistToken is true (Android), and extend NativeNotificationContentFetcher
with a four-argument configure overload delegating to the existing three-arg
default. iOS stores the pool in UserDefaults JSON and uses primary jwt or first
pool entry in the plugin background fetch path. Bump version to 2.2.0. Update
TestNativeFetcher to exercise the new configure overload.
This commit is contained in:
Jose Olarte III
2026-03-27 16:30:31 +08:00
parent 469167a55f
commit 9121b1e0f7
8 changed files with 159 additions and 26 deletions

View File

@@ -22,6 +22,12 @@ object DailyNotificationConstants {
// Permission Request Codes
// ============================================================
/**
* Maximum number of distinct JWT strings allowed in [configureNativeFetcher] `jwtTokens` / pool.
* Host apps (e.g. TimeSafari) use a pool for background prefetch; cap avoids oversized bridge payloads.
*/
const val JWT_TOKEN_POOL_MAX = 128
/**
* Request code for notification permission requests
* Used by ActivityCompat.requestPermissions()

View File

@@ -519,12 +519,22 @@ open class DailyNotificationPlugin : Plugin() {
val activeDid = options.getString("activeDid") ?: return call.reject("activeDid is required")
val jwtToken = options.getString("jwtToken") ?: options.getString("jwtSecret") ?: return call.reject("jwtToken or jwtSecret is required")
val jwtTokenPool: List<String>? = try {
parseJwtTokenPool(options)
} catch (e: IllegalArgumentException) {
return call.reject(e.message ?: "Invalid jwt token pool")
}
val nativeFetcher = getNativeFetcherStatic()
if (nativeFetcher == null) {
return call.reject("No native fetcher registered. Host app must register a NativeNotificationContentFetcher.")
}
Log.i(TAG, "Configuring native fetcher: apiBaseUrl=$apiBaseUrl, activeDid=$activeDid")
Log.i(
TAG,
"Configuring native fetcher: apiBaseUrl=$apiBaseUrl, activeDid=$activeDid" +
if (jwtTokenPool != null) ", jwtTokenPoolSize=${jwtTokenPool.size}" else ""
)
// Call the native fetcher's configure method FIRST
// This configures the fetcher instance with API credentials for background operations
@@ -533,7 +543,7 @@ open class DailyNotificationPlugin : Plugin() {
try {
Log.d(TAG, "FETCHER|CONFIGURE_START apiBaseUrl=$apiBaseUrl, activeDid=${activeDid.take(30)}...")
nativeFetcher.configure(apiBaseUrl, activeDid, jwtToken)
nativeFetcher.configure(apiBaseUrl, activeDid, jwtToken, jwtTokenPool)
configureSuccess = true
Log.i(TAG, "FETCHER|CONFIGURE_COMPLETE success=true")
} catch (e: Exception) {
@@ -566,6 +576,12 @@ open class DailyNotificationPlugin : Plugin() {
// Only store JWT token if explicitly opted-in
if (persistToken) {
put("jwtToken", jwtToken)
if (jwtTokenPool != null && jwtTokenPool.isNotEmpty()) {
put("jwtTokenPool", JSONArray(jwtTokenPool))
Log.w(TAG, "JWT token pool stored (size=${jwtTokenPool.size}, persistToken=true).")
} else {
put("jwtTokenPool", JSONArray())
}
Log.w(TAG, "JWT token stored in database (persistToken=true). " +
"Database is NOT encrypted - token is stored in plain text.")
} else {
@@ -607,6 +623,34 @@ open class DailyNotificationPlugin : Plugin() {
}
}
/**
* Optional JWT pool from `jwtTokens` (bridge array) or `jwtTokenPoolJson` (JSON array string).
* Prefer `jwtTokens` when both are present. Empty array → null (same as omitting).
*/
private fun parseJwtTokenPool(options: JSObject): List<String>? {
val arr: JSONArray = when {
options.has("jwtTokens") -> options.optJSONArray("jwtTokens")
!options.optString("jwtTokenPoolJson", "").isNullOrBlank() ->
JSONArray(options.getString("jwtTokenPoolJson"))
else -> null
} ?: return null
if (arr.length() == 0) return null
if (arr.length() > DailyNotificationConstants.JWT_TOKEN_POOL_MAX) {
throw IllegalArgumentException(
"jwtTokens must have at most ${DailyNotificationConstants.JWT_TOKEN_POOL_MAX} entries"
)
}
val out = ArrayList<String>(arr.length())
for (i in 0 until arr.length()) {
if (arr.isNull(i)) continue
val s = arr.optString(i)
if (s.isNotEmpty()) {
out.add(s)
}
}
return if (out.isEmpty()) null else out
}
@PluginMethod
fun getNotificationStatus(call: PluginCall) {
CoroutineScope(Dispatchers.IO).launch {

View File

@@ -18,6 +18,7 @@
package org.timesafari.dailynotification;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@@ -142,5 +143,21 @@ public interface NativeNotificationContentFetcher {
// This allows fetchers that don't need TypeScript-provided configuration
// to ignore this method without implementing an empty body.
}
/**
* Optional overload: distinct JWT strings for background use (e.g. one per day slot).
* Persisted when {@code persistToken} is true; host fetchers (e.g. TimeSafari) should choose
* which entry to use per fetch (e.g. day index modulo pool size).
* Default delegates to {@link #configure(String, String, String)} so existing fetchers stay compatible.
*
* @param jwtTokenPool validated list (max length 128), or null if the host omitted the pool or sent an empty array
*/
default void configure(
String apiBaseUrl,
String activeDid,
String jwtToken,
@Nullable List<String> jwtTokenPool) {
configure(apiBaseUrl, activeDid, jwtToken);
}
}

View File

@@ -249,16 +249,45 @@ public class DailyNotificationPlugin: CAPPlugin {
return
}
let jwtTokenPoolMax = 128
var jwtTokenPool: [String]? = nil
if let rawTokens = options["jwtTokens"] as? [Any] {
let parsed = rawTokens.compactMap { $0 as? String }.filter { !$0.isEmpty }
if parsed.count > jwtTokenPoolMax {
call.reject("jwtTokens must have at most \(jwtTokenPoolMax) entries")
return
}
jwtTokenPool = parsed.isEmpty ? nil : parsed
} else if let jsonStr = options["jwtTokenPoolJson"] as? String, !jsonStr.isEmpty {
guard let data = jsonStr.data(using: .utf8),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String] else {
call.reject("jwtTokenPoolJson must be a JSON array of strings")
return
}
let filtered = parsed.filter { !$0.isEmpty }
if filtered.count > jwtTokenPoolMax {
call.reject("jwtTokens must have at most \(jwtTokenPoolMax) entries")
return
}
jwtTokenPool = filtered.isEmpty ? nil : filtered
}
print("DNP-PLUGIN: Configuring native fetcher: apiBaseUrl=\(apiBaseUrl), activeDid=\(activeDid.prefix(30))...")
if let pool = jwtTokenPool {
print("DNP-PLUGIN: jwtTokenPool size=\(pool.count)")
}
// Store configuration in database for persistence across app restarts
// Note: iOS native fetcher interface not yet implemented, but we store config for future use
let configId = "native_fetcher_config"
let configValue: [String: Any] = [
var configValue: [String: Any] = [
"apiBaseUrl": apiBaseUrl,
"activeDid": activeDid,
"jwtToken": jwtToken
]
if let pool = jwtTokenPool {
configValue["jwtTokenPool"] = pool
}
// Convert to JSON string for storage
guard let jsonData = try? JSONSerialization.data(withJSONObject: configValue, options: []),
@@ -594,8 +623,11 @@ public class DailyNotificationPlugin: CAPPlugin {
let configData = configJson.data(using: .utf8),
let config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any],
let apiBaseUrl = config["apiBaseUrl"] as? String,
let activeDid = config["activeDid"] as? String,
let jwtToken = config["jwtToken"] as? String {
let activeDid = config["activeDid"] as? String {
let jwtFromPrimary = (config["jwtToken"] as? String).flatMap { $0.isEmpty ? nil : $0 }
let jwtFromPool = (config["jwtTokenPool"] as? [String])?.first { !$0.isEmpty }
let bearerToken = jwtFromPrimary ?? jwtFromPool
if let jwtToken = bearerToken {
// Phase 3: JWT-signed fetcher is configured - attempt HTTP fetch
print("DNP-FETCH: Using JWT-signed fetcher (apiBaseUrl=\(apiBaseUrl), activeDid=\(activeDid.prefix(30))...)")
@@ -623,6 +655,20 @@ public class DailyNotificationPlugin: CAPPlugin {
etag: nil
)
}
} else {
// Config present but no bearer (empty jwtToken and pool)
print("DNP-FETCH: Using dummy content (no bearer token)")
content = NotificationContent(
id: "dummy_\(Date().timeIntervalSince1970)",
title: "Daily Update",
body: "Your daily notification is ready",
scheduledTime: Int64(Date().timeIntervalSince1970 * 1000) + (5 * 60 * 1000),
fetchedAt: Int64(Date().timeIntervalSince1970 * 1000),
url: nil,
payload: nil,
etag: nil
)
}
} else {
// Fallback: Dummy content fetch (no network)
print("DNP-FETCH: Using dummy content (native fetcher not configured)")

View File

@@ -1,6 +1,6 @@
{
"name": "@timesafari/daily-notification-plugin",
"version": "2.1.5",
"version": "2.2.0",
"description": "TimeSafari Daily Notification Plugin - Enterprise-grade daily notification functionality with dual scheduling, callback support, TTL-at-fire logic, and comprehensive observability across Mobile (Capacitor) and Desktop (Electron) platforms",
"main": "dist/plugin.js",
"module": "dist/esm/index.js",

View File

@@ -458,7 +458,18 @@ export interface DailyNotificationPlugin {
configureNativeFetcher(options: {
apiBaseUrl: string;
activeDid: string;
jwtToken: string; // Pre-generated JWT token (ES256K signed) from TypeScript
/** Primary token; keep for backward compatibility and single-JWT flows. */
jwtToken: string;
/**
* Optional. Distinct JWT strings for background use (e.g. one per day slot).
* Persisted with native config when `persistToken` is true (same as primary token).
* If omitted or empty, behavior matches a single `jwtToken` only.
*/
jwtTokens?: string[];
/**
* Optional alternative to `jwtTokens` when bridge payload size matters: JSON array string of JWT strings.
*/
jwtTokenPoolJson?: string;
}): Promise<void>;
// Rolling window management

View File

@@ -27,6 +27,7 @@ import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import androidx.annotation.Nullable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
@@ -92,9 +93,17 @@ public class TestNativeFetcher implements NativeNotificationContentFetcher {
*/
@Override
public void configure(String apiBaseUrl, String activeDid, String jwtToken) {
configure(apiBaseUrl, activeDid, jwtToken, null);
}
@Override
public void configure(String apiBaseUrl, String activeDid, String jwtToken, @Nullable List<String> jwtTokenPool) {
this.apiBaseUrl = apiBaseUrl;
this.activeDid = activeDid;
this.jwtToken = jwtToken;
if (jwtTokenPool != null && !jwtTokenPool.isEmpty()) {
Log.i(TAG, "TestNativeFetcher: jwtTokenPool size=" + jwtTokenPool.size());
}
// Enhanced logging for JWT diagnostic purposes
Log.i(TAG, "TestNativeFetcher: Configured with API: " + apiBaseUrl);

View File

@@ -46,7 +46,7 @@
},
"../..": {
"name": "@timesafari/daily-notification-plugin",
"version": "1.0.11",
"version": "2.2.0",
"license": "MIT",
"workspaces": [
"packages/*"