From dd0cd07405dbf3b348cc4f3d5861446383e84f63 Mon Sep 17 00:00:00 2001 From: Nicola Corti Date: Wed, 9 Apr 2025 03:39:19 -0700 Subject: [PATCH] Migrate to Kotlin - DevServerHelper (#50558) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/50558 This diff migrates the following file to Kotlin - DevServerHelper as part of our ongoing effort of migrating the codebase to Kotlin Changelog: [Internal] [Changed] - DevServerHelper to Kotlin. Reviewed By: javache Differential Revision: D72632266 fbshipit-source-id: 5dab7206dcf11de042d9e274e33a38a4491f1d17 --- .../ReactAndroid/api/ReactAndroid.api | 34 +- .../react/devsupport/DevServerHelper.java | 505 ------------------ .../react/devsupport/DevServerHelper.kt | 411 ++++++++++++++ 3 files changed, 428 insertions(+), 522 deletions(-) delete mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.kt diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 063f1375a46..4169e98ff69 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -1967,28 +1967,28 @@ public final class com/facebook/react/devsupport/DefaultDevLoadingViewImplementa public final fun setDevLoadingEnabled (Z)V } -public class com/facebook/react/devsupport/DevServerHelper { +public final class com/facebook/react/devsupport/DevServerHelper { public fun (Lcom/facebook/react/modules/debug/interfaces/DeveloperSettings;Landroid/content/Context;Lcom/facebook/react/packagerconnection/PackagerConnectionSettings;)V - public fun closeInspectorConnection ()V - public fun closePackagerConnection ()V - public fun disableDebugger ()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 fun downloadBundleResourceFromUrlSync (Ljava/lang/String;Ljava/io/File;)Ljava/io/File; - public fun getDevServerBundleURL (Ljava/lang/String;)Ljava/lang/String; - public fun getDevServerSplitBundleURL (Ljava/lang/String;)Ljava/lang/String; - public fun getSourceMapUrl (Ljava/lang/String;)Ljava/lang/String; - public fun getSourceUrl (Ljava/lang/String;)Ljava/lang/String; - public fun getWebsocketProxyURL ()Ljava/lang/String; - public fun isPackagerRunning (Lcom/facebook/react/devsupport/interfaces/PackagerStatusCallback;)V - public fun openDebugger (Lcom/facebook/react/bridge/ReactContext;Ljava/lang/String;)V - public fun openInspectorConnection ()V - public fun openPackagerConnection (Ljava/lang/String;Lcom/facebook/react/devsupport/DevServerHelper$PackagerCommandListener;)V + public final fun closeInspectorConnection ()V + public final fun closePackagerConnection ()V + public final fun disableDebugger ()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/DevServerHelper;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 final fun downloadBundleResourceFromUrlSync (Ljava/lang/String;Ljava/io/File;)Ljava/io/File; + public final fun getDevServerBundleURL (Ljava/lang/String;)Ljava/lang/String; + public final fun getDevServerSplitBundleURL (Ljava/lang/String;)Ljava/lang/String; + public final fun getSourceMapUrl (Ljava/lang/String;)Ljava/lang/String; + public final fun getSourceUrl (Ljava/lang/String;)Ljava/lang/String; + public final fun getWebsocketProxyURL ()Ljava/lang/String; + public final fun isPackagerRunning (Lcom/facebook/react/devsupport/interfaces/PackagerStatusCallback;)V + public final fun openDebugger (Lcom/facebook/react/bridge/ReactContext;Ljava/lang/String;)V + public final fun openInspectorConnection ()V + public final fun openPackagerConnection (Ljava/lang/String;Lcom/facebook/react/devsupport/DevServerHelper$PackagerCommandListener;)V } public abstract interface class com/facebook/react/devsupport/DevServerHelper$PackagerCommandListener { public abstract fun customCommandHandlers ()Ljava/util/Map; - public fun onCaptureHeapCommand (Lcom/facebook/react/packagerconnection/Responder;)V public abstract fun onPackagerConnected ()V public abstract fun onPackagerDevMenuCommand ()V public abstract fun onPackagerDisconnected ()V diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java deleted file mode 100644 index da0cac909f4..00000000000 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java +++ /dev/null @@ -1,505 +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 android.content.Context; -import android.net.Uri; -import android.os.AsyncTask; -import android.provider.Settings.Secure; -import androidx.annotation.NonNull; -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.bridge.ReactContext; -import com.facebook.react.common.ReactConstants; -import com.facebook.react.devsupport.interfaces.DevBundleDownloadListener; -import com.facebook.react.devsupport.interfaces.PackagerStatusCallback; -import com.facebook.react.modules.debug.interfaces.DeveloperSettings; -import com.facebook.react.modules.systeminfo.AndroidInfoHelpers; -import com.facebook.react.packagerconnection.FileIoHandler; -import com.facebook.react.packagerconnection.JSPackagerClient; -import com.facebook.react.packagerconnection.NotificationOnlyHandler; -import com.facebook.react.packagerconnection.PackagerConnectionSettings; -import com.facebook.react.packagerconnection.ReconnectingWebSocket.ConnectionCallback; -import com.facebook.react.packagerconnection.RequestHandler; -import com.facebook.react.packagerconnection.Responder; -import com.facebook.react.util.RNLog; -import java.io.File; -import java.io.IOException; -import java.io.UnsupportedEncodingException; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.HashMap; -import java.util.Locale; -import java.util.Map; -import java.util.concurrent.TimeUnit; -import okhttp3.Call; -import okhttp3.Callback; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; -import okio.Okio; -import okio.Sink; - -/** - * Helper class for all things about the debug server running in the engineer's host machine. - * - *

One can use 'debug_http_host' shared preferences key to provide a host name for the debug - * server. If the setting is empty we support and detect two basic configuration that works well for - * android emulators connection to debug server running on emulator's host: - * - *

- */ -@Nullsafe(Nullsafe.Mode.LOCAL) -public class DevServerHelper { - private static final int HTTP_CONNECT_TIMEOUT_MS = 5000; - - private static final String DEBUGGER_MSG_DISABLE = "{ \"id\":1,\"method\":\"Debugger.disable\" }"; - - public interface PackagerCommandListener { - void onPackagerConnected(); - - void onPackagerDisconnected(); - - void onPackagerReloadCommand(); - - void onPackagerDevMenuCommand(); - - @Deprecated(forRemoval = true) - default void onCaptureHeapCommand(final Responder responder) {} - - // Allow apps to provide listeners for custom packager commands. - @Nullable - Map customCommandHandlers(); - } - - private enum BundleType { - BUNDLE("bundle"), - MAP("map"); - - private final String mTypeID; - - BundleType(String typeID) { - mTypeID = typeID; - } - - public String typeID() { - return mTypeID; - } - } - - private final DeveloperSettings mSettings; - - private final PackagerConnectionSettings mPackagerConnectionSettings; - - private final OkHttpClient mClient; - private final BundleDownloader mBundleDownloader; - private final PackagerStatusCheck mPackagerStatusCheck; - private final Context mApplicationContext; - private final String mPackageName; - - private @Nullable JSPackagerClient mPackagerClient; - private @Nullable IInspectorPackagerConnection mInspectorPackagerConnection; - - public DevServerHelper( - DeveloperSettings developerSettings, - Context applicationContext, - PackagerConnectionSettings packagerConnectionSettings) { - mSettings = developerSettings; - mPackagerConnectionSettings = packagerConnectionSettings; - mClient = - new OkHttpClient.Builder() - .connectTimeout(HTTP_CONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS) - .readTimeout(0, TimeUnit.MILLISECONDS) - .writeTimeout(0, TimeUnit.MILLISECONDS) - .build(); - mBundleDownloader = new BundleDownloader(mClient); - mPackagerStatusCheck = new PackagerStatusCheck(mClient); - mApplicationContext = applicationContext; - mPackageName = applicationContext.getPackageName(); - } - - public void openPackagerConnection( - final String clientId, final PackagerCommandListener commandListener) { - if (mPackagerClient != null) { - FLog.w(ReactConstants.TAG, "Packager connection already open, nooping."); - return; - } - new AsyncTask() { - @Override - protected Void doInBackground(Void... backgroundParams) { - Map handlers = new HashMap<>(); - handlers.put( - "reload", - new NotificationOnlyHandler() { - @Override - public void onNotification(@Nullable Object params) { - commandListener.onPackagerReloadCommand(); - } - }); - handlers.put( - "devMenu", - new NotificationOnlyHandler() { - @Override - public void onNotification(@Nullable Object params) { - commandListener.onPackagerDevMenuCommand(); - } - }); - Map customHandlers = commandListener.customCommandHandlers(); - if (customHandlers != null) { - handlers.putAll(customHandlers); - } - handlers.putAll(new FileIoHandler().handlers()); - - ConnectionCallback onPackagerConnectedCallback = - new ConnectionCallback() { - @Override - public void onConnected() { - commandListener.onPackagerConnected(); - } - - @Override - public void onDisconnected() { - commandListener.onPackagerDisconnected(); - } - }; - - mPackagerClient = - new JSPackagerClient( - clientId, mPackagerConnectionSettings, handlers, onPackagerConnectedCallback); - mPackagerClient.init(); - - return null; - } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - - public void closePackagerConnection() { - new AsyncTask() { - @Override - protected Void doInBackground(Void... params) { - if (mPackagerClient != null) { - mPackagerClient.close(); - mPackagerClient = null; - } - return null; - } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - - public void openInspectorConnection() { - if (mInspectorPackagerConnection != null) { - FLog.w(ReactConstants.TAG, "Inspector connection already open, nooping."); - return; - } - new AsyncTask() { - @Override - protected Void doInBackground(Void... params) { - Map metadata = - AndroidInfoHelpers.getInspectorHostMetadata(mApplicationContext); - String deviceName = metadata.get("deviceName"); - if (deviceName == null) { - FLog.w(ReactConstants.TAG, "Could not get device name from Inspector Host Metadata."); - return null; - } - mInspectorPackagerConnection = - new CxxInspectorPackagerConnection(getInspectorDeviceUrl(), deviceName, mPackageName); - mInspectorPackagerConnection.connect(); - return null; - } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - - public void disableDebugger() { - if (mInspectorPackagerConnection != null) { - mInspectorPackagerConnection.sendEventToAllConnections(DEBUGGER_MSG_DISABLE); - } - } - - public void closeInspectorConnection() { - new AsyncTask() { - @Override - protected Void doInBackground(Void... params) { - if (mInspectorPackagerConnection != null) { - mInspectorPackagerConnection.closeQuietly(); - mInspectorPackagerConnection = null; - } - return null; - } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - - public String getWebsocketProxyURL() { - return String.format( - Locale.US, - "ws://%s/debugger-proxy?role=client", - mPackagerConnectionSettings.getDebugServerHost()); - } - - private static String getSHA256(String string) { - MessageDigest digest = null; - try { - digest = MessageDigest.getInstance("SHA-256"); - } catch (NoSuchAlgorithmException e) { - throw new AssertionError("Could not get standard SHA-256 algorithm", e); - } - digest.reset(); - byte[] result; - try { - result = digest.digest(string.getBytes("UTF-8")); - } catch (UnsupportedEncodingException e) { - throw new AssertionError("This environment doesn't support UTF-8 encoding", e); - } - return String.format( - "%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x", - result[0], - result[1], - result[2], - result[3], - result[4], - result[5], - result[6], - result[7], - result[8], - result[9], - result[10], - result[11], - result[12], - result[13], - result[14], - result[15], - result[16], - result[17], - result[18], - result[19]); - } - - // Returns an opaque ID which is stable for the current combination of device and app, stable - // across installs, and unique across devices. - private String getInspectorDeviceId() { - // Every Android app has a unique application ID that looks like a Java or Kotlin package name, - // such as com.example.myapp. This ID uniquely identifies your app on the device and in the - // Google Play Store. - // [Source: Android docs] - String packageName = mPackageName; - - // A 64-bit number expressed as a hexadecimal string, which is either: - // * unique to each combination of app-signing key, user, and device (API level >= 26), or - // * randomly generated when the user first sets up the device and should remain constant for - // the lifetime of the user's device (API level < 26). - // [Source: Android docs] - String androidId = - Secure.getString(mApplicationContext.getContentResolver(), Secure.ANDROID_ID); - - String rawDeviceId = - String.format( - Locale.US, - "android-%s-%s-%s", - packageName, - androidId, - InspectorFlags.getFuseboxEnabled() ? "fusebox" : "legacy"); - - return getSHA256(rawDeviceId); - } - - private String getInspectorDeviceUrl() { - return String.format( - Locale.US, - "http://%s/inspector/device?name=%s&app=%s&device=%s&profiling=%b", - mPackagerConnectionSettings.getDebugServerHost(), - Uri.encode(AndroidInfoHelpers.getFriendlyDeviceName()), - Uri.encode(mPackageName), - Uri.encode(getInspectorDeviceId()), - InspectorFlags.getIsProfilingBuild()); - } - - public void downloadBundleFromURL( - DevBundleDownloadListener callback, - File outputFile, - String bundleURL, - @Nullable BundleDownloader.BundleInfo bundleInfo) { - mBundleDownloader.downloadBundleFromURL(callback, outputFile, bundleURL, bundleInfo); - } - - public void downloadBundleFromURL( - DevBundleDownloadListener callback, - File outputFile, - String bundleURL, - @Nullable BundleDownloader.BundleInfo bundleInfo, - Request.Builder requestBuilder) { - mBundleDownloader.downloadBundleFromURL( - callback, outputFile, bundleURL, bundleInfo, requestBuilder); - } - - /** - * @return the host to use when connecting to the bundle server from the host itself. - */ - private String getHostForJSProxy() { - // Use custom port if configured. Note that host stays "localhost". - String host = Assertions.assertNotNull(mPackagerConnectionSettings.getDebugServerHost()); - int portOffset = host.lastIndexOf(':'); - if (portOffset > -1) { - return "localhost" + host.substring(portOffset); - } else { - return AndroidInfoHelpers.DEVICE_LOCALHOST; - } - } - - /** - * @return whether we should enable dev mode when requesting JS bundles. - */ - private boolean getDevMode() { - return mSettings.isJSDevModeEnabled(); - } - - /** - * @return whether we should request minified JS bundles. - */ - private boolean getJSMinifyMode() { - return mSettings.isJSMinifyEnabled(); - } - - private String createBundleURL(String mainModuleID, BundleType type, String host) { - return createBundleURL(mainModuleID, type, host, false, true); - } - - private String createSplitBundleURL(String mainModuleID, String host) { - return createBundleURL(mainModuleID, BundleType.BUNDLE, host, true, false); - } - - private String createBundleURL( - String mainModuleID, BundleType type, String host, boolean modulesOnly, boolean runModule) { - boolean dev = getDevMode(); - StringBuilder additionalOptionsBuilder = new StringBuilder(); - for (Map.Entry entry : - mPackagerConnectionSettings.getAdditionalOptionsForPackager().entrySet()) { - if (entry.getValue().length() == 0) { - continue; - } - additionalOptionsBuilder.append("&" + entry.getKey() + "=" + Uri.encode(entry.getValue())); - } - return String.format( - Locale.US, - "http://%s/%s.%s?platform=android&dev=%s&lazy=%s&minify=%s&app=%s&modulesOnly=%s&runModule=%s", - host, - mainModuleID, - type.typeID(), - dev, // dev - dev, // lazy - getJSMinifyMode(), - mPackageName, - modulesOnly ? "true" : "false", - runModule ? "true" : "false") - + (InspectorFlags.getFuseboxEnabled() ? "&excludeSource=true&sourcePaths=url-server" : "") - + additionalOptionsBuilder.toString(); - } - - private String createBundleURL(String mainModuleID, BundleType type) { - return createBundleURL(mainModuleID, type, mPackagerConnectionSettings.getDebugServerHost()); - } - - private static String createResourceURL(String host, String resourcePath) { - // This is what we get for not using a proper URI library. - if (resourcePath.startsWith("/")) { - FLog.w(ReactConstants.TAG, "Resource path should not begin with `/`, removing it."); - resourcePath = resourcePath.substring(1); - } - return String.format(Locale.US, "http://%s/%s", host, resourcePath); - } - - public String getDevServerBundleURL(final String jsModulePath) { - return createBundleURL( - jsModulePath, BundleType.BUNDLE, mPackagerConnectionSettings.getDebugServerHost()); - } - - public String getDevServerSplitBundleURL(String jsModulePath) { - return createSplitBundleURL(jsModulePath, mPackagerConnectionSettings.getDebugServerHost()); - } - - public void isPackagerRunning(final PackagerStatusCallback callback) { - String host = mPackagerConnectionSettings.getDebugServerHost(); - if (host == null) { - FLog.w(ReactConstants.TAG, "No packager host configured."); - callback.onPackagerStatusFetched(false); - } else { - mPackagerStatusCheck.run(host, callback); - } - } - - public String getSourceMapUrl(String mainModuleName) { - return createBundleURL(mainModuleName, BundleType.MAP); - } - - public String getSourceUrl(String mainModuleName) { - return createBundleURL(mainModuleName, BundleType.BUNDLE); - } - - /** - * This is a debug-only utility to allow fetching a file via packager. It's made synchronous for - * simplicity, but should only be used if it's absolutely necessary. - * - * @return the file with the fetched content, or null if there's any failure. - */ - public @Nullable File downloadBundleResourceFromUrlSync( - final String resourcePath, final File outputFile) { - final String resourceURL = - createResourceURL(mPackagerConnectionSettings.getDebugServerHost(), resourcePath); - final Request request = new Request.Builder().url(resourceURL).build(); - - try (Response response = mClient.newCall(request).execute()) { - if (!response.isSuccessful() || response.body() == null) { - return null; - } - - try (Sink output = Okio.sink(outputFile)) { - Okio.buffer(response.body().source()).readAll(output); - } - - return outputFile; - } catch (Exception ex) { - FLog.e( - ReactConstants.TAG, - "Failed to fetch resource synchronously - resourcePath: \"%s\", outputFile: \"%s\"", - resourcePath, - outputFile.getAbsolutePath(), - ex); - return null; - } - } - - /** Attempt to open the JS debugger on the host machine (on-device CDP debugging). */ - public void openDebugger(@Nullable final ReactContext context, final String errorMessage) { - // TODO(huntie): Requests to dev server should not assume 'http' URL scheme - String requestUrl = - String.format( - Locale.US, - "http://%s/open-debugger?device=%s", - mPackagerConnectionSettings.getDebugServerHost(), - Uri.encode(getInspectorDeviceId())); - Request request = - new Request.Builder().url(requestUrl).method("POST", RequestBody.create(null, "")).build(); - - mClient - .newCall(request) - .enqueue( - new Callback() { - @Override - public void onFailure(@NonNull Call _call, @NonNull IOException _e) { - RNLog.w(context, errorMessage); - } - - @Override - public void onResponse(@NonNull Call _call, @NonNull Response _response) {} - }); - } -} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.kt new file mode 100644 index 00000000000..29a6441d59c --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.kt @@ -0,0 +1,411 @@ +/* + * 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. + */ + +// Conflicting okhttp versions + This class needs to be rewritten to don't +// use AsyncTasks +@file:Suppress("DEPRECATION", "DEPRECATION_ERROR") + +package com.facebook.react.devsupport + +import android.annotation.SuppressLint +import android.content.Context +import android.net.Uri +import android.os.AsyncTask +import android.provider.Settings.Secure +import com.facebook.common.logging.FLog +import com.facebook.react.bridge.ReactContext +import com.facebook.react.common.ReactConstants +import com.facebook.react.devsupport.InspectorFlags.getFuseboxEnabled +import com.facebook.react.devsupport.InspectorFlags.getIsProfilingBuild +import com.facebook.react.devsupport.interfaces.DevBundleDownloadListener +import com.facebook.react.devsupport.interfaces.PackagerStatusCallback +import com.facebook.react.modules.debug.interfaces.DeveloperSettings +import com.facebook.react.modules.systeminfo.AndroidInfoHelpers.getFriendlyDeviceName +import com.facebook.react.modules.systeminfo.AndroidInfoHelpers.getInspectorHostMetadata +import com.facebook.react.packagerconnection.FileIoHandler +import com.facebook.react.packagerconnection.JSPackagerClient +import com.facebook.react.packagerconnection.NotificationOnlyHandler +import com.facebook.react.packagerconnection.PackagerConnectionSettings +import com.facebook.react.packagerconnection.ReconnectingWebSocket +import com.facebook.react.packagerconnection.RequestHandler +import com.facebook.react.util.RNLog +import java.io.File +import java.io.IOException +import java.io.UnsupportedEncodingException +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import java.util.Locale +import java.util.concurrent.TimeUnit +import okhttp3.Call +import okhttp3.Callback +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.Response +import okio.Okio + +/** + * Helper class for all things about the debug server running in the engineer's host machine. + * + * One can use 'debug_http_host' shared preferences key to provide a host name for the debug server. + * If the setting is empty we support and detect two basic configuration that works well for android + * emulators connection to debug server running on emulator's host: + * - Android stock emulator with standard non-configurable local loopback alias: 10.0.2.2 + * - Genymotion emulator with default settings: 10.0.3.2 + */ +@SuppressLint( + "StaticFieldLeak") // TODO: This entire class should be rewritten to don't use AsyncTask +public class DevServerHelper( + private val settings: DeveloperSettings, + private val applicationContext: Context, + private val packagerConnectionSettings: PackagerConnectionSettings +) { + public interface PackagerCommandListener { + public fun onPackagerConnected() + + public fun onPackagerDisconnected() + + public fun onPackagerReloadCommand() + + public fun onPackagerDevMenuCommand() + + // Allow apps to provide listeners for custom packager commands. + public fun customCommandHandlers(): Map? + } + + public val websocketProxyURL: String + get() = "ws://${packagerConnectionSettings.debugServerHost}/debugger-proxy?role=client" + + private enum class BundleType(val typeID: String) { + BUNDLE("bundle"), + MAP("map") + } + + private val client: OkHttpClient = + OkHttpClient.Builder() + .connectTimeout(HTTP_CONNECT_TIMEOUT_MS.toLong(), TimeUnit.MILLISECONDS) + .readTimeout(0, TimeUnit.MILLISECONDS) + .writeTimeout(0, TimeUnit.MILLISECONDS) + .build() + private val bundleDownloader: BundleDownloader = BundleDownloader(client) + private val packagerStatusCheck: PackagerStatusCheck = PackagerStatusCheck(client) + private val packageName: String = applicationContext.packageName + + private var packagerClient: JSPackagerClient? = null + private var inspectorPackagerConnection: IInspectorPackagerConnection? = null + + /** Returns an opaque ID which is stable for the current combination of device and app, stable */ + private val inspectorDeviceId: String + get() { + // Every Android app has a unique application ID that looks like a Java or Kotlin package + // name, + // such as com.example.myapp. This ID uniquely identifies your app on the device and in the + // Google Play Store. [Source: Android docs] + val packageName = packageName + + // A 64-bit number expressed as a hexadecimal string, which is either: + // * unique to each combination of app-signing key, user, and device (API level >= 26), or + // * randomly generated when the user first sets up the device and should remain constant for + // the lifetime of the user's device (API level < 26). + // [Source: Android docs] + val androidId = Secure.getString(applicationContext.contentResolver, Secure.ANDROID_ID) + val rawDeviceId = + String.format( + Locale.US, + "android-%s-%s-%s", + packageName, + androidId, + if (getFuseboxEnabled()) "fusebox" else "legacy") + return getSHA256(rawDeviceId) + } + + private val inspectorDeviceUrl: String + get() = + String.format( + Locale.US, + "http://%s/inspector/device?name=%s&app=%s&device=%s&profiling=%b", + packagerConnectionSettings.debugServerHost, + Uri.encode(getFriendlyDeviceName()), + Uri.encode(packageName), + Uri.encode(inspectorDeviceId), + getIsProfilingBuild()) + + /** Whether we should enable dev mode when requesting JS bundles. */ + private val devMode: Boolean + get() = settings.isJSDevModeEnabled + + /** Whether we should request minified JS bundles. */ + private val jSMinifyMode: Boolean + get() = settings.isJSMinifyEnabled + + public fun openPackagerConnection(clientId: String?, commandListener: PackagerCommandListener) { + if (packagerClient != null) { + FLog.w(ReactConstants.TAG, "Packager connection already open, nooping.") + return + } + object : AsyncTask() { + @Deprecated("This needs to be rewritten to not use AsyncTasks") + override fun doInBackground(vararg backgroundParams: Void): Void? { + val handlers: MutableMap = mutableMapOf() + handlers["reload"] = + object : NotificationOnlyHandler() { + override fun onNotification(params: Any?) { + commandListener.onPackagerReloadCommand() + } + } + handlers["devMenu"] = + object : NotificationOnlyHandler() { + override fun onNotification(params: Any?) { + commandListener.onPackagerDevMenuCommand() + } + } + commandListener.customCommandHandlers()?.let { handlers.putAll(it) } + handlers.putAll(FileIoHandler().handlers()) + + val onPackagerConnectedCallback: ReconnectingWebSocket.ConnectionCallback = + object : ReconnectingWebSocket.ConnectionCallback { + override fun onConnected() { + commandListener.onPackagerConnected() + } + + override fun onDisconnected() { + commandListener.onPackagerDisconnected() + } + } + + checkNotNull(clientId) + packagerClient = + JSPackagerClient( + clientId, packagerConnectionSettings, handlers, onPackagerConnectedCallback) + .apply { init() } + + return null + } + } + .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) + } + + public fun closePackagerConnection() { + object : AsyncTask() { + @Deprecated("This class needs to be rewritten to don't use AsyncTasks") + override fun doInBackground(vararg params: Void): Void? { + packagerClient?.close() + packagerClient = null + return null + } + } + .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) + } + + public fun openInspectorConnection() { + if (inspectorPackagerConnection != null) { + FLog.w(ReactConstants.TAG, "Inspector connection already open, nooping.") + return + } + object : AsyncTask() { + @Deprecated("This class needs to be rewritten to don't use AsyncTasks") + override fun doInBackground(vararg params: Void): Void? { + val metadata = getInspectorHostMetadata(applicationContext) + val deviceName = metadata["deviceName"] + if (deviceName == null) { + FLog.w(ReactConstants.TAG, "Could not get device name from Inspector Host Metadata.") + return null + } + inspectorPackagerConnection = + CxxInspectorPackagerConnection( + this@DevServerHelper.inspectorDeviceUrl, deviceName, packageName) + .apply { connect() } + return null + } + } + .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) + } + + public fun disableDebugger() { + inspectorPackagerConnection?.sendEventToAllConnections(DEBUGGER_MSG_DISABLE) + } + + public fun closeInspectorConnection() { + object : AsyncTask() { + @Deprecated("This class needs to be rewritten to don't use AsyncTasks") + override fun doInBackground(vararg params: Void): Void? { + inspectorPackagerConnection?.closeQuietly() + inspectorPackagerConnection = null + return null + } + } + .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) + } + + @JvmOverloads + public fun downloadBundleFromURL( + callback: DevBundleDownloadListener, + outputFile: File, + bundleURL: String?, + bundleInfo: BundleDownloader.BundleInfo?, + requestBuilder: Request.Builder = Request.Builder() + ) { + bundleDownloader.downloadBundleFromURL( + callback, outputFile, bundleURL, bundleInfo, requestBuilder) + } + + private fun createSplitBundleURL(mainModuleID: String, host: String): String = + createBundleURL(mainModuleID, BundleType.BUNDLE, host, true, false) + + private fun createBundleURL( + mainModuleID: String, + type: BundleType, + host: String = packagerConnectionSettings.debugServerHost, + modulesOnly: Boolean = false, + runModule: Boolean = true + ): String { + val dev = devMode + val additionalOptionsBuilder = StringBuilder() + for ((key, value) in packagerConnectionSettings.additionalOptionsForPackager) { + if (value.isEmpty()) { + continue + } + additionalOptionsBuilder.append("&" + key + "=" + Uri.encode(value)) + } + return (String.format( + Locale.US, + "http://%s/%s.%s?platform=android&dev=%s&lazy=%s&minify=%s&app=%s&modulesOnly=%s&runModule=%s", + host, + mainModuleID, + type.typeID, + dev, // dev + dev, // lazy + jSMinifyMode, + packageName, + if (modulesOnly) "true" else "false", + if (runModule) "true" else "false") + + (if (getFuseboxEnabled()) "&excludeSource=true&sourcePaths=url-server" else "") + + additionalOptionsBuilder.toString()) + } + + public fun getDevServerBundleURL(jsModulePath: String): String = + createBundleURL(jsModulePath, BundleType.BUNDLE, packagerConnectionSettings.debugServerHost) + + public fun getDevServerSplitBundleURL(jsModulePath: String): String = + createSplitBundleURL(jsModulePath, packagerConnectionSettings.debugServerHost) + + public fun isPackagerRunning(callback: PackagerStatusCallback) { + packagerStatusCheck.run(packagerConnectionSettings.debugServerHost, callback) + } + + public fun getSourceMapUrl(mainModuleName: String): String = + createBundleURL(mainModuleName, BundleType.MAP) + + public fun getSourceUrl(mainModuleName: String): String = + createBundleURL(mainModuleName, BundleType.BUNDLE) + + /** + * This is a debug-only utility to allow fetching a file via packager. It's made synchronous for + * simplicity, but should only be used if it's absolutely necessary. + * + * @return the file with the fetched content, or null if there's any failure. + */ + public fun downloadBundleResourceFromUrlSync(resourcePath: String, outputFile: File): File? { + val resourceURL = createResourceURL(packagerConnectionSettings.debugServerHost, resourcePath) + val request = Request.Builder().url(resourceURL).build() + + try { + client.newCall(request).execute().use { response -> + if (!response.isSuccessful || response.body() == null) { + return null + } + Okio.sink(outputFile).use { output -> response.body()?.source()?.buffer()?.readAll(output) } + return outputFile + } + } catch (e: Exception) { + FLog.e( + ReactConstants.TAG, + "Failed to fetch resource synchronously - resourcePath: \"%s\", outputFile: \"%s\"", + resourcePath, + outputFile.absolutePath, + e) + return null + } + } + + /** Attempt to open the JS debugger on the host machine (on-device CDP debugging). */ + public fun openDebugger(context: ReactContext?, errorMessage: String?) { + // TODO(huntie): Requests to dev server should not assume 'http' URL scheme + val requestUrl = + String.format( + Locale.US, + "http://%s/open-debugger?device=%s", + packagerConnectionSettings.debugServerHost, + Uri.encode(inspectorDeviceId)) + val request = + Request.Builder().url(requestUrl).method("POST", RequestBody.create(null, "")).build() + + client + .newCall(request) + .enqueue( + object : Callback { + override fun onFailure(call: Call, e: IOException) { + RNLog.w(context, errorMessage ?: "openDebugger error") + } + + override fun onResponse(call: Call, response: Response) = Unit + }) + } + + private companion object { + private const val HTTP_CONNECT_TIMEOUT_MS = 5000 + private const val DEBUGGER_MSG_DISABLE = "{ \"id\":1,\"method\":\"Debugger.disable\" }" + + private fun getSHA256(string: String): String { + val digest = + try { + MessageDigest.getInstance("SHA-256") + } catch (e: NoSuchAlgorithmException) { + throw AssertionError("Could not get standard SHA-256 algorithm", e) + } + digest.reset() + val result = + try { + digest.digest(string.toByteArray(charset("UTF-8"))) + } catch (e: UnsupportedEncodingException) { + throw AssertionError("This environment doesn't support UTF-8 encoding", e) + } + return String.format( + "%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x", + result[0], + result[1], + result[2], + result[3], + result[4], + result[5], + result[6], + result[7], + result[8], + result[9], + result[10], + result[11], + result[12], + result[13], + result[14], + result[15], + result[16], + result[17], + result[18], + result[19]) + } + + private fun createResourceURL(host: String, resourcePathParam: String): String { + // This is what we get for not using a proper URI library. + var resourcePath = resourcePathParam + if (resourcePath.startsWith("/")) { + FLog.w(ReactConstants.TAG, "Resource path should not begin with `/`, removing it.") + resourcePath = resourcePath.substring(1) + } + return String.format(Locale.US, "http://%s/%s", host, resourcePath) + } + } +}