Skip to content
Open
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 @@ -33,23 +43,18 @@ object AutoPrefetcher {
try {
val refreshConfig = JSONObject(refreshRaw)
val onFailure = refreshConfig.optString("onFailure", "useStoredHeaders")
val refreshURL = refreshConfig.optString("url", "(unknown)")
android.util.Log.d("NitroFetch", "[TokenRefresh] Calling refresh endpoint: $refreshURL")

val refreshed = callTokenRefreshSync(refreshConfig)

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) }
NitroFetchSecureAtRest.putEncrypted(prefs, KEY_TOKEN_CACHE, cacheJson.toString())
refreshed
} else {
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 @@ -62,17 +67,18 @@ object AutoPrefetcher {
} else {
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 All @@ -96,8 +102,6 @@ object AutoPrefetcher {
tokenHeaders.forEach { (k, v) -> merged[k] = v }
merged["prefetchKey"] = prefetchKey

android.util.Log.d("NitroFetch", "[TokenRefresh] Prefetching $url with ${merged.size} header(s)")
merged.forEach { (k, v) -> android.util.Log.d("NitroFetch", "[TokenRefresh] $k: $v") }
val headerObjs = merged.map { (k, v) -> NitroHeader(k, v) }.toTypedArray()
val req = NitroRequest(
url = url,
Expand Down Expand Up @@ -151,16 +155,46 @@ 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) {
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
31 changes: 17 additions & 14 deletions 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 @@ -28,22 +38,18 @@ public final class NitroAutoPrefetcher: NSObject {
let refreshData = refreshRaw.data(using: .utf8),
let refreshObj = try? JSONSerialization.jsonObject(with: refreshData) as? [String: Any] {
let onFailure = refreshObj["onFailure"] as? String ?? "useStoredHeaders"
let refreshURL = refreshObj["url"] as? String ?? "(unknown)"
print("[NitroFetch][TokenRefresh] Calling refresh endpoint: \(refreshURL)")
let refreshed = try? await callTokenRefresh(config: refreshObj)
if let refreshed = refreshed {
print("[NitroFetch][TokenRefresh] ✅ Success — got \(refreshed.count) header(s)")
for (k, v) in refreshed { print("[NitroFetch][TokenRefresh] \(k): \(v)") }
setFetchTokenRefreshOutcome("success", defaults: userDefaults)
// Cache fresh token headers for useStoredHeaders fallback on next cold start
if let cacheData = try? JSONSerialization.data(withJSONObject: refreshed),
let cacheStr = String(data: cacheData, encoding: .utf8) {
try? NitroFetchSecureAtRest.setEncrypted(cacheStr, forKey: tokenCacheKey, defaults: userDefaults)
}
tokenHeaders = refreshed
} else {
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 @@ -53,18 +59,18 @@ public final class NitroAutoPrefetcher: NSObject {
let cacheObj = try? JSONSerialization.jsonObject(with: cacheData) as? [String: String] {
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 = [:]
}

// Launch a prefetch task per entry with merged headers
print("[NitroFetch][TokenRefresh] Injecting token headers into \(arr.count) prefetch URL(s)")
for item in arr {
guard let obj = item as? [String: Any] else { continue }
guard let url = obj["url"] as? String, !url.isEmpty else { continue }
guard let url = obj["url"] as? String, !url.isEmpty, URL(string: url) != nil else { continue }
guard let prefetchKey = obj["prefetchKey"] as? String, !prefetchKey.isEmpty else { continue }
let headersDict = (obj["headers"] as? [String: Any]) ?? [:]

Expand All @@ -76,17 +82,14 @@ public final class NitroAutoPrefetcher: NSObject {
var headers: [NitroHeader] = merged.map { NitroHeader(key: $0.key, value: $0.value) }
headers.append(NitroHeader(key: "prefetchKey", value: prefetchKey))

print("[NitroFetch][TokenRefresh] Prefetching \(url) with \(merged.count) header(s)")
for (k, v) in merged { print("[NitroFetch][TokenRefresh] \(k): \(v)") }

let req = NitroRequest(
url: url,
method: nil,
headers: headers,
bodyString: nil,
bodyBytes: nil,
bodyFormData: nil,
timeoutMs: nil,
timeoutMs: 15_000,
followRedirects: true,
requestId: nil
)
Expand Down
Loading