From df2b2f906af43a1f2ef67ab33a821e7a5f5b3a42 Mon Sep 17 00:00:00 2001 From: Aliaksandr Babrykovich Date: Sun, 22 Feb 2026 13:44:24 +0100 Subject: [PATCH 1/4] feat: support for Android #16 --- Brewfile | 1 + apps/demo/android/app/build.gradle | 12 +- apps/demo/android/settings.gradle | 4 +- apps/fs-experiment/android/app/build.gradle | 12 +- apps/fs-experiment/android/build.gradle | 2 +- apps/fs-experiment/android/settings.gradle | 4 +- apps/p2p-chat/android/app/build.gradle | 12 +- apps/p2p-chat/android/settings.gradle | 4 +- apps/p2p-counter/android/app/build.gradle | 12 +- .../android/app/src/main/AndroidManifest.xml | 1 - apps/p2p-counter/android/build.gradle | 2 +- apps/p2p-counter/android/settings.gradle | 4 +- apps/recursive/android/app/build.gradle | 12 +- apps/recursive/android/build.gradle | 2 +- apps/recursive/android/settings.gradle | 4 +- apps/side-by-side/android/app/build.gradle | 12 +- apps/side-by-side/android/build.gradle | 2 +- apps/side-by-side/android/settings.gradle | 4 +- .../react-native-sandbox/android/build.gradle | 72 ++++ .../android/src/main/AndroidManifest.xml | 3 + .../rnsandbox/SandboxBindingsInstaller.kt | 29 ++ .../rnsandbox/SandboxJSIInstaller.kt | 47 +++ .../rnsandbox/SandboxReactNativeDelegate.kt | 358 ++++++++++++++++ .../rnsandbox/SandboxReactNativePackage.kt | 33 ++ .../rnsandbox/SandboxReactNativeView.kt | 75 ++++ .../SandboxReactNativeViewManager.kt | 237 +++++++++++ .../io/callstack/rnsandbox/SandboxRegistry.kt | 72 ++++ .../android/src/main/jni/CMakeLists.txt | 26 ++ .../src/main/jni/SandboxBindingsInstaller.cpp | 61 +++ .../src/main/jni/SandboxBindingsInstaller.h | 68 +++ .../src/main/jni/SandboxJSIInstaller.cpp | 396 ++++++++++++++++++ packages/react-native-sandbox/package.json | 8 +- .../react-native.config.js | 10 + .../specs/NativeSandboxReactNativeView.ts | 4 +- packages/react-native-sandbox/src/index.tsx | 3 + 35 files changed, 1539 insertions(+), 69 deletions(-) create mode 100644 packages/react-native-sandbox/android/build.gradle create mode 100644 packages/react-native-sandbox/android/src/main/AndroidManifest.xml create mode 100644 packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxBindingsInstaller.kt create mode 100644 packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxJSIInstaller.kt create mode 100644 packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt create mode 100644 packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativePackage.kt create mode 100644 packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeView.kt create mode 100644 packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeViewManager.kt create mode 100644 packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxRegistry.kt create mode 100644 packages/react-native-sandbox/android/src/main/jni/CMakeLists.txt create mode 100644 packages/react-native-sandbox/android/src/main/jni/SandboxBindingsInstaller.cpp create mode 100644 packages/react-native-sandbox/android/src/main/jni/SandboxBindingsInstaller.h create mode 100644 packages/react-native-sandbox/android/src/main/jni/SandboxJSIInstaller.cpp create mode 100644 packages/react-native-sandbox/react-native.config.js diff --git a/Brewfile b/Brewfile index 8e79ab3..037bad2 100644 --- a/Brewfile +++ b/Brewfile @@ -4,6 +4,7 @@ # Core development tools brew "clang-format" brew "ccache" +brew "ktlint" # Additional useful tools for React Native development brew "node" diff --git a/apps/demo/android/app/build.gradle b/apps/demo/android/app/build.gradle index 8936bca..27d0f1b 100644 --- a/apps/demo/android/app/build.gradle +++ b/apps/demo/android/app/build.gradle @@ -8,14 +8,10 @@ apply plugin: "com.facebook.react" */ react { /* Folders */ - // The root of your project, i.e. where "package.json" lives. Default is '../..' - // root = file("../../") - // The folder where the react-native NPM package is. Default is ../../node_modules/react-native - // reactNativeDir = file("../../node_modules/react-native") - // The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen - // codegenDir = file("../../node_modules/@react-native/codegen") - // The cli.js file which is the React Native CLI entrypoint. Default is ../../node_modules/react-native/cli.js - // cliFile = file("../../node_modules/react-native/cli.js") + root = file("../..") + reactNativeDir = file("../../../../node_modules/react-native") + codegenDir = file("../../../../node_modules/@react-native/codegen") + cliFile = file("../../../../node_modules/react-native/cli.js") /* Variants */ // The list of variants to that are debuggable. For those we're going to diff --git a/apps/demo/android/settings.gradle b/apps/demo/android/settings.gradle index e2fe28c..6bee25e 100644 --- a/apps/demo/android/settings.gradle +++ b/apps/demo/android/settings.gradle @@ -1,6 +1,6 @@ -pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") } +pluginManagement { includeBuild("../../../node_modules/@react-native/gradle-plugin") } plugins { id("com.facebook.react.settings") } extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() } rootProject.name = 'Demo' include ':app' -includeBuild('../node_modules/@react-native/gradle-plugin') +includeBuild('../../../node_modules/@react-native/gradle-plugin') diff --git a/apps/fs-experiment/android/app/build.gradle b/apps/fs-experiment/android/app/build.gradle index 1cb306c..9079044 100644 --- a/apps/fs-experiment/android/app/build.gradle +++ b/apps/fs-experiment/android/app/build.gradle @@ -8,14 +8,10 @@ apply plugin: "com.facebook.react" */ react { /* Folders */ - // The root of your project, i.e. where "package.json" lives. Default is '../..' - // root = file("../../") - // The folder where the react-native NPM package is. Default is ../../node_modules/react-native - // reactNativeDir = file("../../node_modules/react-native") - // The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen - // codegenDir = file("../../node_modules/@react-native/codegen") - // The cli.js file which is the React Native CLI entrypoint. Default is ../../node_modules/react-native/cli.js - // cliFile = file("../../node_modules/react-native/cli.js") + root = file("../..") + reactNativeDir = file("../../../../node_modules/react-native") + codegenDir = file("../../../../node_modules/@react-native/codegen") + cliFile = file("../../../../node_modules/react-native/cli.js") /* Variants */ // The list of variants to that are debuggable. For those we're going to diff --git a/apps/fs-experiment/android/build.gradle b/apps/fs-experiment/android/build.gradle index 9766946..b4f3ad9 100644 --- a/apps/fs-experiment/android/build.gradle +++ b/apps/fs-experiment/android/build.gradle @@ -5,7 +5,7 @@ buildscript { compileSdkVersion = 35 targetSdkVersion = 35 ndkVersion = "27.1.12297006" - kotlinVersion = "2.0.21" + kotlinVersion = "2.1.20" } repositories { google() diff --git a/apps/fs-experiment/android/settings.gradle b/apps/fs-experiment/android/settings.gradle index b28ace3..bbfc684 100644 --- a/apps/fs-experiment/android/settings.gradle +++ b/apps/fs-experiment/android/settings.gradle @@ -1,6 +1,6 @@ -pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") } +pluginManagement { includeBuild("../../../node_modules/@react-native/gradle-plugin") } plugins { id("com.facebook.react.settings") } extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() } rootProject.name = 'MultInstance-Recursive' include ':app' -includeBuild('../node_modules/@react-native/gradle-plugin') +includeBuild('../../../node_modules/@react-native/gradle-plugin') diff --git a/apps/p2p-chat/android/app/build.gradle b/apps/p2p-chat/android/app/build.gradle index d20fd2e..54dede5 100644 --- a/apps/p2p-chat/android/app/build.gradle +++ b/apps/p2p-chat/android/app/build.gradle @@ -8,14 +8,10 @@ apply plugin: "com.facebook.react" */ react { /* Folders */ - // The root of your project, i.e. where "package.json" lives. Default is '../..' - // root = file("../../") - // The folder where the react-native NPM package is. Default is ../../node_modules/react-native - // reactNativeDir = file("../../node_modules/react-native") - // The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen - // codegenDir = file("../../node_modules/@react-native/codegen") - // The cli.js file which is the React Native CLI entrypoint. Default is ../../node_modules/react-native/cli.js - // cliFile = file("../../node_modules/react-native/cli.js") + root = file("../..") + reactNativeDir = file("../../../../node_modules/react-native") + codegenDir = file("../../../../node_modules/@react-native/codegen") + cliFile = file("../../../../node_modules/react-native/cli.js") /* Variants */ // The list of variants to that are debuggable. For those we're going to diff --git a/apps/p2p-chat/android/settings.gradle b/apps/p2p-chat/android/settings.gradle index 6877ac9..ec4cdff 100644 --- a/apps/p2p-chat/android/settings.gradle +++ b/apps/p2p-chat/android/settings.gradle @@ -1,6 +1,6 @@ -pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") } +pluginManagement { includeBuild("../../../node_modules/@react-native/gradle-plugin") } plugins { id("com.facebook.react.settings") } extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() } rootProject.name = 'P2PChat' include ':app' -includeBuild('../node_modules/@react-native/gradle-plugin') +includeBuild('../../../node_modules/@react-native/gradle-plugin') diff --git a/apps/p2p-counter/android/app/build.gradle b/apps/p2p-counter/android/app/build.gradle index 3bdf974..35e0993 100644 --- a/apps/p2p-counter/android/app/build.gradle +++ b/apps/p2p-counter/android/app/build.gradle @@ -8,14 +8,10 @@ apply plugin: "com.facebook.react" */ react { /* Folders */ - // The root of your project, i.e. where "package.json" lives. Default is '../..' - // root = file("../../") - // The folder where the react-native NPM package is. Default is ../../node_modules/react-native - // reactNativeDir = file("../../node_modules/react-native") - // The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen - // codegenDir = file("../../node_modules/@react-native/codegen") - // The cli.js file which is the React Native CLI entrypoint. Default is ../../node_modules/react-native/cli.js - // cliFile = file("../../node_modules/react-native/cli.js") + root = file("../..") + reactNativeDir = file("../../../../node_modules/react-native") + codegenDir = file("../../../../node_modules/@react-native/codegen") + cliFile = file("../../../../node_modules/react-native/cli.js") /* Variants */ // The list of variants to that are debuggable. For those we're going to diff --git a/apps/p2p-counter/android/app/src/main/AndroidManifest.xml b/apps/p2p-counter/android/app/src/main/AndroidManifest.xml index 3199009..e189252 100644 --- a/apps/p2p-counter/android/app/src/main/AndroidManifest.xml +++ b/apps/p2p-counter/android/app/src/main/AndroidManifest.xml @@ -4,7 +4,6 @@ ex.autolinkLibrariesFromCommand() } rootProject.name = 'MultiInstancePOC' include ':app' -includeBuild('../node_modules/@react-native/gradle-plugin') +includeBuild('../../../node_modules/@react-native/gradle-plugin') diff --git a/apps/recursive/android/app/build.gradle b/apps/recursive/android/app/build.gradle index 07a0467..379a4e7 100644 --- a/apps/recursive/android/app/build.gradle +++ b/apps/recursive/android/app/build.gradle @@ -8,14 +8,10 @@ apply plugin: "com.facebook.react" */ react { /* Folders */ - // The root of your project, i.e. where "package.json" lives. Default is '../..' - // root = file("../../") - // The folder where the react-native NPM package is. Default is ../../node_modules/react-native - // reactNativeDir = file("../../node_modules/react-native") - // The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen - // codegenDir = file("../../node_modules/@react-native/codegen") - // The cli.js file which is the React Native CLI entrypoint. Default is ../../node_modules/react-native/cli.js - // cliFile = file("../../node_modules/react-native/cli.js") + root = file("../..") + reactNativeDir = file("../../../../node_modules/react-native") + codegenDir = file("../../../../node_modules/@react-native/codegen") + cliFile = file("../../../../node_modules/react-native/cli.js") /* Variants */ // The list of variants to that are debuggable. For those we're going to diff --git a/apps/recursive/android/build.gradle b/apps/recursive/android/build.gradle index 9766946..b4f3ad9 100644 --- a/apps/recursive/android/build.gradle +++ b/apps/recursive/android/build.gradle @@ -5,7 +5,7 @@ buildscript { compileSdkVersion = 35 targetSdkVersion = 35 ndkVersion = "27.1.12297006" - kotlinVersion = "2.0.21" + kotlinVersion = "2.1.20" } repositories { google() diff --git a/apps/recursive/android/settings.gradle b/apps/recursive/android/settings.gradle index b28ace3..bbfc684 100644 --- a/apps/recursive/android/settings.gradle +++ b/apps/recursive/android/settings.gradle @@ -1,6 +1,6 @@ -pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") } +pluginManagement { includeBuild("../../../node_modules/@react-native/gradle-plugin") } plugins { id("com.facebook.react.settings") } extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() } rootProject.name = 'MultInstance-Recursive' include ':app' -includeBuild('../node_modules/@react-native/gradle-plugin') +includeBuild('../../../node_modules/@react-native/gradle-plugin') diff --git a/apps/side-by-side/android/app/build.gradle b/apps/side-by-side/android/app/build.gradle index 0415439..4eab4c5 100644 --- a/apps/side-by-side/android/app/build.gradle +++ b/apps/side-by-side/android/app/build.gradle @@ -8,14 +8,10 @@ apply plugin: "com.facebook.react" */ react { /* Folders */ - // The root of your project, i.e. where "package.json" lives. Default is '../..' - // root = file("../../") - // The folder where the react-native NPM package is. Default is ../../node_modules/react-native - // reactNativeDir = file("../../node_modules/react-native") - // The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen - // codegenDir = file("../../node_modules/@react-native/codegen") - // The cli.js file which is the React Native CLI entrypoint. Default is ../../node_modules/react-native/cli.js - // cliFile = file("../../node_modules/react-native/cli.js") + root = file("../..") + reactNativeDir = file("../../../../node_modules/react-native") + codegenDir = file("../../../../node_modules/@react-native/codegen") + cliFile = file("../../../../node_modules/react-native/cli.js") /* Variants */ // The list of variants to that are debuggable. For those we're going to diff --git a/apps/side-by-side/android/build.gradle b/apps/side-by-side/android/build.gradle index 9766946..b4f3ad9 100644 --- a/apps/side-by-side/android/build.gradle +++ b/apps/side-by-side/android/build.gradle @@ -5,7 +5,7 @@ buildscript { compileSdkVersion = 35 targetSdkVersion = 35 ndkVersion = "27.1.12297006" - kotlinVersion = "2.0.21" + kotlinVersion = "2.1.20" } repositories { google() diff --git a/apps/side-by-side/android/settings.gradle b/apps/side-by-side/android/settings.gradle index f2ef51b..e3ce3b2 100644 --- a/apps/side-by-side/android/settings.gradle +++ b/apps/side-by-side/android/settings.gradle @@ -1,6 +1,6 @@ -pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") } +pluginManagement { includeBuild("../../../node_modules/@react-native/gradle-plugin") } plugins { id("com.facebook.react.settings") } extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() } rootProject.name = 'MultiInstancePOC' include ':app' -includeBuild('../node_modules/@react-native/gradle-plugin') +includeBuild('../../../node_modules/@react-native/gradle-plugin') diff --git a/packages/react-native-sandbox/android/build.gradle b/packages/react-native-sandbox/android/build.gradle new file mode 100644 index 0000000..85a5856 --- /dev/null +++ b/packages/react-native-sandbox/android/build.gradle @@ -0,0 +1,72 @@ +buildscript { + if (project == rootProject) { + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin") + } + } +} + +apply plugin: "com.android.library" +apply plugin: "org.jetbrains.kotlin.android" +apply plugin: "com.facebook.react" + +def safeExtGet(prop, fallback) { + rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback +} + +android { + namespace "io.callstack.rnsandbox" + compileSdkVersion safeExtGet("compileSdkVersion", 35) + + defaultConfig { + minSdkVersion safeExtGet("minSdkVersion", 24) + targetSdkVersion safeExtGet("targetSdkVersion", 35) + + externalNativeBuild { + cmake { + cppFlags "-std=c++17 -fexceptions -frtti" + arguments "-DANDROID_STL=c++_shared" + } + } + } + + externalNativeBuild { + cmake { + path "src/main/jni/CMakeLists.txt" + } + } + + sourceSets { + main { + java.srcDirs += ["src/main/java"] + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + prefab true + } +} + +repositories { + mavenCentral() + google() +} + +dependencies { + implementation "com.facebook.react:react-android:+" +} diff --git a/packages/react-native-sandbox/android/src/main/AndroidManifest.xml b/packages/react-native-sandbox/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f79cac1 --- /dev/null +++ b/packages/react-native-sandbox/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxBindingsInstaller.kt b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxBindingsInstaller.kt new file mode 100644 index 0000000..f8f0e1e --- /dev/null +++ b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxBindingsInstaller.kt @@ -0,0 +1,29 @@ +package io.callstack.rnsandbox + +import com.facebook.jni.HybridData +import com.facebook.react.runtime.BindingsInstaller +import com.facebook.soloader.SoLoader + +class SandboxBindingsInstaller private constructor( + hybridData: HybridData, + private val delegate: SandboxReactNativeDelegate, +) : BindingsInstaller(hybridData) { + companion object { + init { + SoLoader.loadLibrary("rnsandbox") + } + + fun create(delegate: SandboxReactNativeDelegate): SandboxBindingsInstaller { + val hybridData = initHybrid(delegate) + return SandboxBindingsInstaller(hybridData, delegate) + } + + @JvmStatic + private external fun initHybrid(delegate: Any): HybridData + } + + @Suppress("unused") + fun onJSIBindingsInstalled(stateHandle: Long) { + delegate.onJSIBindingsInstalled(stateHandle) + } +} diff --git a/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxJSIInstaller.kt b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxJSIInstaller.kt new file mode 100644 index 0000000..b892ad2 --- /dev/null +++ b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxJSIInstaller.kt @@ -0,0 +1,47 @@ +package io.callstack.rnsandbox + +/** + * JNI bridge for installing JSI globals (postMessage, setOnMessage) into a + * sandboxed React Native runtime. Mirrors the iOS SandboxReactNativeDelegate's + * JSI setup via hostDidStart:. + */ +object SandboxJSIInstaller { + init { + System.loadLibrary("rnsandbox") + } + + /** + * Installs postMessage/setOnMessage globals into the JS runtime. + * Must be called on the JS thread. + * + * @param runtimePtr Raw pointer to jsi::Runtime (from JavaScriptContextHolder.get()) + * @param delegate The delegate that handles messages from JS + * @return A state handle for subsequent postMessage/destroy calls, or 0 on failure + */ + @JvmStatic + external fun nativeInstall( + runtimePtr: Long, + delegate: SandboxReactNativeDelegate, + ): Long + + /** + * Delivers a JSON message to the sandbox's JS onMessage callback. + * Must be called on the JS thread. + * + * @param stateHandle Handle returned by nativeInstall + * @param message JSON-serialized message string + */ + @JvmStatic + external fun nativePostMessage( + stateHandle: Long, + message: String, + ) + + /** + * Cleans up JSI state for a sandbox. Safe to call from any thread. + * + * @param stateHandle Handle returned by nativeInstall + */ + @JvmStatic + external fun nativeDestroy(stateHandle: Long) +} diff --git a/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt new file mode 100644 index 0000000..97f27ce --- /dev/null +++ b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt @@ -0,0 +1,358 @@ +package io.callstack.rnsandbox + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.os.Bundle +import android.util.Log +import android.view.View +import com.facebook.react.BaseReactPackage +import com.facebook.react.ReactHost +import com.facebook.react.ReactInstanceEventListener +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.JSBundleLoader +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.UiThreadUtil +import com.facebook.react.common.annotations.UnstableReactNativeAPI +import com.facebook.react.defaults.DefaultComponentsRegistry +import com.facebook.react.defaults.DefaultReactHostDelegate +import com.facebook.react.defaults.DefaultTurboModuleManagerDelegate +import com.facebook.react.fabric.ComponentFactory +import com.facebook.react.interfaces.fabric.ReactSurface +import com.facebook.react.module.model.ReactModuleInfoProvider +import com.facebook.react.runtime.ReactHostImpl +import com.facebook.react.runtime.hermes.HermesInstance +import com.facebook.react.shell.MainReactPackage +import com.facebook.react.uimanager.ViewManager + +class SandboxReactNativeDelegate( + private val context: Context, +) { + companion object { + private const val TAG = "SandboxRNDelegate" + } + + var origin: String = "" + set(value) { + if (field == value) return + if (field.isNotEmpty()) { + SandboxRegistry.unregister(field) + } + field = value + if (value.isNotEmpty()) { + SandboxRegistry.register(value, this, allowedOrigins) + } + } + + var jsBundleSource: String = "" + var allowedTurboModules: Set = emptySet() + var allowedOrigins: Set = emptySet() + set(value) { + field = value + if (origin.isNotEmpty()) { + SandboxRegistry.register(origin, this, value) + } + } + + @JvmField var hasOnMessageHandler: Boolean = false + + @JvmField var hasOnErrorHandler: Boolean = false + var sandboxView: SandboxReactNativeView? = null + + private var reactHost: ReactHostImpl? = null + private var reactSurface: ReactSurface? = null + private var jsiStateHandle: Long = 0 + private var sandboxReactContext: ReactContext? = null + + @OptIn(UnstableReactNativeAPI::class) + fun loadReactNativeView( + componentName: String, + initialProperties: Bundle?, + @Suppress("UNUSED_PARAMETER") launchOptions: Bundle?, + ): View? { + if (componentName.isEmpty() || jsBundleSource.isEmpty()) return null + + cleanup() + + val capturedBundleSource = jsBundleSource + val capturedAllowedModules = allowedTurboModules + val sandboxId = System.identityHashCode(this).toString(16) + val sandboxContext = SandboxContextWrapper(context, sandboxId) + + try { + val packages: List = + listOf( + FilteredReactPackage(MainReactPackage(), capturedAllowedModules), + ) + + val bundleLoader = createBundleLoader(capturedBundleSource) ?: return null + + val tmmDelegateBuilder = DefaultTurboModuleManagerDelegate.Builder() + + val bindingsInstaller = SandboxBindingsInstaller.create(this) + + val hostDelegate = + DefaultReactHostDelegate( + jsMainModulePath = capturedBundleSource, + jsBundleLoader = bundleLoader, + reactPackages = packages, + jsRuntimeFactory = HermesInstance(), + turboModuleManagerDelegateBuilder = tmmDelegateBuilder, + bindingsInstaller = bindingsInstaller, + ) + + val componentFactory = ComponentFactory() + DefaultComponentsRegistry.register(componentFactory) + + val host = + ReactHostImpl( + sandboxContext, + hostDelegate, + componentFactory, + true, + true, + ) + + reactHost = host + + host.addReactInstanceEventListener( + object : ReactInstanceEventListener { + override fun onReactContextInitialized(reactContext: ReactContext) { + sandboxReactContext = reactContext + } + }, + ) + + val surface = host.createSurface(sandboxContext, componentName, initialProperties) + reactSurface = surface + + surface.start() + + val activity = getActivity() + if (activity != null) { + host.onHostResume(activity) + } + + return surface.view + } catch (e: Exception) { + Log.e(TAG, "Failed to create React Native view: ${e.message}", e) + sandboxView?.emitOnError( + "LoadError", + e.message ?: "Unknown error", + e.stackTraceToString(), + true, + ) + return null + } + } + + fun reloadWithNewBundleSource(): Boolean { + val host = reactHost ?: return false + + val newLoader = createBundleLoader(jsBundleSource) ?: return false + + try { + val delegateField = ReactHostImpl::class.java.getDeclaredField("reactHostDelegate") + delegateField.isAccessible = true + val delegate = delegateField.get(host) + + val loaderField = delegate.javaClass.getDeclaredField("jsBundleLoader") + loaderField.isAccessible = true + + val modifiersField = java.lang.reflect.Field::class.java.getDeclaredField("accessFlags") + modifiersField.isAccessible = true + modifiersField.setInt( + loaderField, + loaderField.modifiers and + java.lang.reflect.Modifier.FINAL + .inv(), + ) + + loaderField.set(delegate, newLoader) + + host.reload("jsBundleSource changed") + Log.d(TAG, "Reloaded sandbox '$origin' with new bundle source via reflection") + return true + } catch (e: Exception) { + Log.w(TAG, "Reflection-based bundle reload failed, falling back to full rebuild: ${e.message}") + return false + } + } + + private fun createBundleLoader(bundleSource: String): JSBundleLoader? { + if (bundleSource.isEmpty()) return null + return when { + bundleSource.startsWith("http://") || bundleSource.startsWith("https://") -> { + JSBundleLoader.createFileLoader(bundleSource) + } + + else -> { + JSBundleLoader.createAssetLoader(context, "assets://$bundleSource", true) + } + } + } + + fun onJSIBindingsInstalled(stateHandle: Long) { + jsiStateHandle = stateHandle + } + + fun postMessage(message: String) { + val reactContext = sandboxReactContext + val handle = jsiStateHandle + Log.d(TAG, "postMessage to '$origin': context=${reactContext != null}, handle=$handle") + if (reactContext == null || handle == 0L) return + + reactContext.runOnJSQueueThread { + SandboxJSIInstaller.nativePostMessage(handle, message) + } + } + + @Suppress("unused") + fun emitOnMessageFromJS(messageJson: String) { + if (!hasOnMessageHandler) return + + UiThreadUtil.runOnUiThread { + try { + val data = + Arguments.createMap().apply { + putString("data", messageJson) + } + sandboxView?.emitOnMessage(data) + } catch (e: Exception) { + Log.e(TAG, "Error emitting onMessage: ${e.message}", e) + } + } + } + + @Suppress("unused") + fun routeMessageFromJS( + messageJson: String, + targetOrigin: String, + ): Boolean { + if (origin == targetOrigin) { + sandboxView?.emitOnError( + "SelfTargetingError", + "Cannot send message to self (sandbox '$targetOrigin')", + ) + return false + } + + return routeMessage(messageJson, targetOrigin) + } + + @Suppress("unused") + fun emitOnErrorFromJS( + name: String, + message: String, + stack: String, + isFatal: Boolean, + ) { + if (!hasOnErrorHandler) return + + UiThreadUtil.runOnUiThread { + try { + sandboxView?.emitOnError(name, message, stack, isFatal) + } catch (e: Exception) { + Log.e(TAG, "Error emitting onError: ${e.message}", e) + } + } + } + + fun routeMessage( + message: String, + targetId: String, + ): Boolean { + val target = SandboxRegistry.find(targetId) + Log.d(TAG, "routeMessage from '$origin' to '$targetId': target found=${target != null}") + if (target == null) return false + + if (!SandboxRegistry.isPermittedFrom(origin, targetId)) { + Log.w(TAG, "routeMessage DENIED: '$origin' -> '$targetId'") + sandboxView?.emitOnError( + "AccessDeniedError", + "Access denied: Sandbox '$origin' is not permitted to send messages to '$targetId'", + ) + return false + } + + Log.d(TAG, "routeMessage PERMITTED: '$origin' -> '$targetId', delivering...") + target.postMessage(message) + return true + } + + private fun getActivity(): Activity? { + var ctx = context + while (ctx is android.content.ContextWrapper) { + if (ctx is Activity) return ctx + ctx = ctx.baseContext + } + return null + } + + fun cleanup() { + if (jsiStateHandle != 0L) { + SandboxJSIInstaller.nativeDestroy(jsiStateHandle) + jsiStateHandle = 0 + } + sandboxReactContext = null + + reactSurface?.let { + it.stop() + it.detach() + } + reactSurface = null + + reactHost?.let { + it.onHostDestroy() + it.destroy("sandbox cleanup", null) + } + reactHost = null + } + + fun destroy() { + if (origin.isNotEmpty()) { + SandboxRegistry.unregister(origin) + } + cleanup() + } + + private class SandboxContextWrapper( + base: Context, + sandboxId: String, + ) : ContextWrapper(base) { + private val sandboxFilesDir = java.io.File(base.filesDir, "sandbox_$sandboxId").also { it.mkdirs() } + + override fun getFilesDir(): java.io.File = sandboxFilesDir + + override fun getApplicationContext(): Context = this + } + + private class FilteredReactPackage( + private val delegate: MainReactPackage, + private val allowedModules: Set, + ) : BaseReactPackage() { + override fun getModule( + name: String, + reactContext: ReactApplicationContext, + ): NativeModule? { + if (!allowedModules.contains(name)) { + Log.w(TAG, "Blocked module '$name' — not in allowedTurboModules") + return null + } + return delegate.getModule(name, reactContext) + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + val delegateProvider = delegate.getReactModuleInfoProvider() + return ReactModuleInfoProvider { + delegateProvider.getReactModuleInfos().filterKeys { allowedModules.contains(it) } + } + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> = + delegate.createViewManagers(reactContext) + } +} diff --git a/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativePackage.kt b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativePackage.kt new file mode 100644 index 0000000..e93c096 --- /dev/null +++ b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativePackage.kt @@ -0,0 +1,33 @@ +package io.callstack.rnsandbox + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider +import com.facebook.react.uimanager.ViewManager + +class SandboxReactNativePackage : BaseReactPackage() { + override fun createViewManagers(reactContext: ReactApplicationContext): List> = + listOf(SandboxReactNativeViewManager()) + + override fun getModule( + name: String, + reactContext: ReactApplicationContext, + ): NativeModule? = null + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = + ReactModuleInfoProvider { + mapOf( + SandboxReactNativeViewManager.REACT_CLASS to + ReactModuleInfo( + name = SandboxReactNativeViewManager.REACT_CLASS, + className = SandboxReactNativeViewManager.REACT_CLASS, + canOverrideExistingModule = false, + needsEagerInit = false, + isCxxModule = false, + isTurboModule = true, + ), + ) + } +} diff --git a/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeView.kt b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeView.kt new file mode 100644 index 0000000..8619e91 --- /dev/null +++ b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeView.kt @@ -0,0 +1,75 @@ +package io.callstack.rnsandbox + +import android.content.Context +import android.os.Bundle +import android.widget.FrameLayout +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.events.Event + +class SandboxReactNativeView( + context: Context, +) : FrameLayout(context) { + var delegate: SandboxReactNativeDelegate? = null + var pendingComponentName: String? = null + var pendingInitialProperties: Bundle? = null + var pendingLaunchOptions: Bundle? = null + var loadScheduled: Boolean = false + var needsLoad: Boolean = false + var onAttachLoadCallback: (() -> Unit)? = null + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (needsLoad && childCount == 0) { + onAttachLoadCallback?.invoke() + } + } + + fun emitOnMessage(data: WritableMap) { + val reactContext = context as? ReactContext ?: return + val surfaceId = UIManagerHelper.getSurfaceId(reactContext) + val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id) + eventDispatcher?.dispatchEvent(OnMessageEvent(surfaceId, id, data)) + } + + fun emitOnError( + name: String, + message: String, + stack: String? = null, + isFatal: Boolean = false, + ) { + val reactContext = context as? ReactContext ?: return + val surfaceId = UIManagerHelper.getSurfaceId(reactContext) + val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id) + val payload = + Arguments.createMap().apply { + putString("name", name) + putString("message", message) + putString("stack", stack ?: "") + putBoolean("isFatal", isFatal) + } + eventDispatcher?.dispatchEvent(OnErrorEvent(surfaceId, id, payload)) + } + + inner class OnMessageEvent( + surfaceId: Int, + viewId: Int, + private val payload: WritableMap, + ) : Event(surfaceId, viewId) { + override fun getEventName() = "topMessage" + + override fun getEventData() = payload + } + + inner class OnErrorEvent( + surfaceId: Int, + viewId: Int, + private val payload: WritableMap, + ) : Event(surfaceId, viewId) { + override fun getEventName() = "topError" + + override fun getEventData() = payload + } +} diff --git a/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeViewManager.kt b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeViewManager.kt new file mode 100644 index 0000000..1922c00 --- /dev/null +++ b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeViewManager.kt @@ -0,0 +1,237 @@ +package io.callstack.rnsandbox + +import android.os.Bundle +import android.widget.FrameLayout +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Dynamic +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableType +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewGroupManager +import com.facebook.react.uimanager.ViewManagerDelegate +import com.facebook.react.uimanager.annotations.ReactProp +import com.facebook.react.viewmanagers.SandboxReactNativeViewManagerDelegate +import com.facebook.react.viewmanagers.SandboxReactNativeViewManagerInterface + +@ReactModule(name = SandboxReactNativeViewManager.REACT_CLASS) +class SandboxReactNativeViewManager : + ViewGroupManager(), + SandboxReactNativeViewManagerInterface { + companion object { + const val REACT_CLASS = "SandboxReactNativeView" + } + + private val mDelegate = SandboxReactNativeViewManagerDelegate(this) + + override fun getDelegate(): ViewManagerDelegate = mDelegate + + override fun getName(): String = REACT_CLASS + + override fun createViewInstance(context: ThemedReactContext): SandboxReactNativeView { + val view = SandboxReactNativeView(context) + view.delegate = + SandboxReactNativeDelegate(context).apply { + sandboxView = view + } + view.onAttachLoadCallback = { loadReactNativeView(view) } + return view + } + + override fun onDropViewInstance(view: SandboxReactNativeView) { + super.onDropViewInstance(view) + view.delegate?.destroy() + view.delegate = null + } + + @ReactProp(name = "origin") + override fun setOrigin( + view: SandboxReactNativeView, + value: String?, + ) { + view.delegate?.origin = value ?: "" + } + + @ReactProp(name = "componentName") + override fun setComponentName( + view: SandboxReactNativeView, + value: String?, + ) { + if (view.pendingComponentName == value) return + view.pendingComponentName = value + scheduleLoad(view) + } + + @ReactProp(name = "jsBundleSource") + override fun setJsBundleSource( + view: SandboxReactNativeView, + value: String?, + ) { + val newValue = value ?: "" + val delegate = view.delegate ?: return + if (delegate.jsBundleSource == newValue) return + delegate.jsBundleSource = newValue + if (view.childCount > 0 && delegate.reloadWithNewBundleSource()) return + scheduleLoad(view) + } + + @ReactProp(name = "initialProperties") + override fun setInitialProperties( + view: SandboxReactNativeView, + value: Dynamic, + ) { + val newBundle = dynamicToBundle(value) + if (bundlesEqual(view.pendingInitialProperties, newBundle)) return + view.pendingInitialProperties = newBundle + if (view.childCount > 0) { + scheduleLoad(view) + } + } + + @ReactProp(name = "launchOptions") + override fun setLaunchOptions( + view: SandboxReactNativeView, + value: Dynamic, + ) { + val newBundle = dynamicToBundle(value) + if (bundlesEqual(view.pendingLaunchOptions, newBundle)) return + view.pendingLaunchOptions = newBundle + if (view.childCount > 0) { + scheduleLoad(view) + } + } + + @ReactProp(name = "allowedTurboModules") + override fun setAllowedTurboModules( + view: SandboxReactNativeView, + value: ReadableArray?, + ) { + val modules = mutableSetOf() + value?.let { + for (i in 0 until it.size()) { + it.getString(i)?.let { name -> modules.add(name) } + } + } + view.delegate?.allowedTurboModules = modules + } + + @ReactProp(name = "allowedOrigins") + override fun setAllowedOrigins( + view: SandboxReactNativeView, + value: ReadableArray?, + ) { + val origins = mutableSetOf() + value?.let { + for (i in 0 until it.size()) { + it.getString(i)?.let { name -> origins.add(name) } + } + } + view.delegate?.allowedOrigins = origins + } + + @ReactProp(name = "hasOnMessageHandler") + override fun setHasOnMessageHandler( + view: SandboxReactNativeView, + value: Boolean, + ) { + view.delegate?.hasOnMessageHandler = value + } + + @ReactProp(name = "hasOnErrorHandler") + override fun setHasOnErrorHandler( + view: SandboxReactNativeView, + value: Boolean, + ) { + view.delegate?.hasOnErrorHandler = value + } + + override fun postMessage( + view: SandboxReactNativeView, + message: String, + ) { + view.delegate?.postMessage(message) + } + + override fun receiveCommand( + root: SandboxReactNativeView, + commandId: String, + args: ReadableArray?, + ) { + mDelegate.receiveCommand(root, commandId, args) + } + + private fun scheduleLoad(view: SandboxReactNativeView) { + view.needsLoad = true + if (view.loadScheduled) return + view.loadScheduled = true + + val posted = + view.post { + view.loadScheduled = false + loadReactNativeView(view) + } + if (!posted) { + view.loadScheduled = false + } + } + + private fun loadReactNativeView(view: SandboxReactNativeView) { + if (!view.needsLoad) return + + val componentName = view.pendingComponentName + val delegate = view.delegate + + if (componentName.isNullOrEmpty() || delegate == null || delegate.jsBundleSource.isEmpty()) { + return + } + + view.needsLoad = false + view.removeAllViews() + + val rnView = + delegate.loadReactNativeView( + componentName = componentName, + initialProperties = view.pendingInitialProperties, + launchOptions = view.pendingLaunchOptions, + ) ?: return + + view.addView( + rnView, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + } + + private fun dynamicToBundle(dynamic: Dynamic): Bundle? { + if (dynamic.isNull || dynamic.type != ReadableType.Map) return null + return Arguments.toBundle(dynamic.asMap()) + } + + private fun bundlesEqual( + a: Bundle?, + b: Bundle?, + ): Boolean { + if (a === b) return true + if (a == null || b == null) return false + if (a.keySet() != b.keySet()) return false + return a.keySet().all { key -> + deepEquals(a.get(key), b.get(key)) + } + } + + private fun deepEquals( + a: Any?, + b: Any?, + ): Boolean { + if (a === b) return true + if (a == null || b == null) return false + if (a is Bundle && b is Bundle) return bundlesEqual(a, b) + if (a is ArrayList<*> && b is ArrayList<*>) { + if (a.size != b.size) return false + return a.indices.all { deepEquals(a[it], b[it]) } + } + return a == b + } +} diff --git a/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxRegistry.kt b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxRegistry.kt new file mode 100644 index 0000000..588a009 --- /dev/null +++ b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxRegistry.kt @@ -0,0 +1,72 @@ +package io.callstack.rnsandbox + +import android.util.Log + +/** + * Thread-safe singleton registry for managing sandbox delegates. + * Mirrors the C++ SandboxRegistry for cross-sandbox communication. + */ +object SandboxRegistry { + private const val TAG = "SandboxRegistry" + private val lock = Any() + private val sandboxRegistry = mutableMapOf() + private val allowedOriginsMap = mutableMapOf>() + + fun register( + origin: String, + delegate: SandboxReactNativeDelegate, + allowedOrigins: Set, + ) { + if (origin.isEmpty()) return + + synchronized(lock) { + if (sandboxRegistry.containsKey(origin)) { + Log.w(TAG, "Overwriting existing sandbox with origin: $origin, allowedOrigins=$allowedOrigins") + } else { + Log.d(TAG, "Registering sandbox origin: $origin, allowedOrigins=$allowedOrigins") + } + sandboxRegistry[origin] = delegate + allowedOriginsMap[origin] = allowedOrigins + } + } + + fun unregister(origin: String) { + if (origin.isEmpty()) return + + synchronized(lock) { + sandboxRegistry.remove(origin) + allowedOriginsMap.remove(origin) + } + } + + fun find(origin: String): SandboxReactNativeDelegate? { + if (origin.isEmpty()) return null + + synchronized(lock) { + return sandboxRegistry[origin] + } + } + + fun isPermittedFrom( + sourceOrigin: String, + targetOrigin: String, + ): Boolean { + if (sourceOrigin.isEmpty() || targetOrigin.isEmpty()) return false + + synchronized(lock) { + val origins = allowedOriginsMap[sourceOrigin] ?: return false + val permitted = origins.contains(targetOrigin) + if (!permitted) { + Log.w(TAG, "isPermittedFrom($sourceOrigin -> $targetOrigin): denied. allowedOrigins[$sourceOrigin]=$origins") + } + return permitted + } + } + + fun reset() { + synchronized(lock) { + sandboxRegistry.clear() + allowedOriginsMap.clear() + } + } +} diff --git a/packages/react-native-sandbox/android/src/main/jni/CMakeLists.txt b/packages/react-native-sandbox/android/src/main/jni/CMakeLists.txt new file mode 100644 index 0000000..9864da9 --- /dev/null +++ b/packages/react-native-sandbox/android/src/main/jni/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(rnsandbox) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +find_package(fbjni REQUIRED CONFIG) +find_package(ReactAndroid REQUIRED CONFIG) + +add_library(${PROJECT_NAME} SHARED + SandboxJSIInstaller.cpp + SandboxBindingsInstaller.cpp +) + +target_include_directories(${PROJECT_NAME} + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}" +) + +target_link_libraries(${PROJECT_NAME} + fbjni::fbjni + ReactAndroid::jsi + ReactAndroid::reactnative + android + log +) diff --git a/packages/react-native-sandbox/android/src/main/jni/SandboxBindingsInstaller.cpp b/packages/react-native-sandbox/android/src/main/jni/SandboxBindingsInstaller.cpp new file mode 100644 index 0000000..89e0389 --- /dev/null +++ b/packages/react-native-sandbox/android/src/main/jni/SandboxBindingsInstaller.cpp @@ -0,0 +1,61 @@ +#include "SandboxBindingsInstaller.h" + +#include + +#define LOG_TAG "SandboxJSI" +#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +namespace jsi = facebook::jsi; +namespace jni = facebook::jni; + +extern jlong installSandboxJSIBindings( + jsi::Runtime& runtime, + JNIEnv* env, + jobject delegateRef); + +namespace rnsandbox { + +SandboxBindingsInstaller::SandboxBindingsInstaller( + jni::alias_ref delegateRef) + : delegateRef_(jni::make_global(delegateRef)) {} + +jni::local_ref +SandboxBindingsInstaller::initHybrid( + jni::alias_ref, + jni::alias_ref delegateRef) { + return makeCxxInstance(delegateRef); +} + +void SandboxBindingsInstaller::registerNatives() { + registerHybrid({ + makeNativeMethod("initHybrid", SandboxBindingsInstaller::initHybrid), + }); +} + +facebook::react::ReactInstance::BindingsInstallFunc +SandboxBindingsInstaller::getBindingsInstallFunc() { + auto delegateRef = delegateRef_; + return [delegateRef](jsi::Runtime& runtime) { + JNIEnv* env = jni::Environment::current(); + if (!env) { + LOGE("BindingsInstaller: no JNI environment"); + return; + } + + jobject rawDelegate = delegateRef.get(); + + jclass cls = env->GetObjectClass(rawDelegate); + jmethodID onInstalled = + env->GetMethodID(cls, "onJSIBindingsInstalled", "(J)V"); + env->DeleteLocalRef(cls); + + jlong stateHandle = ::installSandboxJSIBindings(runtime, env, rawDelegate); + + if (onInstalled) { + env->CallVoidMethod(rawDelegate, onInstalled, stateHandle); + } + }; +} + +} // namespace rnsandbox diff --git a/packages/react-native-sandbox/android/src/main/jni/SandboxBindingsInstaller.h b/packages/react-native-sandbox/android/src/main/jni/SandboxBindingsInstaller.h new file mode 100644 index 0000000..038d499 --- /dev/null +++ b/packages/react-native-sandbox/android/src/main/jni/SandboxBindingsInstaller.h @@ -0,0 +1,68 @@ +#pragma once + +#include +#include +#include + +// ABI-compatible forward declarations for facebook::react types. +// We cannot include the real headers (react/runtime/BindingsInstaller.h) +// because they transitively pull in folly-config.h which isn't in +// Android's prefab distribution. The layout below matches the real +// classes exactly: same virtual methods, same inheritance order, same +// Java descriptors for fbjni. + +namespace facebook::react { + +class ReactInstance { + public: + using BindingsInstallFunc = std::function; +}; + +class BindingsInstaller { + public: + virtual ReactInstance::BindingsInstallFunc getBindingsInstallFunc() { + return nullptr; + } +}; + +class JBindingsInstaller + : public facebook::jni::HybridClass, + public BindingsInstaller { + public: + static constexpr auto kJavaDescriptor = + "Lcom/facebook/react/runtime/BindingsInstaller;"; + ~JBindingsInstaller() {} + + private: + friend HybridBase; +}; + +} // namespace facebook::react + +namespace rnsandbox { + +class SandboxBindingsInstaller : public facebook::jni::HybridClass< + SandboxBindingsInstaller, + facebook::react::JBindingsInstaller> { + public: + static constexpr auto kJavaDescriptor = + "Lio/callstack/rnsandbox/SandboxBindingsInstaller;"; + + static facebook::jni::local_ref initHybrid( + facebook::jni::alias_ref, + facebook::jni::alias_ref delegateRef); + + static void registerNatives(); + + facebook::react::ReactInstance::BindingsInstallFunc getBindingsInstallFunc() + override; + + private: + friend HybridBase; + explicit SandboxBindingsInstaller( + facebook::jni::alias_ref delegateRef); + + facebook::jni::global_ref delegateRef_; +}; + +} // namespace rnsandbox diff --git a/packages/react-native-sandbox/android/src/main/jni/SandboxJSIInstaller.cpp b/packages/react-native-sandbox/android/src/main/jni/SandboxJSIInstaller.cpp new file mode 100644 index 0000000..cfd5202 --- /dev/null +++ b/packages/react-native-sandbox/android/src/main/jni/SandboxJSIInstaller.cpp @@ -0,0 +1,396 @@ +#include "SandboxBindingsInstaller.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define LOG_TAG "SandboxJSI" +#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +namespace jsi = facebook::jsi; + +static JavaVM* gJavaVM = nullptr; + +struct SandboxJSIState { + jsi::Runtime* runtime = nullptr; + std::shared_ptr onMessageCallback; + std::vector pendingMessages; + std::mutex mutex; +}; + +static std::mutex gRegistryMutex; +static std::unordered_map> gStates; + +static JNIEnv* getJNIEnv() { + JNIEnv* env = nullptr; + if (gJavaVM) { + gJavaVM->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6); + if (!env) { + gJavaVM->AttachCurrentThread(&env, nullptr); + } + } + return env; +} + +static std::string safeGetStringProperty( + jsi::Runtime& rt, + const jsi::Object& obj, + const char* key) { + if (!obj.hasProperty(rt, key)) + return ""; + jsi::Value value = obj.getProperty(rt, key); + return value.isString() ? value.getString(rt).utf8(rt) : ""; +} + +static void +stubJsiFunction(jsi::Runtime& runtime, jsi::Object& object, const char* name) { + object.setProperty( + runtime, + name, + jsi::Function::createFromHostFunction( + runtime, + jsi::PropNameID::forUtf8(runtime, name), + 1, + [](auto&, const auto&, const auto*, size_t) { + return jsi::Value::undefined(); + })); +} + +static void setupErrorHandler( + jsi::Runtime& runtime, + jobject globalDelegateRef) { + jsi::Object global = runtime.global(); + jsi::Value errorUtilsVal = global.getProperty(runtime, "ErrorUtils"); + if (!errorUtilsVal.isObject()) + return; + + jsi::Object errorUtils = errorUtilsVal.asObject(runtime); + + auto originalHandler = std::make_shared( + errorUtils.getProperty(runtime, "getGlobalHandler") + .asObject(runtime) + .asFunction(runtime) + .call(runtime)); + + auto handlerFunc = jsi::Function::createFromHostFunction( + runtime, + jsi::PropNameID::forAscii(runtime, "sandboxGlobalErrorHandler"), + 2, + [globalDelegateRef, originalHandler = std::move(originalHandler)]( + jsi::Runtime& rt, + const jsi::Value&, + const jsi::Value* args, + size_t count) -> jsi::Value { + if (count < 2) + return jsi::Value::undefined(); + + JNIEnv* jniEnv = getJNIEnv(); + if (!jniEnv) + return jsi::Value::undefined(); + + jclass cls = jniEnv->GetObjectClass(globalDelegateRef); + jfieldID hasHandlerField = + jniEnv->GetFieldID(cls, "hasOnErrorHandler", "Z"); + jboolean hasHandler = + jniEnv->GetBooleanField(globalDelegateRef, hasHandlerField); + + if (hasHandler) { + const jsi::Object& error = args[0].asObject(rt); + bool isFatal = args[1].getBool(); + std::string name = safeGetStringProperty(rt, error, "name"); + std::string message = safeGetStringProperty(rt, error, "message"); + std::string stack = safeGetStringProperty(rt, error, "stack"); + + jmethodID emitMethod = jniEnv->GetMethodID( + cls, + "emitOnErrorFromJS", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V"); + jstring jName = jniEnv->NewStringUTF(name.c_str()); + jstring jMsg = jniEnv->NewStringUTF(message.c_str()); + jstring jStack = jniEnv->NewStringUTF(stack.c_str()); + jniEnv->CallVoidMethod( + globalDelegateRef, + emitMethod, + jName, + jMsg, + jStack, + (jboolean)isFatal); + jniEnv->DeleteLocalRef(jName); + jniEnv->DeleteLocalRef(jMsg); + jniEnv->DeleteLocalRef(jStack); + } else if ( + originalHandler->isObject() && + originalHandler->asObject(rt).isFunction(rt)) { + originalHandler->asObject(rt).asFunction(rt).call(rt, args, count); + } + + jniEnv->DeleteLocalRef(cls); + return jsi::Value::undefined(); + }); + + jsi::Function setHandler = errorUtils.getProperty(runtime, "setGlobalHandler") + .asObject(runtime) + .asFunction(runtime); + setHandler.call(runtime, std::move(handlerFunc)); + stubJsiFunction(runtime, errorUtils, "setGlobalHandler"); +} + +// Shared JSI installation logic used by both the BindingsInstaller path +// (pre-bundle) and the legacy nativeInstall JNI path (post-bundle fallback). +jlong installSandboxJSIBindings( + jsi::Runtime& runtime, + JNIEnv* env, + jobject delegateRef) { + auto state = std::make_shared(); + state->runtime = &runtime; + + jlong stateHandle = reinterpret_cast(state.get()); + { + std::lock_guard lock(gRegistryMutex); + gStates[stateHandle] = state; + } + + jobject globalDelegateRef = env->NewGlobalRef(delegateRef); + + auto postMessageFn = jsi::Function::createFromHostFunction( + runtime, + jsi::PropNameID::forAscii(runtime, "postMessage"), + 2, + [globalDelegateRef]( + jsi::Runtime& rt, + const jsi::Value&, + const jsi::Value* args, + size_t count) -> jsi::Value { + if (count < 1 || count > 2) { + throw jsi::JSError( + rt, + "postMessage(message, targetOrigin?): expected 1 or 2 arguments"); + } + if (!args[0].isObject()) { + throw jsi::JSError( + rt, "postMessage: first argument must be an object"); + } + + jsi::Object jsonObj = rt.global().getPropertyAsObject(rt, "JSON"); + jsi::Function stringify = + jsonObj.getPropertyAsFunction(rt, "stringify"); + std::string messageJson = + stringify.call(rt, args[0]).getString(rt).utf8(rt); + + JNIEnv* jniEnv = getJNIEnv(); + if (!jniEnv) + return jsi::Value::undefined(); + + if (count == 2 && !args[1].isNull() && !args[1].isUndefined()) { + if (!args[1].isString()) { + throw jsi::JSError( + rt, "postMessage: targetOrigin must be a string"); + } + std::string targetOrigin = args[1].getString(rt).utf8(rt); + jclass cls = jniEnv->GetObjectClass(globalDelegateRef); + jmethodID mid = jniEnv->GetMethodID( + cls, + "routeMessageFromJS", + "(Ljava/lang/String;Ljava/lang/String;)Z"); + jstring jMsg = jniEnv->NewStringUTF(messageJson.c_str()); + jstring jTarget = jniEnv->NewStringUTF(targetOrigin.c_str()); + jniEnv->CallBooleanMethod(globalDelegateRef, mid, jMsg, jTarget); + jniEnv->DeleteLocalRef(jMsg); + jniEnv->DeleteLocalRef(jTarget); + jniEnv->DeleteLocalRef(cls); + } else { + jclass cls = jniEnv->GetObjectClass(globalDelegateRef); + jmethodID mid = jniEnv->GetMethodID( + cls, "emitOnMessageFromJS", "(Ljava/lang/String;)V"); + jstring jMsg = jniEnv->NewStringUTF(messageJson.c_str()); + jniEnv->CallVoidMethod(globalDelegateRef, mid, jMsg); + jniEnv->DeleteLocalRef(jMsg); + jniEnv->DeleteLocalRef(cls); + } + + return jsi::Value::undefined(); + }); + + auto setOnMessageFn = jsi::Function::createFromHostFunction( + runtime, + jsi::PropNameID::forAscii(runtime, "setOnMessage"), + 1, + [stateWeak = std::weak_ptr(state)]( + jsi::Runtime& rt, + const jsi::Value&, + const jsi::Value* args, + size_t count) -> jsi::Value { + if (count != 1) { + throw jsi::JSError(rt, "setOnMessage: expected exactly one argument"); + } + if (!args[0].isObject() || !args[0].asObject(rt).isFunction(rt)) { + throw jsi::JSError(rt, "setOnMessage: argument must be a function"); + } + + auto statePtr = stateWeak.lock(); + if (!statePtr) + return jsi::Value::undefined(); + + std::vector buffered; + { + std::lock_guard lock(statePtr->mutex); + statePtr->onMessageCallback.reset(); + statePtr->onMessageCallback = std::make_shared( + args[0].asObject(rt).asFunction(rt)); + buffered.swap(statePtr->pendingMessages); + } + + for (const auto& msg : buffered) { + try { + jsi::Value parsed = + rt.global() + .getPropertyAsObject(rt, "JSON") + .getPropertyAsFunction(rt, "parse") + .call(rt, jsi::String::createFromUtf8(rt, msg)); + statePtr->onMessageCallback->call(rt, std::move(parsed)); + } catch (const std::exception& e) { + LOGE("Error flushing buffered message: %s", e.what()); + } + } + if (!buffered.empty()) { + try { + rt.drainMicrotasks(); + } catch (...) { + } + } + + return jsi::Value::undefined(); + }); + + jsi::Function defineProperty = + runtime.global() + .getPropertyAsObject(runtime, "Object") + .getPropertyAsFunction(runtime, "defineProperty"); + + auto makePropDesc = [&](jsi::Function&& fn) { + jsi::Object desc(runtime); + desc.setProperty(runtime, "value", std::move(fn)); + desc.setProperty(runtime, "writable", false); + desc.setProperty(runtime, "enumerable", false); + desc.setProperty(runtime, "configurable", false); + return desc; + }; + + defineProperty.call( + runtime, + runtime.global(), + jsi::String::createFromAscii(runtime, "postMessage"), + makePropDesc(std::move(postMessageFn))); + + defineProperty.call( + runtime, + runtime.global(), + jsi::String::createFromAscii(runtime, "setOnMessage"), + makePropDesc(std::move(setOnMessageFn))); + + try { + setupErrorHandler(runtime, globalDelegateRef); + } catch (const std::exception& e) { + LOGW("Failed to setup error handler: %s", e.what()); + } + + return stateHandle; +} + +extern "C" { + +JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void*) { + gJavaVM = vm; + return facebook::jni::initialize( + vm, [] { rnsandbox::SandboxBindingsInstaller::registerNatives(); }); +} + +JNIEXPORT jlong JNICALL +Java_io_callstack_rnsandbox_SandboxJSIInstaller_nativeInstall( + JNIEnv* env, + jclass, + jlong runtimePtr, + jobject delegateRef) { + if (runtimePtr == 0) { + LOGE("nativeInstall called with null runtime pointer"); + return 0; + } + + auto* runtime = reinterpret_cast(runtimePtr); + return installSandboxJSIBindings(*runtime, env, delegateRef); +} + +JNIEXPORT void JNICALL +Java_io_callstack_rnsandbox_SandboxJSIInstaller_nativePostMessage( + JNIEnv* env, + jclass, + jlong stateHandle, + jstring message) { + std::shared_ptr state; + { + std::lock_guard lock(gRegistryMutex); + auto it = gStates.find(stateHandle); + if (it == gStates.end()) + return; + state = it->second; + } + + const char* msgChars = env->GetStringUTFChars(message, nullptr); + std::string messageStr(msgChars); + env->ReleaseStringUTFChars(message, msgChars); + + std::lock_guard lock(state->mutex); + if (!state->runtime) + return; + + if (!state->onMessageCallback) { + state->pendingMessages.push_back(std::move(messageStr)); + return; + } + + try { + jsi::Runtime& rt = *state->runtime; + jsi::Value parsed = + rt.global() + .getPropertyAsObject(rt, "JSON") + .getPropertyAsFunction(rt, "parse") + .call(rt, jsi::String::createFromUtf8(rt, messageStr)); + state->onMessageCallback->call(rt, std::move(parsed)); + + // runOnJSQueueThread does not drain the microtask queue, so React/Fabric + // never sees the state update. Drain explicitly to mirror what the + // RuntimeExecutor (and iOS's callFunctionOnBufferedRuntimeExecutor) does. + rt.drainMicrotasks(); + } catch (const jsi::JSError& e) { + LOGE("JSError in postMessage: %s", e.getMessage().c_str()); + } catch (const std::exception& e) { + LOGE("Exception in postMessage: %s", e.what()); + } +} + +JNIEXPORT void JNICALL +Java_io_callstack_rnsandbox_SandboxJSIInstaller_nativeDestroy( + JNIEnv*, + jclass, + jlong stateHandle) { + std::lock_guard lock(gRegistryMutex); + auto it = gStates.find(stateHandle); + if (it != gStates.end()) { + { + std::lock_guard stateLock(it->second->mutex); + it->second->onMessageCallback.reset(); + it->second->pendingMessages.clear(); + it->second->runtime = nullptr; + } + gStates.erase(it); + } +} + +} // extern "C" diff --git a/packages/react-native-sandbox/package.json b/packages/react-native-sandbox/package.json index 873032f..790896b 100644 --- a/packages/react-native-sandbox/package.json +++ b/packages/react-native-sandbox/package.json @@ -28,8 +28,12 @@ "React-Sandbox.podspec" ], "scripts": { - "lint": "clang-format --dry-run --Werror ios/*.{h,mm,cpp} tests/*.{h,cpp}", - "format": "clang-format -i ios/*.{h,mm,cpp} tests/*.{h,cpp}", + "lint": "npm run lint:clang && npm run lint:kotlin", + "lint:clang": "clang-format --dry-run --Werror ios/*.{h,mm,cpp} android/src/main/jni/*.{h,cpp} tests/*.{h,cpp}", + "lint:kotlin": "ktlint 'android/src/main/java/**/*.kt'", + "format": "npm run format:clang && npm run format:kotlin", + "format:clang": "clang-format -i ios/*.{h,mm,cpp} android/src/main/jni/*.{h,cpp} tests/*.{h,cpp}", + "format:kotlin": "ktlint -F 'android/src/main/java/**/*.kt'", "typecheck": "tsc --noEmit", "prepare": "bob build", "ctest": "cmake -S tests -B tests/build && cmake --build tests/build && ctest --test-dir tests/build --output-on-failure --verbose" diff --git a/packages/react-native-sandbox/react-native.config.js b/packages/react-native-sandbox/react-native.config.js new file mode 100644 index 0000000..ca94219 --- /dev/null +++ b/packages/react-native-sandbox/react-native.config.js @@ -0,0 +1,10 @@ +module.exports = { + dependency: { + platforms: { + android: { + componentDescriptors: ['SandboxReactNativeViewComponentDescriptor'], + cmakeListsPath: null, + }, + }, + }, +} diff --git a/packages/react-native-sandbox/specs/NativeSandboxReactNativeView.ts b/packages/react-native-sandbox/specs/NativeSandboxReactNativeView.ts index a701aed..ea6dac0 100644 --- a/packages/react-native-sandbox/specs/NativeSandboxReactNativeView.ts +++ b/packages/react-native-sandbox/specs/NativeSandboxReactNativeView.ts @@ -69,10 +69,10 @@ export interface NativeProps extends ViewProps { hasOnErrorHandler?: boolean /** Handler for messages sent from the sandbox */ - onMessage?: CodegenTypes.BubblingEventHandler + onMessage?: CodegenTypes.DirectEventHandler /** Handler for errors that occur in the sandbox */ - onError?: CodegenTypes.BubblingEventHandler + onError?: CodegenTypes.DirectEventHandler } export type NativeSandboxReactNativeViewComponentType = diff --git a/packages/react-native-sandbox/src/index.tsx b/packages/react-native-sandbox/src/index.tsx index a0d1058..5610381 100644 --- a/packages/react-native-sandbox/src/index.tsx +++ b/packages/react-native-sandbox/src/index.tsx @@ -40,9 +40,12 @@ const SANDBOX_TURBOMODULES_WHITELIST = [ 'ReactDevToolsRuntimeSettingsModule', 'NativeReactNativeFeatureFlagsCxx', 'NativeAnimatedTurboModule', + 'NativeAnimatedModule', 'KeyboardObserver', 'I18nManager', 'FrameRateLogger', + 'StatusBarManager', + 'FileReaderModule', ] /** From 3c1e9c28298bc60e84535f1f32ce9210f68c6879 Mon Sep 17 00:00:00 2001 From: Aliaksandr Babrykovich Date: Sun, 22 Feb 2026 21:07:14 +0100 Subject: [PATCH 2/4] chore: simplify friendship protocol, direct messaging, and fix stability issues --- .../src/main/java/com/p2pchat/MainActivity.kt | 17 +- apps/p2p-chat/src/App.tsx | 20 +- apps/p2p-chat/src/ChatApp.tsx | 53 ++--- apps/p2p-chat/src/components/ChatCarousel.tsx | 193 +++++++++++------- .../src/components/FriendRequestsList.tsx | 16 +- apps/p2p-chat/src/components/MessageInput.tsx | 31 ++- apps/p2p-chat/src/components/MessagesList.tsx | 13 +- .../src/components/TargetSelector.tsx | 41 ++-- apps/p2p-chat/src/constants.ts | 6 +- apps/p2p-chat/src/hooks/useChatInstances.ts | 4 - apps/p2p-chat/src/hooks/useCommunication.ts | 4 - apps/p2p-chat/src/hooks/useFriendRequests.ts | 88 +++++--- apps/p2p-chat/src/hooks/useMessages.ts | 25 ++- apps/p2p-chat/src/hooks/useTargetSelection.ts | 11 +- .../src/services/FriendshipManager.ts | 29 +-- apps/p2p-chat/src/services/MessageHandler.ts | 101 +++------ apps/p2p-chat/src/styles/carousel.ts | 29 +-- apps/p2p-chat/src/types/chat.ts | 7 +- apps/p2p-chat/src/types/friends.ts | 8 +- apps/p2p-chat/src/types/index.ts | 7 +- apps/p2p-chat/src/utils/chatHelpers.ts | 15 +- 21 files changed, 340 insertions(+), 378 deletions(-) diff --git a/apps/p2p-chat/android/app/src/main/java/com/p2pchat/MainActivity.kt b/apps/p2p-chat/android/app/src/main/java/com/p2pchat/MainActivity.kt index c7eff90..7ae14c7 100644 --- a/apps/p2p-chat/android/app/src/main/java/com/p2pchat/MainActivity.kt +++ b/apps/p2p-chat/android/app/src/main/java/com/p2pchat/MainActivity.kt @@ -1,5 +1,7 @@ package com.p2pchat +import android.os.Bundle +import androidx.core.view.WindowCompat import com.facebook.react.ReactActivity import com.facebook.react.ReactActivityDelegate import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled @@ -7,16 +9,13 @@ import com.facebook.react.defaults.DefaultReactActivityDelegate class MainActivity : ReactActivity() { - /** - * Returns the name of the main component registered from JavaScript. This is used to schedule - * rendering of the component. - */ - override fun getMainComponentName(): String = "P2PChat" + override fun getMainComponentName(): String = "p2p-chat" + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + WindowCompat.setDecorFitsSystemWindows(window, true) + } - /** - * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] - * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] - */ override fun createReactActivityDelegate(): ReactActivityDelegate = DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) } diff --git a/apps/p2p-chat/src/App.tsx b/apps/p2p-chat/src/App.tsx index 7bc1ae5..e547d81 100644 --- a/apps/p2p-chat/src/App.tsx +++ b/apps/p2p-chat/src/App.tsx @@ -21,18 +21,21 @@ const App = () => { } = useChatInstances() const sandboxRefs = useRef>({}) + const chatInstancesRef = useRef(chatInstances) + chatInstancesRef.current = chatInstances - const messageHandler = new MessageHandler( - chatInstances, - friendshipManager, - sandboxRefs, - triggerFriendshipUpdate - ) + const messageHandler = useRef( + new MessageHandler( + () => chatInstancesRef.current, + friendshipManager, + sandboxRefs, + triggerFriendshipUpdate + ) + ).current - // Use the scroll color interpolation hook const {currentBackgroundColor, onScroll} = useScrollColorInterpolation({ chatInstances, - scrollStep: screenWidth, // Now each slide takes full screen width + scrollStep: screenWidth, onIndexChange: setCurrentIndex, }) @@ -45,7 +48,6 @@ const App = () => { { - // Convert hex to RGB const hex = vibrantColor.replace('#', '') const r = parseInt(hex.substr(0, 2), 16) const g = parseInt(hex.substr(2, 2), 16) const b = parseInt(hex.substr(4, 2), 16) - // Create a very light version (90% white + 10% color) const lightR = Math.round(255 * 0.9 + r * 0.1) const lightG = Math.round(255 * 0.9 + g * 0.1) const lightB = Math.round(255 * 0.9 + b * 0.1) @@ -37,12 +34,23 @@ LogBox.ignoreAllLogs() const ChatApp: React.FC = ({ userId, userName, - targetOptions, - potentialFriends, - pendingRequests, + targetOptions: initialTargetOptions, + potentialFriends: initialPotentialFriends, backgroundColor, }) => { - const [, setIsConnected] = useState(false) + const { + pendingRequests, + targetOptions, + potentialFriends, + respondToFriendRequest, + handleFriendMessage, + sendFriendRequest, + } = useFriendRequests({ + userName, + initialTargetOptions, + initialPotentialFriends, + onSendMessage: (msg: MessageData) => sendMessage(msg), + }) const {sendMessage, sendConnectionMessage} = useCommunication({ userName, @@ -71,34 +79,18 @@ const ChatApp: React.FC = ({ } = useMessages({ userId, userName, - onSendMessage: (message: MessageData) => { - const success = sendMessage(message) - if (success) { - setIsConnected(true) - } - return success - }, + onSendMessage: (message: MessageData, targetOrigin?: string) => + sendMessage(message, targetOrigin), }) - const {respondToFriendRequest, handleFriendMessage, sendFriendRequest} = - useFriendRequests({ - userName, - onSendMessage: sendMessage, - }) - function handleIncomingMessage(data: MessageData) { console.log(`[${userName}] Processing message type: ${data.type}`) - handleMessageIncoming(data) handleFriendMessage(data) - if ( - data.type === 'chat_message' || - data.type === 'connection_established' - ) { - setIsConnected(true) - } } + const isSelectedTargetFriend = targetOptions.includes(selectedTarget) + const handleSendMessage = useCallback(() => { sendChatMessage(selectedTarget) }, [sendChatMessage, selectedTarget]) @@ -121,8 +113,6 @@ const ChatApp: React.FC = ({ return ( - - = ({ diff --git a/apps/p2p-chat/src/components/ChatCarousel.tsx b/apps/p2p-chat/src/components/ChatCarousel.tsx index 2c941fd..1719e0b 100644 --- a/apps/p2p-chat/src/components/ChatCarousel.tsx +++ b/apps/p2p-chat/src/components/ChatCarousel.tsx @@ -1,6 +1,7 @@ import SandboxReactNativeView from '@callstack/react-native-sandbox' -import React, {useCallback, useEffect, useRef} from 'react' +import React, {useCallback, useEffect, useMemo, useRef} from 'react' import { + Alert, Dimensions, NativeScrollEvent, NativeSyntheticEvent, @@ -15,9 +16,10 @@ import {FriendshipManager, MessageHandler} from '../services' import {carouselStyles} from '../styles' import {getChatHelpers} from '../utils/chatHelpers' -// Individual Chat Instance Component interface ChatInstanceViewProps { chat: ChatMeta + index: number + currentIndex: number friendshipTrigger: number friendshipManager: FriendshipManager messageHandler: MessageHandler @@ -26,85 +28,121 @@ interface ChatInstanceViewProps { onRemoveChatInstance: (chatId: string) => void } -const ChatInstanceView: React.FC = ({ - chat, - friendshipTrigger, - friendshipManager, - messageHandler, - sandboxRefs, - chatInstances, - onRemoveChatInstance, -}) => { - const { - getTargetOptions, - getPotentialFriends, - getPendingRequests, - getFriends, - } = getChatHelpers(chatInstances, friendshipManager) +const ChatInstanceView: React.FC = React.memo( + ({ + chat, + index, + currentIndex, + friendshipTrigger, + friendshipManager, + messageHandler, + sandboxRefs, + chatInstances, + onRemoveChatInstance, + }) => { + const isVisible = index === currentIndex + const {getTargetOptions, getPotentialFriends, getFriends} = getChatHelpers( + chatInstances, + friendshipManager + ) - // Get list of friend IDs for allowedOrigins - const allowedOrigins = getFriends(chat.id).map(friend => friend.id) + const allowedOrigins = getFriends(chat.id).map(friend => friend.id) - return ( - - - - - {chat.id} - + const initialProperties = useMemo( + () => ({ + userId: chat.id, + userName: chat.userName, + targetOptions: getTargetOptions(chat.id), + potentialFriends: getPotentialFriends(chat.id), + backgroundColor: chat.backgroundColor, + }), + // Only recompute when the chat identity changes, NOT on friendship updates. + // Friendship updates are delivered via postMessage to avoid full sandbox reload. + // eslint-disable-next-line react-hooks/exhaustive-deps + [chat.id, chat.userName, chat.backgroundColor] + ) - onRemoveChatInstance(chat.id)} - disabled={chatInstances.length <= 1}> - - × - - - + const onError = useMemo( + () => messageHandler.handleChatError(chat.userName), + [messageHandler, chat.userName] + ) - - { - if (ref) { - sandboxRefs.current[chat.id] = ref - console.log( - `[Host] Registered sandbox ref for ${chat.id}. Active refs:`, - Object.keys(sandboxRefs.current) - ) - } else { - console.log(`[Host] Removing sandbox ref for ${chat.id}`) - delete sandboxRefs.current[chat.id] - } - }} - origin={chat.id} - componentName="ChatApp" - initialProperties={{ - userId: chat.id, - userName: chat.userName, - targetOptions: getTargetOptions(chat.id), - potentialFriends: getPotentialFriends(chat.id), - pendingRequests: getPendingRequests(chat.id), - backgroundColor: chat.backgroundColor, - friendshipTrigger: friendshipTrigger, - }} - onError={messageHandler.handleChatError(chat.userName)} - onMessage={messageHandler.handleChatMessage(chat.id)} - allowedOrigins={allowedOrigins} - style={carouselStyles.sandbox} - /> + const onMessage = useMemo( + () => messageHandler.handleChatMessage(chat.id), + [messageHandler, chat.id] + ) + + const refCallback = useCallback( + (ref: any) => { + if (ref) { + sandboxRefs.current[chat.id] = ref + console.log( + `[Host] Registered sandbox ref for ${chat.id}. Active refs:`, + Object.keys(sandboxRefs.current) + ) + } else { + console.log(`[Host] Removing sandbox ref for ${chat.id}`) + delete sandboxRefs.current[chat.id] + } + }, + [sandboxRefs, chat.id] + ) + + const handleRemove = useCallback(() => { + Alert.alert('Remove Chat', `Remove ${chat.userName} from the chat?`, [ + {text: 'Cancel', style: 'cancel'}, + { + text: 'Remove', + style: 'destructive', + onPress: () => onRemoveChatInstance(chat.id), + }, + ]) + }, [onRemoveChatInstance, chat.id, chat.userName]) + + return ( + + + + + {chat.id} + + + {isVisible && ( + + + × + + + )} + + + + + - - ) -} + ) + } +) +ChatInstanceView.displayName = 'ChatInstanceView' -// Add Chat Component interface AddChatViewProps { chatInstances: ChatMeta[] onAddChatInstance: () => void @@ -171,14 +209,12 @@ export const ChatCarousel: React.FC = ({ }) => { const scrollViewRef = useRef(null) - // Function to scroll to specific chat const scrollToChat = useCallback( (targetChatId: string) => { const targetIndex = chatInstances.findIndex( chat => chat.id === targetChatId ) if (targetIndex !== -1 && scrollViewRef.current) { - // Each slide now takes full screen width for proper paging const {width: screenWidth} = Dimensions.get('window') const scrollX = targetIndex * screenWidth @@ -192,7 +228,6 @@ export const ChatCarousel: React.FC = ({ [chatInstances] ) - // Expose scroll function to messageHandler useEffect(() => { if ( messageHandler && @@ -215,10 +250,12 @@ export const ChatCarousel: React.FC = ({ decelerationRate="fast" bounces={false} style={carouselStyles.carousel}> - {chatInstances.map(chat => ( + {chatInstances.map((chat, idx) => ( = ({ {request.from} wants to be friends - = ({ ]} onPress={() => onRespondToRequest(request.id, 'accept')}> Accept - - + = ({ ]} onPress={() => onRespondToRequest(request.id, 'reject')}> Reject - + ))} @@ -74,16 +74,16 @@ const styles = StyleSheet.create({ ...commonStyles.row, justifyContent: 'space-between', backgroundColor: '#f0f0f0', - padding: spacing.sm + 2, // 10px + padding: spacing.sm + 2, ...commonStyles.rounded, marginBottom: spacing.sm, ...commonStyles.border, }, friendRequestText: { flex: 1, - fontSize: typography.sizes.md + 1, // 13px + fontSize: typography.sizes.md + 1, color: colors.text.primary, - marginRight: spacing.sm + 2, // 10px + marginRight: spacing.sm + 2, }, buttonContainer: { flexDirection: 'row', diff --git a/apps/p2p-chat/src/components/MessageInput.tsx b/apps/p2p-chat/src/components/MessageInput.tsx index 26b4646..ae1cc26 100644 --- a/apps/p2p-chat/src/components/MessageInput.tsx +++ b/apps/p2p-chat/src/components/MessageInput.tsx @@ -1,11 +1,12 @@ import React from 'react' -import {StyleSheet, Text, TextInput, TouchableOpacity, View} from 'react-native' +import {Pressable, StyleSheet, Text, TextInput, View} from 'react-native' import {buttonStyles, colors, spacing, typography} from '../styles/common' interface MessageInputProps { inputText: string selectedTarget: string + isFriend: boolean onInputChange: (text: string) => void onSendMessage: () => void } @@ -13,10 +14,11 @@ interface MessageInputProps { export const MessageInput: React.FC = ({ inputText, selectedTarget, + isFriend, onInputChange, onSendMessage, }) => { - const canSend = inputText.trim() && selectedTarget.trim() + const canSend = inputText.trim() && selectedTarget.trim() && isFriend const handleSubmit = () => { if (canSend) { @@ -24,23 +26,26 @@ export const MessageInput: React.FC = ({ } } + const placeholder = !selectedTarget + ? 'Select a user above...' + : !isFriend + ? `Add ${selectedTarget} as friend first` + : `Message ${selectedTarget}...` + return ( - = ({ onPress={handleSubmit} disabled={!canSend}> Send - + ) } @@ -75,10 +80,14 @@ const styles = StyleSheet.create({ backgroundColor: colors.surface, fontSize: typography.sizes.lg, }, + textInputDisabled: { + backgroundColor: '#f5f5f5', + opacity: 0.6, + }, sendButton: { borderRadius: 18, paddingHorizontal: spacing.lg, - paddingVertical: spacing.sm + 2, // 10px + paddingVertical: spacing.sm + 2, }, sendButtonText: { fontSize: typography.sizes.lg, diff --git a/apps/p2p-chat/src/components/MessagesList.tsx b/apps/p2p-chat/src/components/MessagesList.tsx index b1d26ab..3b4c915 100644 --- a/apps/p2p-chat/src/components/MessagesList.tsx +++ b/apps/p2p-chat/src/components/MessagesList.tsx @@ -16,7 +16,6 @@ export const MessagesList: React.FC = ({ const scrollRef = useRef(null) useEffect(() => { - // Auto-scroll to bottom when new messages arrive if (messages.length > 0) { setTimeout(() => { scrollRef.current?.scrollToEnd({animated: true}) @@ -96,14 +95,14 @@ const styles = StyleSheet.create({ padding: spacing.xl, }, emptyText: { - fontSize: typography.sizes.md + 1, // 13px + fontSize: typography.sizes.md + 1, color: colors.text.secondary, textAlign: 'center', fontStyle: 'italic', }, messageBubble: { marginVertical: 3, - padding: spacing.sm + 2, // 10px + padding: spacing.sm + 2, borderRadius: 14, maxWidth: '80%', }, @@ -118,9 +117,9 @@ const styles = StyleSheet.create({ }, errorMessage: { alignSelf: 'flex-start', - backgroundColor: '#ffebee', // Light red background + backgroundColor: '#ffebee', borderWidth: 1, - borderColor: colors.error, // Red border + borderColor: colors.error, }, errorIcon: { alignSelf: 'flex-start', @@ -143,7 +142,7 @@ const styles = StyleSheet.create({ color: colors.text.white, }, errorMessageText: { - color: '#d32f2f', // Darker red for error text + color: '#d32f2f', }, messageTime: { fontSize: typography.sizes.sm, @@ -154,6 +153,6 @@ const styles = StyleSheet.create({ color: 'rgba(255,255,255,0.8)', }, errorMessageTime: { - color: colors.error, // Red for error time + color: colors.error, }, }) diff --git a/apps/p2p-chat/src/components/TargetSelector.tsx b/apps/p2p-chat/src/components/TargetSelector.tsx index fa6783a..ba61572 100644 --- a/apps/p2p-chat/src/components/TargetSelector.tsx +++ b/apps/p2p-chat/src/components/TargetSelector.tsx @@ -1,11 +1,5 @@ import React from 'react' -import { - ScrollView, - StyleSheet, - Text, - TouchableOpacity, - View, -} from 'react-native' +import {Pressable, ScrollView, StyleSheet, Text, View} from 'react-native' import {colors, commonStyles, spacing, typography} from '../styles' import {PotentialFriend} from '../types' @@ -26,16 +20,14 @@ export const TargetSelector: React.FC = ({ onSendFriendRequest, }) => { const allPossibleTargets = [ - ...targetOptions, // Current friends - ...potentialFriends.map(pf => pf.id), // Potential friends + ...targetOptions, + ...potentialFriends.map(pf => pf.id), ] const handleTargetPress = (target: string) => { if (targetOptions.includes(target)) { - // This is a friend - select for messaging onTargetSelect(target) } else { - // This is a potential friend - send friend request onSendFriendRequest(target) } } @@ -50,18 +42,20 @@ export const TargetSelector: React.FC = ({ const isFriend = targetOptions.includes(target) const isSelected = selectedTarget === target return ( - handleTargetPress(target)}> {isFriend ? '👫' : '👥'} {target} @@ -69,7 +63,7 @@ export const TargetSelector: React.FC = ({ (tap to add) )} - + ) })} @@ -106,17 +100,24 @@ const styles = StyleSheet.create({ marginRight: spacing.sm, ...commonStyles.border, }, - selectedTargetButton: { - backgroundColor: colors.primary, - borderColor: colors.primary, + friendButton: { + backgroundColor: 'rgba(76, 175, 80, 0.12)', + borderColor: 'rgba(76, 175, 80, 0.4)', + }, + selectedFriendButton: { + backgroundColor: 'rgba(76, 175, 80, 0.25)', + borderColor: '#4caf50', }, targetButtonText: { fontSize: typography.sizes.md, fontWeight: typography.weights.medium, color: colors.text.primary, }, - selectedTargetButtonText: { - color: colors.text.white, + friendButtonText: { + color: '#2e7d32', + }, + selectedFriendButtonText: { + color: '#1b5e20', fontWeight: typography.weights.semibold, }, potentialFriendButton: { diff --git a/apps/p2p-chat/src/constants.ts b/apps/p2p-chat/src/constants.ts index 47a4035..b94b501 100644 --- a/apps/p2p-chat/src/constants.ts +++ b/apps/p2p-chat/src/constants.ts @@ -2,9 +2,8 @@ import {Dimensions} from 'react-native' const {width: screenWidth} = Dimensions.get('window') -// Carousel slide dimensions -export const CHAT_WIDTH = screenWidth - 32 // Account for container margins -export const SLIDE_MARGIN = 8 // Horizontal margin between slides +export const CHAT_WIDTH = screenWidth - 32 +export const SLIDE_MARGIN = 8 export const USER_THEMES = [ {name: 'Alice', color: '#667eea'}, @@ -16,7 +15,6 @@ export const USER_THEMES = [ ] as const export const MAX_CHAT_INSTANCES = USER_THEMES.length -export const MIN_CHAT_INSTANCES = 1 export interface ChatMeta { id: string diff --git a/apps/p2p-chat/src/hooks/useChatInstances.ts b/apps/p2p-chat/src/hooks/useChatInstances.ts index fecc0aa..c32b565 100644 --- a/apps/p2p-chat/src/hooks/useChatInstances.ts +++ b/apps/p2p-chat/src/hooks/useChatInstances.ts @@ -19,7 +19,6 @@ export const useChatInstances = () => { return } - // Find the next available preset user const usedNames = new Set(chatInstances.map(chat => chat.userName)) const availableUser = USER_THEMES.find(user => !usedNames.has(user.name)) @@ -46,9 +45,6 @@ export const useChatInstances = () => { setChatInstances(prev => prev.filter(chat => chat.id !== chatId)) console.log(`[Host] Removed chat instance: ${chatId}`) - - // Trigger friendship updates since instances changed - setFriendshipTrigger(prev => prev + 1) }, [chatInstances.length] ) diff --git a/apps/p2p-chat/src/hooks/useCommunication.ts b/apps/p2p-chat/src/hooks/useCommunication.ts index 93f0710..d4852ef 100644 --- a/apps/p2p-chat/src/hooks/useCommunication.ts +++ b/apps/p2p-chat/src/hooks/useCommunication.ts @@ -2,7 +2,6 @@ import {useCallback, useEffect} from 'react' import {MessageData} from '../types' -// Global function declarations for sandbox environment declare global { var setOnMessage: (callback: (data: any) => void) => void var postMessage: (message: any, targetOrigin?: string) => void @@ -22,7 +21,6 @@ export const useCommunication = ({ onConnectionEstablished, }: UseCommunicationProps) => { useEffect(() => { - // Set up message listener for P2P communication if (global.setOnMessage) { console.log( `[${userName}] global.setOnMessage is available, setting up listener` @@ -50,12 +48,10 @@ export const useCommunication = ({ ) } - // Send initial connection message if callback provided if (onConnectionEstablished) { onConnectionEstablished() } - // Announce readiness console.log( `[${userName}] 📢 Sandbox initialization complete and ready to receive messages` ) diff --git a/apps/p2p-chat/src/hooks/useFriendRequests.ts b/apps/p2p-chat/src/hooks/useFriendRequests.ts index ea03c80..5edc3d0 100644 --- a/apps/p2p-chat/src/hooks/useFriendRequests.ts +++ b/apps/p2p-chat/src/hooks/useFriendRequests.ts @@ -1,30 +1,43 @@ import {useCallback, useState} from 'react' -import {FriendAction, MessageData} from '../types' +import { + FriendAction, + FriendRequest, + MessageData, + PotentialFriend, +} from '../types' interface UseFriendRequestsProps { userName: string + initialTargetOptions: string[] + initialPotentialFriends: PotentialFriend[] onSendMessage: (message: MessageData) => boolean } export const useFriendRequests = ({ userName, + initialTargetOptions, + initialPotentialFriends, onSendMessage, }: UseFriendRequestsProps) => { - const [friendNotifications, setFriendNotifications] = useState([]) - - const clearNotification = useCallback((index: number) => { - setFriendNotifications(prev => prev.filter((_, i) => i !== index)) - }, []) + const [pendingRequests, setPendingRequests] = useState([]) + const [targetOptions, setTargetOptions] = + useState(initialTargetOptions) + const [potentialFriends, setPotentialFriends] = useState( + initialPotentialFriends + ) - const addNotification = useCallback((notification: string) => { - setFriendNotifications(prev => [...prev, notification]) + const addFriend = useCallback((friendId: string) => { + setTargetOptions(prev => + prev.includes(friendId) ? prev : [...prev, friendId] + ) + setPotentialFriends(prev => prev.filter(pf => pf.id !== friendId)) }, []) const sendFriendRequest = useCallback( (targetId: string) => { const success = onSendMessage({ - type: 'make_friend', + type: 'friend_request', target: targetId, timestamp: Date.now(), }) @@ -44,6 +57,8 @@ export const useFriendRequests = ({ `[${userName}] Responding to friend request ${requestId}: ${action}` ) + const request = pendingRequests.find(r => r.id === requestId) + const success = onSendMessage({ type: 'friend_response', requestId, @@ -52,54 +67,65 @@ export const useFriendRequests = ({ }) if (success) { - console.log(`[${userName}] Friend response sent to host`) + setPendingRequests(prev => prev.filter(r => r.id !== requestId)) + + if (action === 'accept' && request) { + addFriend(request.fromId) + console.log(`[${userName}] Now friends with ${request.from}`) + } } }, - [userName, onSendMessage] + [userName, onSendMessage, pendingRequests, addFriend] ) const handleFriendMessage = useCallback( (data: MessageData) => { switch (data.type) { case 'friend_request': { - if (data.fromName) { + if (data.requestId && data.from) { console.log( - `[${userName}] 🚨 PROCESSING FRIEND REQUEST from ${data.fromName} (${data.from})` + `[${userName}] Received friend request from ${data.fromName ?? data.from}` ) - addNotification(`Friend request from ${data.fromName}`) - console.log(`[${userName}] Friend request notification added`) + setPendingRequests(prev => { + if (prev.some(r => r.id === data.requestId)) return prev + return [ + ...prev, + { + id: data.requestId!, + fromId: data.from!, + from: data.fromName ?? data.from!, + to: userName, + timestamp: data.timestamp, + }, + ] + }) } break } - case 'friend_accepted': { - if (data.friendName) { + case 'friend_response': { + const friendId = data.friend + if (friendId && data.action === 'accept') { console.log( - `[${userName}] Friend request accepted by ${data.friendName}` + `[${userName}] Friend request accepted by ${data.friendName ?? friendId}` ) - addNotification(`${data.friendName} accepted your friend request!`) - } - break - } - - case 'friendship_established': { - if (data.friendName) { + addFriend(friendId) + } else if (data.action === 'reject') { console.log( - `[${userName}] Friendship established with ${data.friendName}` + `[${userName}] Friend request rejected by ${data.friendName ?? data.friend}` ) - addNotification(`You are now friends with ${data.friendName}!`) } break } } }, - [userName, addNotification] + [userName, addFriend] ) return { - friendNotifications, - clearNotification, - addNotification, + pendingRequests, + targetOptions, + potentialFriends, sendFriendRequest, respondToFriendRequest, handleFriendMessage, diff --git a/apps/p2p-chat/src/hooks/useMessages.ts b/apps/p2p-chat/src/hooks/useMessages.ts index 67a8338..cc85ceb 100644 --- a/apps/p2p-chat/src/hooks/useMessages.ts +++ b/apps/p2p-chat/src/hooks/useMessages.ts @@ -5,7 +5,7 @@ import {Message, MessageData} from '../types' interface UseMessagesProps { userId: string userName: string - onSendMessage: (message: MessageData) => boolean + onSendMessage: (message: MessageData, targetOrigin?: string) => boolean } export const useMessages = ({ @@ -27,7 +27,6 @@ export const useMessages = ({ const messageId = `${userId}_${Date.now()}` const timestamp = Date.now() - // Add to local messages const newMessage: Message = { id: messageId, text: inputText.trim(), @@ -38,19 +37,19 @@ export const useMessages = ({ addMessage(newMessage) - // Send P2P message to selected target sandbox via host - const success = onSendMessage({ - type: 'chat_message', - messageId, - text: inputText.trim(), - senderId: userId, - senderName: userName, - timestamp, - target: target.trim(), - }) + const success = onSendMessage( + { + type: 'chat_message', + messageId, + text: inputText.trim(), + senderId: userId, + senderName: userName, + timestamp, + }, + target.trim() + ) if (!success) { - // Add a local error message const errorMessage: Message = { id: `error_${Date.now()}`, text: `Failed to send message: Communication error`, diff --git a/apps/p2p-chat/src/hooks/useTargetSelection.ts b/apps/p2p-chat/src/hooks/useTargetSelection.ts index 4d3a8d7..dc379ba 100644 --- a/apps/p2p-chat/src/hooks/useTargetSelection.ts +++ b/apps/p2p-chat/src/hooks/useTargetSelection.ts @@ -13,29 +13,20 @@ export const useTargetSelection = ({ }: UseTargetSelectionProps) => { const [selectedTarget, setSelectedTarget] = useState('') - // Create a list of all possible targets (friends + potential friends) const allPossibleTargets = useMemo( - () => [ - ...targetOptions, // Current friends - ...potentialFriends.map(pf => pf.id), // Potential friends - ], + () => [...targetOptions, ...potentialFriends.map(pf => pf.id)], [targetOptions, potentialFriends] ) useEffect(() => { - // Update selected target if current one is no longer available if ( allPossibleTargets.length > 0 && !allPossibleTargets.includes(selectedTarget) ) { setSelectedTarget(allPossibleTargets[0]) - } else if (allPossibleTargets.length === 0 && selectedTarget) { - // Keep the selected target even if no options available - // This allows users to manually type or remember target IDs } }, [targetOptions, potentialFriends, selectedTarget, allPossibleTargets]) - // Initialize with first available target useEffect(() => { if (!selectedTarget && allPossibleTargets.length > 0) { setSelectedTarget(allPossibleTargets[0]) diff --git a/apps/p2p-chat/src/services/FriendshipManager.ts b/apps/p2p-chat/src/services/FriendshipManager.ts index d61e6a0..b1e0cab 100644 --- a/apps/p2p-chat/src/services/FriendshipManager.ts +++ b/apps/p2p-chat/src/services/FriendshipManager.ts @@ -1,8 +1,9 @@ -import {FriendRequest} from '../types' - export class FriendshipManager { - private friendships = new Set() // "alice-bob" format (sorted) - private pendingRequests = new Map() + private friendships = new Set() + private pendingRequests = new Map< + string, + {from: string; to: string; timestamp: number} + >() private requestCounter = 0 private createFriendshipKey(user1: string, user2: string): string { @@ -11,15 +12,7 @@ export class FriendshipManager { sendFriendRequest(from: string, to: string): string { const requestId = `req_${++this.requestCounter}_${Date.now()}` - const request: FriendRequest = { - id: requestId, - from, - to, - timestamp: Date.now(), - status: 'pending', - } - - this.pendingRequests.set(requestId, request) + this.pendingRequests.set(requestId, {from, to, timestamp: Date.now()}) console.log( `[FriendshipManager] Friend request sent: ${from} → ${to} (${requestId})` ) @@ -29,15 +22,13 @@ export class FriendshipManager { respondToRequest( requestId: string, action: 'accept' | 'reject' - ): FriendRequest | null { + ): {from: string; to: string} | null { const request = this.pendingRequests.get(requestId) if (!request) { console.warn(`[FriendshipManager] Request ${requestId} not found`) return null } - request.status = action === 'accept' ? 'accepted' : 'rejected' - if (action === 'accept') { const friendshipKey = this.createFriendshipKey(request.from, request.to) this.friendships.add(friendshipKey) @@ -60,12 +51,6 @@ export class FriendshipManager { return friends } - getPendingRequestsFor(userId: string): FriendRequest[] { - return Array.from(this.pendingRequests.values()).filter( - req => req.to === userId && req.status === 'pending' - ) - } - hasPendingRequestBetween(user1: string, user2: string): boolean { return Array.from(this.pendingRequests.values()).some( req => diff --git a/apps/p2p-chat/src/services/MessageHandler.ts b/apps/p2p-chat/src/services/MessageHandler.ts index cc9416a..dc1d9f1 100644 --- a/apps/p2p-chat/src/services/MessageHandler.ts +++ b/apps/p2p-chat/src/services/MessageHandler.ts @@ -5,7 +5,7 @@ export class MessageHandler { private scrollToChat?: (chatId: string) => void constructor( - private chatInstances: ChatMeta[], + private getChatInstances: () => ChatMeta[], private friendshipManager: FriendshipManager, private sandboxRefs: React.MutableRefObject>, private triggerFriendshipUpdate: () => void @@ -18,7 +18,6 @@ export class MessageHandler { handleChatError = (chatId: string) => (error: any) => { console.log(`[${chatId}] Error:`, error) - // Send error message to sandbox for display in chat history const errorMessage = { type: 'message_error', errorText: error.message || 'An error occurred', @@ -29,8 +28,10 @@ export class MessageHandler { this.sendToSandbox(chatId, errorMessage) } - handleChatMessage = (chatId: string) => (data: any) => { - console.log(`[${chatId}] Received message from sandbox:`, data) + handleChatMessage = (chatId: string) => (rawData: any) => { + console.log(`[${chatId}] Received message from sandbox:`, rawData) + + const data = typeof rawData === 'string' ? JSON.parse(rawData) : rawData if (!data.type) { console.warn(`[Host] Message from ${chatId} missing type:`, data) @@ -38,28 +39,27 @@ export class MessageHandler { } switch (data.type) { - case 'make_friend': - this.handleMakeFriend(chatId, data) + case 'friend_request': + this.handleFriendRequest(chatId, data) break case 'friend_response': this.handleFriendResponse(chatId, data) break - case 'chat_message': - this.handleForwardMessage(chatId, data) - break default: console.log(`[Host] Unknown message type from ${chatId}:`, data.type) } } - private handleMakeFriend(chatId: string, data: any) { + private handleFriendRequest(chatId: string, data: any) { const {target} = data if (!target) { - console.warn(`[Host] make_friend message from ${chatId} missing target`) + console.warn(`[Host] friend_request from ${chatId} missing target`) return } - const targetInstance = this.chatInstances.find(inst => inst.id === target) + const targetInstance = this.getChatInstances().find( + inst => inst.id === target + ) if (!targetInstance) { console.warn( `[Host] Target ${target} not found for friend request from ${chatId}` @@ -81,25 +81,22 @@ export class MessageHandler { const requestId = this.friendshipManager.sendFriendRequest(chatId, target) - // Automatically scroll to target chat to make it easier to accept the invite if (this.scrollToChat) { setTimeout(() => { this.scrollToChat!(target) - }, 100) // Small delay for UX + }, 100) } setTimeout(() => { - const hostMessage = { + this.sendToSandbox(target, { type: 'friend_request', from: chatId, fromName: - this.chatInstances.find(inst => inst.id === chatId)?.userName || + this.getChatInstances().find(inst => inst.id === chatId)?.userName || chatId, requestId, timestamp: Date.now(), - } - - this.sendToSandbox(target, hostMessage) + }) }, 500) } @@ -117,62 +114,16 @@ export class MessageHandler { this.triggerFriendshipUpdate() - if (action === 'accept') { - // Notify the original requester - const acceptMessage = { - type: 'friend_accepted', - friend: chatId, - friendName: - this.chatInstances.find(inst => inst.id === chatId)?.userName || - chatId, - timestamp: Date.now(), - } - this.sendToSandbox(request.from, acceptMessage) - - // Notify both parties about established friendship - const friendshipMessage = { - type: 'friendship_established', - friendName: '', - timestamp: Date.now(), - } - - this.sendToSandbox(request.from, { - ...friendshipMessage, - friendName: - this.chatInstances.find(inst => inst.id === chatId)?.userName || - chatId, - }) - - this.sendToSandbox(chatId, { - ...friendshipMessage, - friendName: - this.chatInstances.find(inst => inst.id === request.from)?.userName || - request.from, - }) - } - } - - private handleForwardMessage(senderId: string, data: any) { - const {target, messageId, text, senderName, timestamp} = data - - if (!target || !messageId || !text || !senderName) { - console.warn( - `[Host] chat_message from ${senderId} missing required fields` - ) - return - } - - const forwardedMessage = { - type: 'chat_message', - messageId, - text, - senderId, - senderName, - timestamp, - } - - // Message will be blocked at native level if not allowed - this.sendToSandbox(target, forwardedMessage) + const instances = this.getChatInstances() + this.sendToSandbox(request.from, { + type: 'friend_response', + action, + friend: chatId, + friendName: + instances.find(inst => inst.id === chatId)?.userName || chatId, + requestId, + timestamp: Date.now(), + }) } private sendToSandbox(targetId: string, message: any) { diff --git a/apps/p2p-chat/src/styles/carousel.ts b/apps/p2p-chat/src/styles/carousel.ts index 4c16c2f..5a359e2 100644 --- a/apps/p2p-chat/src/styles/carousel.ts +++ b/apps/p2p-chat/src/styles/carousel.ts @@ -20,14 +20,15 @@ export const carouselStyles = StyleSheet.create({ paddingHorizontal: SLIDE_MARGIN, }, chatSlide: { - width: screenWidth, // Full screen width for proper paging + width: screenWidth, flex: 1, - paddingHorizontal: SLIDE_MARGIN, // Use padding instead of margin - alignItems: 'center', // Center the content + paddingHorizontal: SLIDE_MARGIN, + alignItems: 'center', justifyContent: 'center', + overflow: 'hidden', }, chatContent: { - width: CHAT_WIDTH, // The actual chat content width + width: CHAT_WIDTH, flex: 1, backgroundColor: '#ffffff', borderRadius: 12, @@ -89,19 +90,19 @@ export const carouselStyles = StyleSheet.create({ }, deleteButtonText: { color: '#ffffff', - fontSize: 20, + fontSize: 16, fontWeight: 'bold', - margin: -10, + lineHeight: 18, }, deleteButtonDisabled: { opacity: 0.5, }, addChatCard: { - width: '45%', // Make it 45% of the slide width - height: '60%', // Make it 60% of the slide height + width: '45%', + height: '60%', backgroundColor: '#f8f9fa', borderRadius: 12, - padding: 20, // Reduced padding + padding: 20, alignItems: 'center', justifyContent: 'center', shadowColor: '#000', @@ -112,24 +113,24 @@ export const carouselStyles = StyleSheet.create({ borderWidth: 2, borderColor: '#dee2e6', borderStyle: 'dashed', - alignSelf: 'center', // Center horizontally in the slide + alignSelf: 'center', }, addChatContent: { alignItems: 'center', }, addChatIcon: { - fontSize: 30, // Reduced from 40 + fontSize: 30, color: '#6c757d', - marginBottom: 8, // Reduced margin + marginBottom: 8, }, addChatIconDisabled: { opacity: 0.5, }, addChatText: { - fontSize: 16, // Reduced from 18 + fontSize: 16, fontWeight: 'bold', color: '#6c757d', - marginBottom: 4, // Reduced margin + marginBottom: 4, }, addChatTextDisabled: { opacity: 0.5, diff --git a/apps/p2p-chat/src/types/chat.ts b/apps/p2p-chat/src/types/chat.ts index 5e817ae..fbb7222 100644 --- a/apps/p2p-chat/src/types/chat.ts +++ b/apps/p2p-chat/src/types/chat.ts @@ -11,11 +11,9 @@ export interface Message { export interface ChatAppProps { userId: string userName: string - targetOptions: string[] // Array of friend IDs who can receive messages - potentialFriends: {id: string; name: string}[] // Users who can be added as friends - pendingRequests: {id: string; from: string; to: string; timestamp: number}[] // Incoming friend requests + targetOptions: string[] + potentialFriends: {id: string; name: string}[] backgroundColor: string - friendshipTrigger?: number // Trigger prop to force re-renders } export interface MessageData { @@ -34,7 +32,6 @@ export interface MessageData { friendName?: string reason?: string errorText?: string - message?: string } export interface GlobalCommunication { diff --git a/apps/p2p-chat/src/types/friends.ts b/apps/p2p-chat/src/types/friends.ts index 9dbd71b..2e6e59c 100644 --- a/apps/p2p-chat/src/types/friends.ts +++ b/apps/p2p-chat/src/types/friends.ts @@ -1,9 +1,9 @@ export interface FriendRequest { id: string + fromId: string from: string to: string timestamp: number - status?: 'pending' | 'accepted' | 'rejected' } export interface PotentialFriend { @@ -12,9 +12,3 @@ export interface PotentialFriend { } export type FriendAction = 'accept' | 'reject' - -export interface FriendNotification { - id: string - text: string - timestamp: number -} diff --git a/apps/p2p-chat/src/types/index.ts b/apps/p2p-chat/src/types/index.ts index 0f8be6a..69d6c6d 100644 --- a/apps/p2p-chat/src/types/index.ts +++ b/apps/p2p-chat/src/types/index.ts @@ -4,9 +4,4 @@ export type { Message, MessageData, } from './chat' -export type { - FriendAction, - FriendNotification, - FriendRequest, - PotentialFriend, -} from './friends' +export type {FriendAction, FriendRequest, PotentialFriend} from './friends' diff --git a/apps/p2p-chat/src/utils/chatHelpers.ts b/apps/p2p-chat/src/utils/chatHelpers.ts index 07d4e13..e0eb06a 100644 --- a/apps/p2p-chat/src/utils/chatHelpers.ts +++ b/apps/p2p-chat/src/utils/chatHelpers.ts @@ -11,29 +11,24 @@ export const getChatHelpers = ( const getPotentialFriends = (chatId: string) => { return chatInstances - .filter(chat => chat.id !== chatId) // Exclude self - .filter(chat => !friendshipManager.areFriends(chatId, chat.id)) // Exclude existing friends + .filter(chat => chat.id !== chatId) + .filter(chat => !friendshipManager.areFriends(chatId, chat.id)) .filter( chat => !friendshipManager.hasPendingRequestBetween(chatId, chat.id) - ) // Exclude pending requests + ) .map(chat => ({id: chat.id, name: chat.userName})) } - const getPendingRequests = (chatId: string) => { - return friendshipManager.getPendingRequestsFor(chatId) - } - const getFriends = (chatId: string) => { return chatInstances - .filter(chat => chat.id !== chatId) // Exclude self - .filter(chat => friendshipManager.areFriends(chatId, chat.id)) // Only include friends + .filter(chat => chat.id !== chatId) + .filter(chat => friendshipManager.areFriends(chatId, chat.id)) .map(chat => ({id: chat.id, name: chat.userName})) } return { getTargetOptions, getPotentialFriends, - getPendingRequests, getFriends, } } From 20f7fc9d031f45910b7893fa43b967184e410053 Mon Sep 17 00:00:00 2001 From: Aliaksandr Babrykovich Date: Sun, 22 Feb 2026 22:49:34 +0100 Subject: [PATCH 3/4] fix: install Homebrew dependencies (ktlint) in CI lint job Co-authored-by: Cursor --- .github/workflows/check.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index d505a09..7c73b50 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -42,6 +42,9 @@ jobs: if: steps.asdf-cache.outputs.cache-hit != 'true' uses: asdf-vm/actions/install@v4 + - name: Install Homebrew dependencies + run: brew bundle + - name: Install dependencies run: bun install --frozen-lockfile From e846dca0834e9d218fcbbb9085981684012c3fec Mon Sep 17 00:00:00 2001 From: Aliaksandr Babrykovich Date: Sun, 22 Feb 2026 23:01:10 +0100 Subject: [PATCH 4/4] fix: install ktlint directly in CI lint job Co-authored-by: Cursor --- .github/workflows/check.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 7c73b50..c00fc95 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -42,8 +42,11 @@ jobs: if: steps.asdf-cache.outputs.cache-hit != 'true' uses: asdf-vm/actions/install@v4 - - name: Install Homebrew dependencies - run: brew bundle + - name: Install ktlint + run: | + curl -sSLO https://github.com/pinterest/ktlint/releases/latest/download/ktlint + chmod +x ktlint + sudo mv ktlint /usr/local/bin/ - name: Install dependencies run: bun install --frozen-lockfile