feat(notifications): mint JWT pool for native fetcher; log API response

- Mint BACKGROUND_JWT_POOL_SIZE (90 + 10) distinct background JWTs with
  unique jti; pass jwtTokens from nativeFetcherConfig into configureNativeFetcher.
- Android TimeSafariNativeFetcher: overload configure with jwtTokenPool;
  select bearer via epoch day mod pool size; fall back to primary jwtToken.
- Log truncated plansLastUpdatedBetween response at DEBUG for prefetch debugging.
This commit is contained in:
Jose Olarte III
2026-03-30 17:25:50 +08:00
parent 9f44a53047
commit 2c8aa21fa5
4 changed files with 98 additions and 9 deletions

View File

@@ -5,6 +5,7 @@ import android.content.SharedPreferences;
import android.util.Log; import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.gson.Gson; import com.google.gson.Gson;
import com.google.gson.JsonArray; import com.google.gson.JsonArray;
@@ -42,6 +43,8 @@ public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher
private static final int CONNECT_TIMEOUT_MS = 10000; private static final int CONNECT_TIMEOUT_MS = 10000;
private static final int READ_TIMEOUT_MS = 15000; private static final int READ_TIMEOUT_MS = 15000;
private static final int MAX_RETRIES = 3; private static final int MAX_RETRIES = 3;
/** Max chars of response body logged at DEBUG (avoids huge log lines). */
private static final int MAX_RESPONSE_BODY_LOG_CHARS = 4096;
private static final int RETRY_DELAY_MS = 1000; private static final int RETRY_DELAY_MS = 1000;
// Must match plugin's SharedPreferences name and keys (DailyNotificationPlugin / TimeSafariIntegrationManager) // Must match plugin's SharedPreferences name and keys (DailyNotificationPlugin / TimeSafariIntegrationManager)
@@ -56,6 +59,9 @@ public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher
private volatile String apiBaseUrl; private volatile String apiBaseUrl;
private volatile String activeDid; private volatile String activeDid;
private volatile String jwtToken; private volatile String jwtToken;
/** Distinct JWTs from configureNativeFetcher `jwtTokens`; null = use jwtToken only. */
@Nullable
private List<String> jwtTokenPool;
public TimeSafariNativeFetcher(Context context) { public TimeSafariNativeFetcher(Context context) {
this.appContext = context.getApplicationContext(); this.appContext = context.getApplicationContext();
@@ -64,11 +70,48 @@ public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher
@Override @Override
public void configure(String apiBaseUrl, String activeDid, String jwtToken) { 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.apiBaseUrl = apiBaseUrl;
this.activeDid = activeDid; this.activeDid = activeDid;
this.jwtToken = jwtToken; this.jwtToken = jwtToken;
this.jwtTokenPool =
jwtTokenPool != null && !jwtTokenPool.isEmpty()
? new ArrayList<>(jwtTokenPool)
: null;
int starredCount = getStarredPlanIds().size(); int starredCount = getStarredPlanIds().size();
Log.i(TAG, "Configured with API: " + apiBaseUrl + ", starredPlanIds count=" + starredCount); Log.i(
TAG,
"Configured with API: "
+ apiBaseUrl
+ ", starredPlanIds count="
+ starredCount
+ (this.jwtTokenPool != null
? ", jwtPoolSize=" + this.jwtTokenPool.size()
: ""));
}
/** One pool entry per UTC day (epoch day mod pool size); else primary jwtToken. */
private String selectBearerTokenForRequest() {
List<String> pool = jwtTokenPool;
if (pool == null || pool.isEmpty()) {
return jwtToken;
}
long epochDay = System.currentTimeMillis() / (24L * 60 * 60 * 1000);
int idx = (int) (epochDay % pool.size());
String t = pool.get(idx);
if (t == null || t.isEmpty()) {
return jwtToken;
}
Log.i(TAG, "Bearer from JWT pool: index=" + idx + " of " + pool.size());
return t;
} }
@NonNull @NonNull
@@ -92,7 +135,8 @@ public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher
return CompletableFuture.supplyAsync(() -> { return CompletableFuture.supplyAsync(() -> {
try { try {
Log.i(TAG, "fetchContent worker thread=" + Thread.currentThread().getName()); Log.i(TAG, "fetchContent worker thread=" + Thread.currentThread().getName());
if (apiBaseUrl == null || activeDid == null || jwtToken == null) { String bearer = selectBearerTokenForRequest();
if (apiBaseUrl == null || activeDid == null || bearer == null || bearer.isEmpty()) {
Log.e(TAG, "Not configured. Call configureNativeFetcher() from TypeScript first."); Log.e(TAG, "Not configured. Call configureNativeFetcher() from TypeScript first.");
return Collections.emptyList(); return Collections.emptyList();
} }
@@ -104,7 +148,7 @@ public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher
connection.setReadTimeout(READ_TIMEOUT_MS); connection.setReadTimeout(READ_TIMEOUT_MS);
connection.setRequestMethod("POST"); connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "application/json"); connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Authorization", "Bearer " + jwtToken); connection.setRequestProperty("Authorization", "Bearer " + bearer);
connection.setDoOutput(true); connection.setDoOutput(true);
Map<String, Object> requestBody = new HashMap<>(); Map<String, Object> requestBody = new HashMap<>();
@@ -143,6 +187,16 @@ public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher
} }
} }
String responseBody = response.toString(); String responseBody = response.toString();
String snippet =
responseBody.length() <= MAX_RESPONSE_BODY_LOG_CHARS
? responseBody
: responseBody.substring(0, MAX_RESPONSE_BODY_LOG_CHARS) + "";
Log.d(
TAG,
"plansLastUpdatedBetween response len="
+ responseBody.length()
+ " body="
+ snippet);
List<NotificationContent> contents = parseApiResponse(responseBody, context); List<NotificationContent> contents = parseApiResponse(responseBody, context);
if (!contents.isEmpty()) { if (!contents.isEmpty()) {
updateLastAckedJwtIdFromResponse(responseBody); updateLastAckedJwtIdFromResponse(responseBody);

View File

@@ -1,9 +1,15 @@
/** /**
* JWT lifetime for native New Activity background prefetch (`configureNativeFetcher`). * JWT lifetime for native New Activity background prefetch (`configureNativeFetcher`).
* Phase A: single long-lived token minted in TS; see doc/plan-background-jwt-pool-and-expiry.md. * See doc/plan-background-jwt-pool-and-expiry.md. Confirm max `exp` with Endorser before raising.
* Confirm max `exp` with Endorser before raising.
*/ */
export const BACKGROUND_JWT_EXPIRY_DAYS = 90; export const BACKGROUND_JWT_EXPIRY_DAYS = 90;
export const BACKGROUND_JWT_EXPIRY_SECONDS = export const BACKGROUND_JWT_EXPIRY_SECONDS =
BACKGROUND_JWT_EXPIRY_DAYS * 24 * 60 * 60; BACKGROUND_JWT_EXPIRY_DAYS * 24 * 60 * 60;
/** Headroom for retries / tests; pool size should be ≥ expiryDays + buffer. */
export const BACKGROUND_JWT_POOL_BUFFER = 10;
/** Distinct JWT strings minted per configure (duplicate-JWT / daily slot). */
export const BACKGROUND_JWT_POOL_SIZE =
BACKGROUND_JWT_EXPIRY_DAYS + BACKGROUND_JWT_POOL_BUFFER;

View File

@@ -4,7 +4,10 @@ import { entropyToMnemonic } from "ethereum-cryptography/bip39";
import { wordlist } from "ethereum-cryptography/bip39/wordlists/english"; import { wordlist } from "ethereum-cryptography/bip39/wordlists/english";
import { HDNode } from "@ethersproject/hdnode"; import { HDNode } from "@ethersproject/hdnode";
import { BACKGROUND_JWT_EXPIRY_SECONDS } from "@/constants/backgroundJwt"; import {
BACKGROUND_JWT_EXPIRY_SECONDS,
BACKGROUND_JWT_POOL_SIZE,
} from "@/constants/backgroundJwt";
import { import {
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI, CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
createEndorserJwtForDid, createEndorserJwtForDid,
@@ -122,6 +125,28 @@ export const accessTokenForBackgroundNotifications = async (
return createEndorserJwtForDid(did, tokenPayload); return createEndorserJwtForDid(did, tokenPayload);
}; };
/**
* Mint {@link BACKGROUND_JWT_POOL_SIZE} distinct JWTs for native background prefetch
* (`configureNativeFetcher` `jwtTokens`). Unique `jti` per slot; same `exp` for all.
*/
export async function mintBackgroundJwtTokenPool(
did: string,
): Promise<string[]> {
const nowEpoch = Math.floor(Date.now() / 1000);
const endEpoch = nowEpoch + BACKGROUND_JWT_EXPIRY_SECONDS;
const tokens: string[] = [];
for (let i = 0; i < BACKGROUND_JWT_POOL_SIZE; i++) {
const tokenPayload = {
exp: endEpoch,
iat: nowEpoch,
iss: did,
jti: `${did}#bg#${i}`,
};
tokens.push(await createEndorserJwtForDid(did, tokenPayload));
}
return tokens;
}
/** /**
* Extract JWT from various URL formats * Extract JWT from various URL formats
* @param jwtUrlText The URL containing the JWT * @param jwtUrlText The URL containing the JWT

View File

@@ -8,7 +8,7 @@
import { Capacitor } from "@capacitor/core"; import { Capacitor } from "@capacitor/core";
import { DailyNotification } from "@/plugins/DailyNotificationPlugin"; import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
import { accessTokenForBackgroundNotifications } from "@/libs/crypto"; import { mintBackgroundJwtTokenPool } from "@/libs/crypto";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { logger } from "@/utils/logger"; import { logger } from "@/utils/logger";
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app"; import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
@@ -63,7 +63,8 @@ export async function configureNativeFetcherIfReady(
: DEFAULT_ENDORSER_API_SERVER; : DEFAULT_ENDORSER_API_SERVER;
} }
const jwtToken = await accessTokenForBackgroundNotifications(did); const jwtTokens = await mintBackgroundJwtTokenPool(did);
const jwtToken = jwtTokens[0] ?? "";
if (!jwtToken) { if (!jwtToken) {
logger.warn( logger.warn(
"[nativeFetcherConfig] No JWT for native fetcher; API-driven notifications may fail", "[nativeFetcherConfig] No JWT for native fetcher; API-driven notifications may fail",
@@ -82,9 +83,12 @@ export async function configureNativeFetcherIfReady(
apiBaseUrl?.trim().replace(/\/$/, "") ?? DEFAULT_ENDORSER_API_SERVER, apiBaseUrl?.trim().replace(/\/$/, "") ?? DEFAULT_ENDORSER_API_SERVER,
activeDid: did, activeDid: did,
jwtToken, jwtToken,
jwtTokens,
}); });
logger.info( logger.info(
"[nativeFetcherConfig] Native fetcher configured for API-driven notifications", "[nativeFetcherConfig] Native fetcher configured (JWT pool size=" +
jwtTokens.length +
")",
); );
return true; return true;
} catch (error) { } catch (error) {