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 androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.gson.Gson;
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 READ_TIMEOUT_MS = 15000;
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;
// 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 activeDid;
private volatile String jwtToken;
/** Distinct JWTs from configureNativeFetcher `jwtTokens`; null = use jwtToken only. */
@Nullable
private List<String> jwtTokenPool;
public TimeSafariNativeFetcher(Context context) {
this.appContext = context.getApplicationContext();
@@ -64,11 +70,48 @@ public class TimeSafariNativeFetcher 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;
this.jwtTokenPool =
jwtTokenPool != null && !jwtTokenPool.isEmpty()
? new ArrayList<>(jwtTokenPool)
: null;
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
@@ -92,7 +135,8 @@ public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher
return CompletableFuture.supplyAsync(() -> {
try {
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.");
return Collections.emptyList();
}
@@ -104,7 +148,7 @@ public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher
connection.setReadTimeout(READ_TIMEOUT_MS);
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Authorization", "Bearer " + jwtToken);
connection.setRequestProperty("Authorization", "Bearer " + bearer);
connection.setDoOutput(true);
Map<String, Object> requestBody = new HashMap<>();
@@ -143,6 +187,16 @@ public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher
}
}
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);
if (!contents.isEmpty()) {
updateLastAckedJwtIdFromResponse(responseBody);

View File

@@ -1,9 +1,15 @@
/**
* 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.
* Confirm max `exp` with Endorser before raising.
* See doc/plan-background-jwt-pool-and-expiry.md. Confirm max `exp` with Endorser before raising.
*/
export const BACKGROUND_JWT_EXPIRY_DAYS = 90;
export const BACKGROUND_JWT_EXPIRY_SECONDS =
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 { 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 {
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
createEndorserJwtForDid,
@@ -122,6 +125,28 @@ export const accessTokenForBackgroundNotifications = async (
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
* @param jwtUrlText The URL containing the JWT

View File

@@ -8,7 +8,7 @@
import { Capacitor } from "@capacitor/core";
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
import { accessTokenForBackgroundNotifications } from "@/libs/crypto";
import { mintBackgroundJwtTokenPool } from "@/libs/crypto";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { logger } from "@/utils/logger";
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
@@ -63,7 +63,8 @@ export async function configureNativeFetcherIfReady(
: DEFAULT_ENDORSER_API_SERVER;
}
const jwtToken = await accessTokenForBackgroundNotifications(did);
const jwtTokens = await mintBackgroundJwtTokenPool(did);
const jwtToken = jwtTokens[0] ?? "";
if (!jwtToken) {
logger.warn(
"[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,
activeDid: did,
jwtToken,
jwtTokens,
});
logger.info(
"[nativeFetcherConfig] Native fetcher configured for API-driven notifications",
"[nativeFetcherConfig] Native fetcher configured (JWT pool size=" +
jwtTokens.length +
")",
);
return true;
} catch (error) {