Migrate to Kotlin - BundleDownloader (#50557)

Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/50557

This diff migrates the following file to Kotlin - BundleDownloader
as part of our ongoing effort of migrating the codebase to Kotlin
Changelog:
[Internal] [Changed] - BundleDownloader to Kotlin

Reviewed By: javache

Differential Revision: D72627995

fbshipit-source-id: 9d65473c4d4a0962f78b0f71fc24530415a0e908
This commit is contained in:
Nicola Corti
2025-04-08 16:37:46 -07:00
committed by Facebook GitHub Bot
parent 655a86c348
commit f866a8d800
3 changed files with 341 additions and 340 deletions
@@ -1947,18 +1947,28 @@ public final class com/facebook/react/devsupport/BridgeDevSupportManager : com/f
public fun loadSplitBundleFromServer (Ljava/lang/String;Lcom/facebook/react/devsupport/interfaces/DevSplitBundleCallback;)V
}
public class com/facebook/react/devsupport/BundleDownloader {
public final class com/facebook/react/devsupport/BundleDownloader {
public static final field Companion Lcom/facebook/react/devsupport/BundleDownloader$Companion;
public fun <init> (Lokhttp3/OkHttpClient;)V
public fun downloadBundleFromURL (Lcom/facebook/react/devsupport/interfaces/DevBundleDownloadListener;Ljava/io/File;Ljava/lang/String;Lcom/facebook/react/devsupport/BundleDownloader$BundleInfo;)V
public fun downloadBundleFromURL (Lcom/facebook/react/devsupport/interfaces/DevBundleDownloadListener;Ljava/io/File;Ljava/lang/String;Lcom/facebook/react/devsupport/BundleDownloader$BundleInfo;Lokhttp3/Request$Builder;)V
public final fun downloadBundleFromURL (Lcom/facebook/react/devsupport/interfaces/DevBundleDownloadListener;Ljava/io/File;Ljava/lang/String;Lcom/facebook/react/devsupport/BundleDownloader$BundleInfo;)V
public final fun downloadBundleFromURL (Lcom/facebook/react/devsupport/interfaces/DevBundleDownloadListener;Ljava/io/File;Ljava/lang/String;Lcom/facebook/react/devsupport/BundleDownloader$BundleInfo;Lokhttp3/Request$Builder;)V
public static synthetic fun downloadBundleFromURL$default (Lcom/facebook/react/devsupport/BundleDownloader;Lcom/facebook/react/devsupport/interfaces/DevBundleDownloadListener;Ljava/io/File;Ljava/lang/String;Lcom/facebook/react/devsupport/BundleDownloader$BundleInfo;Lokhttp3/Request$Builder;ILjava/lang/Object;)V
}
public class com/facebook/react/devsupport/BundleDownloader$BundleInfo {
public final class com/facebook/react/devsupport/BundleDownloader$BundleInfo {
public static final field Companion Lcom/facebook/react/devsupport/BundleDownloader$BundleInfo$Companion;
public fun <init> ()V
public static fun fromJSONString (Ljava/lang/String;)Lcom/facebook/react/devsupport/BundleDownloader$BundleInfo;
public fun getFilesChangedCount ()I
public fun getUrl ()Ljava/lang/String;
public fun toJSONString ()Ljava/lang/String;
public static final fun fromJSONString (Ljava/lang/String;)Lcom/facebook/react/devsupport/BundleDownloader$BundleInfo;
public final fun getFilesChangedCount ()I
public final fun getUrl ()Ljava/lang/String;
public final fun toJSONString ()Ljava/lang/String;
}
public final class com/facebook/react/devsupport/BundleDownloader$BundleInfo$Companion {
public final fun fromJSONString (Ljava/lang/String;)Lcom/facebook/react/devsupport/BundleDownloader$BundleInfo;
}
public final class com/facebook/react/devsupport/BundleDownloader$Companion {
}
public final class com/facebook/react/devsupport/DefaultDevLoadingViewImplementation : com/facebook/react/devsupport/interfaces/DevLoadingViewManager {
@@ -1,332 +0,0 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
package com.facebook.react.devsupport;
import androidx.annotation.Nullable;
import com.facebook.common.logging.FLog;
import com.facebook.infer.annotation.Assertions;
import com.facebook.infer.annotation.Nullsafe;
import com.facebook.react.common.DebugServerException;
import com.facebook.react.common.ReactConstants;
import com.facebook.react.devsupport.interfaces.DevBundleDownloadListener;
import java.io.File;
import java.io.IOException;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.Headers;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
import okio.Buffer;
import okio.BufferedSource;
import okio.Okio;
import okio.Sink;
import org.json.JSONException;
import org.json.JSONObject;
@Nullsafe(Nullsafe.Mode.LOCAL)
public class BundleDownloader {
private static final String TAG = "BundleDownloader";
// Should be kept in sync with constants in RCTJavaScriptLoader.h
private static final int FILES_CHANGED_COUNT_NOT_BUILT_BY_BUNDLER = -2;
private final OkHttpClient mClient;
private @Nullable Call mDownloadBundleFromURLCall;
public static class BundleInfo {
private @Nullable String mUrl;
private int mFilesChangedCount;
public static @Nullable BundleInfo fromJSONString(String jsonStr) {
if (jsonStr == null) {
return null;
}
BundleInfo info = new BundleInfo();
try {
JSONObject obj = new JSONObject(jsonStr);
info.mUrl = obj.getString("url");
info.mFilesChangedCount = obj.getInt("filesChangedCount");
} catch (JSONException e) {
FLog.e(TAG, "Invalid bundle info: ", e);
return null;
}
return info;
}
public @Nullable String toJSONString() {
JSONObject obj = new JSONObject();
try {
obj.put("url", mUrl);
obj.put("filesChangedCount", mFilesChangedCount);
} catch (JSONException e) {
FLog.e(TAG, "Can't serialize bundle info: ", e);
return null;
}
return obj.toString();
}
public String getUrl() {
return mUrl != null ? mUrl : "unknown";
}
public int getFilesChangedCount() {
return mFilesChangedCount;
}
}
public BundleDownloader(OkHttpClient client) {
mClient = client;
}
public void downloadBundleFromURL(
final DevBundleDownloadListener callback,
final File outputFile,
final String bundleURL,
final @Nullable BundleInfo bundleInfo) {
downloadBundleFromURL(callback, outputFile, bundleURL, bundleInfo, new Request.Builder());
}
public void downloadBundleFromURL(
final DevBundleDownloadListener callback,
final File outputFile,
final String bundleURL,
final @Nullable BundleInfo bundleInfo,
Request.Builder requestBuilder) {
final Request request =
requestBuilder.url(bundleURL).addHeader("Accept", "multipart/mixed").build();
mDownloadBundleFromURLCall = Assertions.assertNotNull(mClient.newCall(request));
mDownloadBundleFromURLCall.enqueue(
new Callback() {
@Override
public void onFailure(Call call, IOException e) {
// ignore callback if call was cancelled
if (mDownloadBundleFromURLCall == null || mDownloadBundleFromURLCall.isCanceled()) {
mDownloadBundleFromURLCall = null;
return;
}
mDownloadBundleFromURLCall = null;
String url = call.request().url().toString();
callback.onFailure(
DebugServerException.makeGeneric(
url, "Could not connect to development server.", "URL: " + url, e));
}
@Override
public void onResponse(Call call, final Response response) throws IOException {
try (Response r = response) {
// ignore callback if call was cancelled
if (mDownloadBundleFromURLCall == null || mDownloadBundleFromURLCall.isCanceled()) {
mDownloadBundleFromURLCall = null;
return;
}
mDownloadBundleFromURLCall = null;
final String url = response.request().url().toString();
// Make sure the result is a multipart response and parse the boundary.
String contentType = response.header("content-type");
if (contentType == null) {
// fallback to empty string for nullability
contentType = "";
}
Pattern regex = Pattern.compile("multipart/mixed;.*boundary=\"([^\"]+)\"");
Matcher match = regex.matcher(contentType);
if (!contentType.isEmpty() && match.find()) {
String boundary = Assertions.assertNotNull(match.group(1));
processMultipartResponse(url, r, boundary, outputFile, bundleInfo, callback);
} else {
// In case the server doesn't support multipart/mixed responses, fallback to normal
// download.
try (ResponseBody body = r.body()) {
if (body != null) {
processBundleResult(
url,
r.code(),
r.headers(),
body.source(),
outputFile,
bundleInfo,
callback);
}
}
}
}
}
});
}
private void processMultipartResponse(
final String url,
final Response response,
String boundary,
final File outputFile,
@Nullable final BundleInfo bundleInfo,
final DevBundleDownloadListener callback)
throws IOException {
if (response.body() == null) {
callback.onFailure(
new DebugServerException(
"Error while reading multipart response.\n\nResponse body was empty: "
+ response.code()
+ "\n\n"
+ "URL: "
+ url.toString()
+ "\n\n"));
return;
}
MultipartStreamReader bodyReader =
new MultipartStreamReader(response.body().source(), boundary);
boolean completed =
bodyReader.readAllParts(
new MultipartStreamReader.ChunkListener() {
@Override
public void onChunkComplete(
Map<String, String> headers, Buffer body, boolean isLastChunk)
throws IOException {
// This will get executed for every chunk of the multipart response. The last chunk
// (isLastChunk = true) will be the JS bundle, the other ones will be progress
// events
// encoded as JSON.
if (isLastChunk) {
// The http status code for each separate chunk is in the X-Http-Status header.
int status = response.code();
if (headers.containsKey("X-Http-Status")) {
status = Integer.parseInt(headers.get("X-Http-Status"));
}
processBundleResult(
url, status, Headers.of(headers), body, outputFile, bundleInfo, callback);
} else {
if (!headers.containsKey("Content-Type")
|| !headers.get("Content-Type").equals("application/json")) {
return;
}
try {
JSONObject progress = new JSONObject(body.readUtf8());
String status =
progress.has("status") ? progress.getString("status") : "Bundling";
Integer done = null;
if (progress.has("done")) {
done = progress.getInt("done");
}
Integer total = null;
if (progress.has("total")) {
total = progress.getInt("total");
}
callback.onProgress(status, done, total);
} catch (JSONException e) {
FLog.e(ReactConstants.TAG, "Error parsing progress JSON. " + e.toString());
}
}
}
@Override
public void onChunkProgress(Map<String, String> headers, long loaded, long total) {
if ("application/javascript".equals(headers.get("Content-Type"))) {
callback.onProgress("Downloading", (int) (loaded / 1024), (int) (total / 1024));
}
}
});
if (!completed) {
callback.onFailure(
new DebugServerException(
"Error while reading multipart response.\n\nResponse code: "
+ response.code()
+ "\n\n"
+ "URL: "
+ url.toString()
+ "\n\n"));
}
}
private void processBundleResult(
String url,
int statusCode,
Headers headers,
BufferedSource body,
File outputFile,
@Nullable BundleInfo bundleInfo,
DevBundleDownloadListener callback)
throws IOException {
// Check for server errors. If the server error has the expected form, fail with more info.
if (statusCode != 200) {
String bodyString = body.readUtf8();
DebugServerException debugServerException = DebugServerException.parse(url, bodyString);
if (debugServerException != null) {
callback.onFailure(debugServerException);
} else {
StringBuilder sb = new StringBuilder();
sb.append("The development server returned response error code: ")
.append(statusCode)
.append("\n\n")
.append("URL: ")
.append(url)
.append("\n\n")
.append("Body:\n")
.append(bodyString);
callback.onFailure(new DebugServerException(sb.toString()));
}
return;
}
if (bundleInfo != null) {
populateBundleInfo(url, headers, bundleInfo);
}
File tmpFile = new File(outputFile.getPath() + ".tmp");
if (storePlainJSInFile(body, tmpFile)) {
// If we have received a new bundle from the server, move it to its final destination.
if (!tmpFile.renameTo(outputFile)) {
throw new IOException("Couldn't rename " + tmpFile + " to " + outputFile);
}
}
callback.onSuccess();
}
private static boolean storePlainJSInFile(BufferedSource body, File outputFile)
throws IOException {
Sink output = null;
try {
output = Okio.sink(outputFile);
body.readAll(output);
} finally {
if (output != null) {
output.close();
}
}
return true;
}
private static void populateBundleInfo(String url, Headers headers, BundleInfo bundleInfo) {
bundleInfo.mUrl = url;
String filesChangedCountStr = headers.get("X-Metro-Files-Changed-Count");
if (filesChangedCountStr != null) {
try {
bundleInfo.mFilesChangedCount = Integer.parseInt(filesChangedCountStr);
} catch (NumberFormatException e) {
bundleInfo.mFilesChangedCount = FILES_CHANGED_COUNT_NOT_BUILT_BY_BUNDLER;
}
}
}
}
@@ -0,0 +1,323 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
@file:Suppress("DEPRECATION_ERROR") // Conflicting okhttp versions
package com.facebook.react.devsupport
import com.facebook.common.logging.FLog
import com.facebook.infer.annotation.Assertions
import com.facebook.react.common.DebugServerException
import com.facebook.react.common.DebugServerException.Companion.makeGeneric
import com.facebook.react.common.DebugServerException.Companion.parse
import com.facebook.react.common.ReactConstants
import com.facebook.react.devsupport.MultipartStreamReader.ChunkListener
import com.facebook.react.devsupport.interfaces.DevBundleDownloadListener
import java.io.File
import java.io.IOException
import java.util.regex.Pattern
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okio.Buffer
import okio.BufferedSource
import okio.Okio
import org.json.JSONException
import org.json.JSONObject
public class BundleDownloader public constructor(private val client: OkHttpClient) {
private var downloadBundleFromURLCall: Call? = null
public class BundleInfo public constructor() {
internal var _url: String? = null
public val url: String
get() = _url ?: "unknown"
public var filesChangedCount: Int = 0
internal set
public fun toJSONString(): String? =
try {
JSONObject()
.apply {
put("url", _url)
put("filesChangedCount", filesChangedCount)
}
.toString()
} catch (e: JSONException) {
FLog.e(TAG, "Can't serialize bundle info: ", e)
null
}
public companion object {
@JvmStatic
public fun fromJSONString(jsonStr: String?): BundleInfo? {
if (jsonStr == null) {
return null
}
return try {
val obj = JSONObject(jsonStr)
BundleInfo().apply {
_url = obj.getString("url")
filesChangedCount = obj.getInt("filesChangedCount")
}
} catch (e: JSONException) {
FLog.e(TAG, "Invalid bundle info: ", e)
null
}
}
}
}
@JvmOverloads
public fun downloadBundleFromURL(
callback: DevBundleDownloadListener,
outputFile: File,
bundleURL: String?,
bundleInfo: BundleInfo?,
requestBuilder: Request.Builder = Request.Builder()
) {
checkNotNull(bundleURL)
val request = requestBuilder.url(bundleURL).addHeader("Accept", "multipart/mixed").build()
downloadBundleFromURLCall = client.newCall(request)
checkNotNull(downloadBundleFromURLCall)
.enqueue(
object : Callback {
override fun onFailure(call: Call, e: IOException) {
// ignore callback if call was cancelled
if (downloadBundleFromURLCall == null ||
downloadBundleFromURLCall?.isCanceled() == true) {
downloadBundleFromURLCall = null
return
}
downloadBundleFromURLCall = null
val url = call.request().url().toString()
callback.onFailure(
makeGeneric(url, "Could not connect to development server.", "URL: $url", e))
}
@Throws(IOException::class)
override fun onResponse(call: Call, response: Response) {
response.use { resp ->
// ignore callback if call was cancelled
if (downloadBundleFromURLCall == null ||
downloadBundleFromURLCall?.isCanceled() == true) {
downloadBundleFromURLCall = null
return
}
downloadBundleFromURLCall = null
val url = resp.request().url().toString()
// Make sure the result is a multipart response and parse the boundary.
var contentType = resp.header("content-type")
if (contentType == null) {
// fallback to empty string for nullability
contentType = ""
}
val regex = Pattern.compile("multipart/mixed;.*boundary=\"([^\"]+)\"")
val match = regex.matcher(contentType)
if (contentType.isNotEmpty() && match.find()) {
val boundary = Assertions.assertNotNull(match.group(1))
processMultipartResponse(url, resp, boundary, outputFile, bundleInfo, callback)
} else {
// In case the server doesn't support multipart/mixed responses, fallback to
// normal
// download.
resp.body().use { body ->
if (body != null) {
processBundleResult(
url,
resp.code(),
resp.headers(),
body.source(),
outputFile,
bundleInfo,
callback)
}
}
}
}
}
})
}
@Throws(IOException::class)
private fun processMultipartResponse(
url: String,
response: Response,
boundary: String,
outputFile: File,
bundleInfo: BundleInfo?,
callback: DevBundleDownloadListener
) {
if (response.body() == null) {
callback.onFailure(
DebugServerException(
("""
Error while reading multipart response.
Response body was empty: ${response.code()}
URL: $url
"""
.trimIndent())))
return
}
val source = checkNotNull(response.body()?.source())
val bodyReader = MultipartStreamReader(source, boundary)
val completed =
bodyReader.readAllParts(
object : ChunkListener {
@Throws(IOException::class)
override fun onChunkComplete(
headers: Map<String, String>,
body: Buffer,
isLastChunk: Boolean
) {
// This will get executed for every chunk of the multipart response. The last chunk
// (isLastChunk = true) will be the JS bundle, the other ones will be progress
// events
// encoded as JSON.
if (isLastChunk) {
// The http status code for each separate chunk is in the X-Http-Status header.
var status = response.code()
if (headers.containsKey("X-Http-Status")) {
status = headers.getOrDefault("X-Http-Status", "0").toInt()
}
processBundleResult(
url, status, Headers.of(headers), body, outputFile, bundleInfo, callback)
} else {
if (!headers.containsKey("Content-Type") ||
headers["Content-Type"] != "application/json") {
return
}
try {
val progress = JSONObject(body.readUtf8())
val status =
if (progress.has("status")) progress.getString("status") else "Bundling"
var done: Int? = null
if (progress.has("done")) {
done = progress.getInt("done")
}
var total: Int? = null
if (progress.has("total")) {
total = progress.getInt("total")
}
callback.onProgress(status, done, total)
} catch (e: JSONException) {
FLog.e(ReactConstants.TAG, "Error parsing progress JSON. $e")
}
}
}
override fun onChunkProgress(
headers: Map<String, String>,
loaded: Long,
total: Long
) {
if ("application/javascript" == headers["Content-Type"]) {
callback.onProgress(
"Downloading", (loaded / 1024).toInt(), (total / 1024).toInt())
}
}
})
if (!completed) {
callback.onFailure(
DebugServerException(
("""
Error while reading multipart response.
Response code: ${response.code()}
URL: $url
"""
.trimIndent())))
}
}
@Throws(IOException::class)
private fun processBundleResult(
url: String,
statusCode: Int,
headers: Headers,
body: BufferedSource,
outputFile: File,
bundleInfo: BundleInfo?,
callback: DevBundleDownloadListener
) {
// Check for server errors. If the server error has the expected form, fail with more info.
if (statusCode != 200) {
val bodyString = body.readUtf8()
val debugServerException = parse(url, bodyString)
if (debugServerException != null) {
callback.onFailure(debugServerException)
} else {
val sb = StringBuilder()
sb.append("The development server returned response error code: ")
.append(statusCode)
.append("\n\n")
.append("URL: ")
.append(url)
.append("\n\n")
.append("Body:\n")
.append(bodyString)
callback.onFailure(DebugServerException(sb.toString()))
}
return
}
if (bundleInfo != null) {
populateBundleInfo(url, headers, bundleInfo)
}
val tmpFile = File(outputFile.path + ".tmp")
if (storePlainJSInFile(body, tmpFile)) {
// If we have received a new bundle from the server, move it to its final destination.
if (!tmpFile.renameTo(outputFile)) {
throw IOException("Couldn't rename $tmpFile to $outputFile")
}
}
callback.onSuccess()
}
public companion object {
private const val TAG = "BundleDownloader"
// Should be kept in sync with constants in RCTJavaScriptLoader.h
private const val FILES_CHANGED_COUNT_NOT_BUILT_BY_BUNDLER = -2
@Throws(IOException::class)
private fun storePlainJSInFile(body: BufferedSource, outputFile: File): Boolean {
Okio.sink(outputFile).use { it -> body.readAll(it) }
return true
}
private fun populateBundleInfo(url: String, headers: Headers, bundleInfo: BundleInfo) {
bundleInfo._url = url
val filesChangedCountStr = headers["X-Metro-Files-Changed-Count"]
if (filesChangedCountStr != null) {
try {
bundleInfo.filesChangedCount = filesChangedCountStr.toInt()
} catch (e: NumberFormatException) {
bundleInfo.filesChangedCount = FILES_CHANGED_COUNT_NOT_BUILT_BY_BUNDLER
FLog.e(TAG, "Can't populate bundle info: ", e)
}
}
}
}
}