Add React Native to the Signal open source app — part 2 (Android)
Mariusz Stanisz•Dec 13, 2024•8 min readIn the first part of the article, we’ve covered the general JavaScript setup and running React Native on iOS. This time, we’re going to focus on Android!
Regarding brownfield integration, it’s difficult to say which platform is more challenging. Actually, almost everything depends on the number of non-standard solutions in the project that haven’t been covered in the React Native documentation. In the case of Signal, Android was a bit more complicated, but playing with buildscripts is something I really enjoy.
Android build
I’m sure you’ve heard of the Android build tool — Gradle. Many devs are pretty scared to even touch it, but it’s just code after all! In this tutorial, you’ll have a chance to learn some of it, because most of the code will be written in Gradle-related files.
But before I got to the build scripts, I had to adjust our Gradle setup and settings! In the beginning I needed to bump the Gradle version in the gradle-wrapper.properties file:
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=85719317abd2112f021d4f41f09ec370534ba288432065f4b477b6a3b652910d
- distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-all.zip
+ distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Next, I moved on to gradle.properties and added a couple of flags used by React Native.
org.gradle.jvmargs=-Xmx6g -Xms256m -XX:MaxMetaspaceSize=1g
android.useAndroidX=true
android.experimental.androidTest.numManagedDeviceShards=4
# Uncomment these to build libsignal from source.
# libsignalClientPath=../libsignal
+ org.gradle.dependency.verification=lenient
+ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
+ newArchEnabled=true
+ hermesEnabled=true
The org.gradle.dependency.verification=lenitent part is something that you may not see in the documentation. Because of this line, Gradle skips dependencies verification, but in order to build the app locally it had to be added.
We’re getting closer to the buildscripts! There was one more change that had to be done in constants.gradle.kts. I had to bump minSdkVersion there:
val signalBuildToolsVersion by extra("34.0.0")
val signalCompileSdkVersion by extra("android-34")
val signalTargetSdkVersion by extra(34)
- val signalMinSdkVersion by extra(21)
+ val signalMinSdkVersion by extra(24)
val signalNdkVersion by extra("27.0.12077973")
val signalJavaVersion by extra(JavaVersion.VERSION_17)
val signalKotlinJvmTarget by extra("17")
At this point, we can start manipulating the settings.gradle.kts file. This part of the brownfield integration was a bit more challenging. In theory, it’s all about including and configuring the Gradle plugin for React Native in less than 10 lines of code. Unfortunately, I had to take an extra step and move almost all repositiories to the build.gradle in order to make the app to compile correctly.
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
includeBuild("build-logic")
+ includeBuild("../node_modules/@react-native/gradle-plugin")
}
dependencyResolutionManagement {
- repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
- google()
- mavenCentral()
- mavenLocal()
- maven {
- url = uri("https://raw.githubusercontent.com/signalapp/maven/master/sqlcipher/release/")
- content {
- includeGroupByRegex("org\\.signal.*")
- }
- }
maven {
url = uri("https://dl.cloudsmith.io/qxAgwaeEE1vN8aLU/mobilecoin/mobilecoin/maven/")
}
- jcenter {
- content {
- includeVersion("mobi.upod", "time-duration-picker", "1.1.3")
- }
- }
}
}
+ plugins {
+ id("com.facebook.react.settings")
+ }
// To build libsignal from source, set the libsignalClientPath property in gradle.properties.
val libsignalClientPath = if (extra.has("libsignalClientPath")) extra.get("libsignalClientPath") else null;
if (libsignalClientPath is String) {
includeBuild(rootDir.resolve(libsignalClientPath + "/java")) {
name = "libsignal-client"
dependencySubstitution {
substitute(module("org.signal:libsignal-client")).using(project(":client"))
substitute(module("org.signal:libsignal-android")).using(project(":android"))
}
}
}
+ extensions.configure<com.facebook.react.ReactSettingsExtension> { autolinkLibrariesFromCommand() }
+ includeBuild("../node_modules/@react-native/gradle-plugin")
The removed lines landed in the so-called top-level build.gradle. In this file, I also had to add the react-native-gradle-plugin dependency.
classpath("androidx.benchmark:benchmark-gradle-plugin:1.1.0-beta04")
classpath(files("$rootDir/wire-handler/wire-handler-1.0.0.jar"))
classpath("com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin:1.9.20-1.0.14")
+ classpath("com.facebook.react:react-native-gradle-plugin")
}
}
tasks.withType<Wrapper> {
distributionType = Wrapper.DistributionType.ALL
}
apply(from = "${rootDir}/constants.gradle.kts")
subprojects {
if (JavaVersion.current().isJava8Compatible) {
allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ maven {
+ url = uri("https://plugins.gradle.org/m2/")
+ content {
+ includeGroupByRegex("org\\.jlleitschuh\\.gradle.*")
+ }
+ }
+ mavenLocal()
+ maven {
+ url = uri("https://raw.githubusercontent.com/signalapp/maven/master/sqlcipher/release/")
+ }
+ maven {
+ url = uri("https://dl.cloudsmith.io/qxAgwaeEE1vN8aLU/mobilecoin/mobilecoin/maven/")
+ }
+ jcenter {
+ content {
+ includeVersion("mobi.upod", "time-duration-picker", "1.1.3")
+ }
+ }
+ }
And that’s how we’ve reached the last build file that I had to modify. It’s the app/build.gradle. It may be confusing, but it differs from the top-level one, which shares build configuration across all projects. On android, however, app is just a project that has to be included into the build. In our brownfield case, it’s the place where we add React Native and Hermes. Additionally we setup autolinking and integrate a React plugin here.
plugins {
id("com.android.application")
id("kotlin-android")
id("androidx.navigation.safeargs")
id("org.jlleitschuh.gradle.ktlint")
id("org.jetbrains.kotlin.android")
id("app.cash.exhaustive")
id("kotlin-parcelize")
id("com.squareup.wire")
id("translations")
id("licenses")
+ id("com.facebook.react")
}
apply(from = "static-ips.gradle.kts")
val canonicalVersionCode = 1482
val canonicalVersionName = "7.24.0"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
+ react {
+ autolinkLibrariesWithApp()
+ }
...
dependencies {
+ // React Native dependencies
+ implementation("com.facebook.react:react-android")
+ implementation("com.facebook.react:hermes-android")
And that’s it for the build! At this point, our brownfield Signal app should compile just fine. Now we can jump into the Kotlin code to open a React Native screen!
Wiring
There are two ways to implement React Native in an Android application. The default is to do it via a dedicated Activity. The other alternative is to use an Android Fragment — which in some cases may be a way to go.
As a rule of thumb, we can generalize that the bigger chunk of the app React Native will be, the higher the chance the Activity would be your choice. Nevertheless, there are many variables that you can take into consideration before making your final decision. The questions you may have to answer are for example:
- How is your app currently structured?
- Do you use multiple Activities?
- What React Native is going to do in your brownfield setup?
- How much of the app React Native code is going to take?
In this tutorial, we’re going to integrate a ReactNativeActivity. In fact, the Kotlin file with the boilerplate is going to have only 13 lines of code:
+ package org.thoughtcrime.securesms;
+
+ import com.facebook.react.ReactActivity
+ import com.facebook.react.ReactActivityDelegate
+ import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
+ import com.facebook.react.defaults.DefaultReactActivityDelegate
+
+ class ReactNativeActivity : ReactActivity() {
+ override fun getMainComponentName(): String = "HelloBrownfield"
+
+ override fun createReactActivityDelegate(): ReactActivityDelegate =
+ DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
+ }
However, when you add a new Activity to an Android project, you also have to declare it in the AndroidManifest.xml file. Apart from that I needed to add two props to the applicationtag to enable connection to the Metro bundler, and apply the correct targetApi version:
<application android:name=".ApplicationContext"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
+ android:usesCleartextTraffic="true"
+ tools:targetApi="28"
android:supportsRtl="true"
android:resizeableActivity="true"
android:fullBackupOnly="false"
android:allowBackup="true"
android:backupAgent=".absbackup.SignalBackupAgent"
android:theme="@style/TextSecure.LightTheme"
android:largeHeap="true">
+ <activity
+ android:name=".ReactNativeActivity"
+ android:theme="@style/Theme.AppCompat.Light.NoActionBar">
+ </activity>
Next, I could move on to the ApplicationContext.java file, which had to be modified a bit more. That’s the place, from where we’re going to set up the ReactNativeHost. It contains information like initial properties passed to the React Native application, and packages with native code that should be included. In order to do that I had to override two methods in order to satisfy the ReactApplication interface.
+ import java.io.IOException;
+ import java.util.List;
+
+ import com.facebook.react.PackageList;
+ import com.facebook.react.ReactApplication;
+ import com.facebook.react.ReactHost;
+ import com.facebook.react.ReactNativeHost;
+ import com.facebook.react.ReactPackage;
+ import com.facebook.react.defaults.DefaultReactHost;
+ import com.facebook.react.defaults.DefaultReactNativeHost;
+ import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
+ import com.facebook.soloader.SoLoader;
+ import com.facebook.soloader.ExternalSoMapping;
+ import com.facebook.react.soloader.*;
- public class ApplicationContext extends Application implements AppForegroundObserver.Listener {
+ public class ApplicationContext extends Application implements AppForegroundObserver.Listener, ReactApplication {
private static final String TAG = Log.tag(ApplicationContext.class);
public static ApplicationContext getInstance(Context context) {
return (ApplicationContext)context.getApplicationContext();
}
+ @NonNull @Override
+ public ReactNativeHost getReactNativeHost() {
+ return new DefaultReactNativeHost(this) {
+ @Override
+ protected List<ReactPackage> getPackages() { return new PackageList(this).getPackages(); }
+ @Override
+ protected String getJSMainModuleName() { return "index"; }
+ @Override
+ public boolean getUseDeveloperSupport() { return BuildConfig.DEBUG; }
+ @Override
+ protected boolean isNewArchEnabled() { return BuildConfig.IS_NEW_ARCHITECTURE_ENABLED; }
+ @Override
+ protected Boolean isHermesEnabled() { return BuildConfig.IS_HERMES_ENABLED; }
+ };
+ }
+ @Override
+ public ReactHost getReactHost() {
+ return DefaultReactHost.getDefaultReactHost(getApplicationContext(), getReactNativeHost());
+ }
@Override
public void onCreate() {
Tracer.getInstance().start("Application#onCreate()");
AppStartup.getInstance().onApplicationCreate();
SignalLocalMetrics.ColdStart.start();
long startTime = System.currentTimeMillis();
super.onCreate();
+ try {
+ SoLoader.init(this, OpenSourceMergedSoMapping.INSTANCE);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
+ DefaultNewArchitectureEntryPoint.load();
+ }
Final touches
The only thing that is missing now, is to find a place to open the ReactNativeActivity! I decided to do it in the ConversationListFragment.java file, where I could get access to the FAB button on the home screen. The implementation is very straightforward. I just needed to modify one line there:
getResources().getColor(R.color.conversation_list_archive_background_end))).attachToRecyclerView(list);
- fab.setOnClickListener(v -> startActivity(new Intent(getActivity(), NewConversationActivity.class)));
+ fab.setOnClickListener(v -> startActivity(new Intent(getActivity(), ReactNativeActivity.class)));
cameraFab.setOnClickListener(v -> {
if (CameraXUtil.isSupported()) {
Additionally, I also added a React logo to the resources, so I could change the look of the button. But that’s it! At this moment I had a fully working brownfield solution, and I was ready to implement new screens as in a regular React Native application!
Additional resources:
- Article in the React Native documentation
- Source code on GitHub
- Add React Native to the Signal open source app — part 1 (iOS)
- My talk about Brownfield at App.js Conf 2024
We’re Software Mansion: React Native core contributors, New architecture experts, community builders, multimedia experts, and software development consultants. Do you need help with building your brownfield app? Hire us: [email protected].
