From f866a8d800142b342b44cdbf28f538f9ba2dc699 Mon Sep 17 00:00:00 2001 From: Nicola Corti Date: Tue, 8 Apr 2025 16:37:46 -0700 Subject: [PATCH] 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 --- .../ReactAndroid/api/ReactAndroid.api | 26 +- .../react/devsupport/BundleDownloader.java | 332 ------------------ .../react/devsupport/BundleDownloader.kt | 323 +++++++++++++++++ 3 files changed, 341 insertions(+), 340 deletions(-) delete mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/BundleDownloader.java create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/BundleDownloader.kt diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 941f4a8ae3a..1139af3875f 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -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 (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 ()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 { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/BundleDownloader.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/BundleDownloader.java deleted file mode 100644 index 33110e0484c..00000000000 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/BundleDownloader.java +++ /dev/null @@ -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 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 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; - } - } - } -} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/BundleDownloader.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/BundleDownloader.kt new file mode 100644 index 00000000000..f006bea7d8b --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/BundleDownloader.kt @@ -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, + 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, + 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) + } + } + } + } +}