From 04f649ad3e805f4b9f9141466caec8eec972a67e Mon Sep 17 00:00:00 2001 From: Ofer Morag Date: Sat, 11 Apr 2026 09:32:02 +0300 Subject: [PATCH] feat: expose cold-start fetch token-refresh outcome to JS - Android AutoPrefetcher: persist last outcome to SharedPreferences (plaintext key nitro_token_refresh_fetch_last_outcome) at each exit: success, failed_skip, failed_cache, none, not_run, error. - iOS NitroAutoPrefetcher: mirror outcome writes to UserDefaults. - JS: getFetchTokenRefreshLastOutcome(); clearTokenRefresh removes the outcome key. Depends on Android CookieManager sync PR for a clean AutoPrefetcher history. Made-with: Cursor --- .../nitro/nitrofetch/AutoPrefetcher.kt | 17 +++++++++++++++-- .../ios/NitroAutoPrefetcher.swift | 16 +++++++++++++++- .../react-native-nitro-fetch/src/index.tsx | 1 + .../src/tokenRefresh.ts | 19 +++++++++++++++++++ 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/AutoPrefetcher.kt b/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/AutoPrefetcher.kt index 072e403..f2ad21b 100644 --- a/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/AutoPrefetcher.kt +++ b/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/AutoPrefetcher.kt @@ -14,15 +14,24 @@ object AutoPrefetcher { private const val KEY_QUEUE = "nitrofetch_autoprefetch_queue" private const val KEY_TOKEN_REFRESH = "nitro_token_refresh_fetch" private const val KEY_TOKEN_CACHE = "nitro_token_refresh_fetch_cache" + /** Plaintext outcome for debug / JS — same key as `tokenRefresh.ts` */ + private const val KEY_LAST_FETCH_TOKEN_REFRESH_OUTCOME = "nitro_token_refresh_fetch_last_outcome" private const val PREFS_NAME = NitroFetchSecureAtRest.PREFS_NAME + private fun setFetchTokenRefreshOutcome(prefs: android.content.SharedPreferences, value: String) { + prefs.edit().putString(KEY_LAST_FETCH_TOKEN_REFRESH_OUTCOME, value).apply() + } + fun prefetchOnStart(app: Application) { if (initialized) return initialized = true try { val prefs = app.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val raw = prefs.getString(KEY_QUEUE, null) ?: "" - if (raw.isEmpty()) return + if (raw.isEmpty()) { + setFetchTokenRefreshOutcome(prefs, "not_run") + return + } val arr = JSONArray(raw) val refreshRaw = NitroFetchSecureAtRest.getDecryptedForPrefs(prefs, KEY_TOKEN_REFRESH) @@ -40,7 +49,7 @@ object AutoPrefetcher { val tokenHeaders: Map = if (refreshed != null) { android.util.Log.d("NitroFetch", "[TokenRefresh] ✅ Success — got ${refreshed.size} header(s)") - refreshed.forEach { (k, v) -> android.util.Log.d("NitroFetch", "[TokenRefresh] $k: $v") } + setFetchTokenRefreshOutcome(prefs, "success") // Cache fresh token headers for useStoredHeaders fallback on next cold start val cacheJson = JSONObject() refreshed.forEach { (k, v) -> cacheJson.put(k, v) } @@ -50,6 +59,7 @@ object AutoPrefetcher { android.util.Log.d("NitroFetch", "[TokenRefresh] ❌ Refresh failed — onFailure: $onFailure") if (onFailure == "skip") { android.util.Log.d("NitroFetch", "[TokenRefresh] Skipping all prefetches") + setFetchTokenRefreshOutcome(prefs, "failed_skip") return@Thread } // Use last cached token headers (or empty map if none cached yet) @@ -63,16 +73,19 @@ object AutoPrefetcher { emptyMap() } android.util.Log.d("NitroFetch", "[TokenRefresh] Using cached headers (${cached.size} header(s))") + setFetchTokenRefreshOutcome(prefs, "failed_cache") cached } android.util.Log.d("NitroFetch", "[TokenRefresh] Injecting token headers into ${arr.length()} prefetch URL(s)") startPrefetches(arr, tokenHeaders) } catch (_: Throwable) { + setFetchTokenRefreshOutcome(prefs, "error") // Best-effort — never crash the app } }.start() } else { + setFetchTokenRefreshOutcome(prefs, "none") // No token refresh config — proceed on current thread (Cronet is async) startPrefetches(arr, emptyMap()) } diff --git a/packages/react-native-nitro-fetch/ios/NitroAutoPrefetcher.swift b/packages/react-native-nitro-fetch/ios/NitroAutoPrefetcher.swift index aefa025..f6c9ee2 100644 --- a/packages/react-native-nitro-fetch/ios/NitroAutoPrefetcher.swift +++ b/packages/react-native-nitro-fetch/ios/NitroAutoPrefetcher.swift @@ -7,6 +7,13 @@ public final class NitroAutoPrefetcher: NSObject { private static let suiteName = "nitro_fetch_storage" private static let tokenRefreshKey = "nitro_token_refresh_fetch" private static let tokenCacheKey = "nitro_token_refresh_fetch_cache" + /// Plaintext outcome for debug / JS (`NativeStorage.getString`). Same key as `tokenRefresh.ts`. + private static let lastFetchTokenRefreshOutcomeKey = "nitro_token_refresh_fetch_last_outcome" + + private static func setFetchTokenRefreshOutcome(_ value: String, defaults: UserDefaults) { + defaults.set(value, forKey: lastFetchTokenRefreshOutcomeKey) + defaults.synchronize() + } @objc public static func prefetchOnStart() { @@ -14,7 +21,10 @@ public final class NitroAutoPrefetcher: NSObject { initialized = true let userDefaults = UserDefaults(suiteName: suiteName) ?? UserDefaults.standard - guard let raw = userDefaults.string(forKey: queueKey), !raw.isEmpty else { return } + guard let raw = userDefaults.string(forKey: queueKey), !raw.isEmpty else { + setFetchTokenRefreshOutcome("not_run", defaults: userDefaults) + return + } guard let data = raw.data(using: .utf8) else { return } guard let arr = try? JSONSerialization.jsonObject(with: data, options: []) as? [Any] else { return } @@ -33,6 +43,7 @@ public final class NitroAutoPrefetcher: NSObject { let refreshed = try? await callTokenRefresh(config: refreshObj) if let refreshed = refreshed { print("[NitroFetch][TokenRefresh] ✅ Success — got \(refreshed.count) header(s)") + setFetchTokenRefreshOutcome("success", defaults: userDefaults) for (k, v) in refreshed { print("[NitroFetch][TokenRefresh] \(k): \(v)") } // Cache fresh token headers for useStoredHeaders fallback on next cold start if let cacheData = try? JSONSerialization.data(withJSONObject: refreshed), @@ -44,6 +55,7 @@ public final class NitroAutoPrefetcher: NSObject { print("[NitroFetch][TokenRefresh] ❌ Refresh failed — onFailure: \(onFailure)") if onFailure == "skip" { print("[NitroFetch][TokenRefresh] Skipping all prefetches") + setFetchTokenRefreshOutcome("failed_skip", defaults: userDefaults) return } var cached: [String: String] = [:] @@ -54,9 +66,11 @@ public final class NitroAutoPrefetcher: NSObject { cached = cacheObj } print("[NitroFetch][TokenRefresh] Using cached headers (\(cached.count) header(s))") + setFetchTokenRefreshOutcome("failed_cache", defaults: userDefaults) tokenHeaders = cached } } else { + setFetchTokenRefreshOutcome("none", defaults: userDefaults) tokenHeaders = [:] } diff --git a/packages/react-native-nitro-fetch/src/index.tsx b/packages/react-native-nitro-fetch/src/index.tsx index 5f3d059..d931732 100644 --- a/packages/react-native-nitro-fetch/src/index.tsx +++ b/packages/react-native-nitro-fetch/src/index.tsx @@ -22,6 +22,7 @@ export { clearTokenRefresh, callRefreshEndpoint, getStoredTokenRefreshConfig, + getFetchTokenRefreshLastOutcome, getNestedField, applyTemplate, } from './tokenRefresh'; diff --git a/packages/react-native-nitro-fetch/src/tokenRefresh.ts b/packages/react-native-nitro-fetch/src/tokenRefresh.ts index bc63dc6..a55415d 100644 --- a/packages/react-native-nitro-fetch/src/tokenRefresh.ts +++ b/packages/react-native-nitro-fetch/src/tokenRefresh.ts @@ -5,6 +5,8 @@ const KEY_WS = 'nitro_token_refresh_websocket'; const KEY_FETCH = 'nitro_token_refresh_fetch'; const KEY_WS_CACHE = 'nitro_token_refresh_ws_cache'; const KEY_FETCH_CACHE = 'nitro_token_refresh_fetch_cache'; +/** Plaintext; written by native cold-start autoprefetch (`NitroAutoPrefetcher` / `AutoPrefetcher`). */ +const KEY_FETCH_LAST_OUTCOME = 'nitro_token_refresh_fetch_last_outcome'; type TokenRefreshTarget = 'websocket' | 'fetch' | 'all'; @@ -143,6 +145,11 @@ export function clearTokenRefresh(target?: TokenRefreshTarget): void { if (t === 'fetch' || t === 'all') { NativeStorageSingleton.removeSecureString(KEY_FETCH); NativeStorageSingleton.removeSecureString(KEY_FETCH_CACHE); + try { + NativeStorageSingleton.removeString(KEY_FETCH_LAST_OUTCOME); + } catch (_error) { + /* ignore */ + } } } @@ -158,3 +165,15 @@ export function getStoredTokenRefreshConfig( return null; } } + +/** + * Outcome of the last native cold-start fetch token refresh (before JS runs). + * Values: `success` | `failed_skip` | `failed_cache` | `none` | `not_run` | `error` | `''` if unset. + */ +export function getFetchTokenRefreshLastOutcome(): string { + try { + return NativeStorageSingleton.getString(KEY_FETCH_LAST_OUTCOME).trim(); + } catch (_error) { + return ''; + } +}