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 9eb6e10..34840c0 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 @@ -2,6 +2,7 @@ package com.margelo.nitro.nitrofetch import android.app.Application import android.content.Context +import android.webkit.CookieManager import org.json.JSONArray import org.json.JSONObject import java.net.HttpURLConnection @@ -14,15 +15,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 +50,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 +60,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 +74,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()) } @@ -151,16 +165,47 @@ object AutoPrefetcher { conn.doInput = true if (body != null) conn.doOutput = true + var hasCookieHeader = false reqHeaders?.keys()?.forEachRemaining { k -> + if (k.equals("Cookie", ignoreCase = true)) hasCookieHeader = true conn.setRequestProperty(k, reqHeaders.optString(k, "")) } + if (!hasCookieHeader) { + try { + val jar = CookieManager.getInstance() + val cookieHeader = jar.getCookie(urlStr) + if (!cookieHeader.isNullOrEmpty()) { + conn.setRequestProperty("Cookie", cookieHeader) + } + } catch (_: Throwable) { + // Best-effort — CookieManager may not be initialized yet + } + } + if (body != null) { conn.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } } val status = conn.responseCode - if (status !in 200..299) return null + if (status !in 200..299) { + android.util.Log.d("NitroFetch", "[TokenRefresh] Refresh endpoint returned HTTP $status") + return null + } + + try { + val cookieManager = CookieManager.getInstance() + conn.headerFields?.forEach { (key, values) -> + if (key?.equals("Set-Cookie", ignoreCase = true) == true) { + values.forEach { cookieValue -> + cookieManager.setCookie(urlStr, cookieValue) + } + } + } + cookieManager.flush() + } catch (_: Throwable) { + // Best-effort — CookieManager may not be initialized yet + } val responseBody = conn.inputStream.use { it.bufferedReader(Charsets.UTF_8).readText() } diff --git a/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetchClient.kt b/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetchClient.kt index a6fb46a..61becea 100644 --- a/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetchClient.kt +++ b/packages/react-native-nitro-fetch/android/src/main/java/com/margelo/nitro/nitrofetch/NitroFetchClient.kt @@ -3,6 +3,7 @@ package com.margelo.nitro.nitrofetch import android.net.Uri import android.os.Trace import android.util.Log +import android.webkit.CookieManager import com.facebook.proguard.annotations.DoNotStrip import com.margelo.nitro.NitroModules import com.margelo.nitro.core.ArrayBuffer @@ -48,6 +49,25 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E } companion object { + private fun hasCookieHeader(request: NitroRequest): Boolean { + return request.headers?.any { it.key.equals("Cookie", ignoreCase = true) } == true + } + + private fun storeResponseCookies(responseUrl: String, info: UrlResponseInfo) { + try { + val cookieManager = CookieManager.getInstance() + val setCookieHeaders = info.allHeadersAsList.filter { + it.key.equals("Set-Cookie", ignoreCase = true) + } + for (header in setCookieHeaders) { + cookieManager.setCookie(responseUrl, header.value) + } + cookieManager.flush() + } catch (exception: Exception) { + Log.w("NitroFetchClient", "Failed to store response cookies", exception) + } + } + @JvmStatic fun fetch( req: NitroRequest, @@ -87,6 +107,7 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newLocationUrl: String) { if (shouldFollowRedirects) { + storeResponseCookies(info.url, info) request.followRedirect() } else { // Return the redirect response as-is without following @@ -131,6 +152,7 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E Trace.endAsyncSection(traceLabel, traceCookie) } try { + storeResponseCookies(info.url, info) val headersArr: Array = info.allHeadersAsList.map { NitroHeader(it.key, it.value) }.toTypedArray() val status = info.httpStatusCode @@ -184,6 +206,18 @@ class NitroFetchClient(private val engine: CronetEngine, private val executor: E builder.setHttpMethod(method) req.headers?.forEach { (k, v) -> builder.addHeader(k, v) } + if (!hasCookieHeader(req)) { + try { + val cookieManager = CookieManager.getInstance() + val cookie = cookieManager.getCookie(url) + if (!cookie.isNullOrEmpty()) { + builder.addHeader("Cookie", cookie) + } + } catch (exception: Exception) { + Log.w("NitroFetchClient", "Failed to attach cookie header", exception) + } + } + val formParts = req.bodyFormData if (formParts != null && formParts.isNotEmpty()) { val (multipartBody, contentType) = buildMultipartBody(formParts) 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 ''; + } +}