Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -40,7 +50,7 @@ object AutoPrefetcher {

val tokenHeaders: Map<String, String> = 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) }
Expand All @@ -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)
Expand All @@ -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())
}
Expand Down Expand Up @@ -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() }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<NitroHeader> =
info.allHeadersAsList.map { NitroHeader(it.key, it.value) }.toTypedArray()
val status = info.httpStatusCode
Expand Down Expand Up @@ -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)
Expand Down
16 changes: 15 additions & 1 deletion packages/react-native-nitro-fetch/ios/NitroAutoPrefetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,24 @@ 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() {
if initialized { return }
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 }

Expand All @@ -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),
Expand All @@ -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] = [:]
Expand All @@ -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 = [:]
}

Expand Down
1 change: 1 addition & 0 deletions packages/react-native-nitro-fetch/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export {
clearTokenRefresh,
callRefreshEndpoint,
getStoredTokenRefreshConfig,
getFetchTokenRefreshLastOutcome,
getNestedField,
applyTemplate,
} from './tokenRefresh';
Expand Down
19 changes: 19 additions & 0 deletions packages/react-native-nitro-fetch/src/tokenRefresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 */
}
}
}

Expand All @@ -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 '';
}
}
Loading