Skip to content

Commit

Permalink
feat: autolinking for Android with Gradle (#258)
Browse files Browse the repository at this point in the history
Provides the Android/Gradle functionality to go with #256 & #254

## CLI Changes

- Updated `react-native config` to include the android `sourceDir` for react native module packages

----

## Usage

These are the one-off changes required to enable this in the React Native init template (or for existing user projects).

### Modify `android/settings.gradle`

Include the following line in your Android projects `settings.gradle` file:

```groovy
apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings)
```

### Modify `android/app/build.gradle`

Add the following line at the bottom of your Android projects `android/app/build.gradle` file:

```groovy
apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)
```

### Modify `MainApplication.java`

Import the PackageList class;

```java
import com.facebook.react.PackageList;
```

Replace the `protected List<ReactPackage> getPackages()` method with the following:

```java
    @OverRide
    protected List<ReactPackage> getPackages() {
      @SuppressWarnings("UnnecessaryLocalVariable")
      List<ReactPackage> packages = new PackageList(this).getPackages();
      // additional non auto detected packages can still be added here:
      // packages.add(new SomeReactNativePackage());
      return packages;
    }
```


### Testing

I've tested the following scenarios locally:

- User has no React Native global CLI installed
  - Will warn but continue without issue
- User has no React Native packages installed yet
  - Continues as normal with no warning
- User has more than one React Native package installed
  - Correctly adds multiple packages without issue
- User has a package installed that does not support autodetection (config == null)
  - Prints a warning but continues gracefully
- User has custom/non-linked React Native packages
  - Can be sucessfully manually added via `packages.add(new SomeReactNativePackage());` as documented above in `MainApplication.java`

To test this in a new `react-native init` project locally:

- Clone the CLI project
- Run `yarn` in the CLI project
- Run `cd packages/cli && yarn && yarn link`  in the CLI project
  - (a little hacky as it's a mono-repo)
- Run `yarn link "@react-native-community/cli"` on your React Native project
- Make the required changes as above to your React Native project

----

### Codegen Output Example

An example of the generated `PackageList.java` class:

```java
package com.facebook.react;

import android.app.Application;
import android.content.Context;
import android.content.res.Resources;

import com.facebook.react.ReactPackage;
import com.facebook.react.shell.MainReactPackage;
import java.util.Arrays;
import java.util.List;

import com.test.BuildConfig;

// react-native-webview
import com.reactnativecommunity.webview.RNCWebViewPackage;

public class PackageList {
  private ReactNativeHost reactNativeHost;
  public PackageList(ReactNativeHost reactNativeHost) {
    this.reactNativeHost = reactNativeHost;
  }

  private ReactNativeHost getReactNativeHost() {
    return this.reactNativeHost;
  }

  private Resources getResources() {
    return this.getApplication().getResources();
  }

  private Application getApplication() {
    return this.reactNativeHost.getApplication();
  }

  private Context getApplicationContext() {
    return this.getApplication().getApplicationContext();
  }

  public List<ReactPackage> getPackages() {
    return Arrays.<ReactPackage>asList(
      new MainReactPackage(),
      new RNCWebViewPackage()
    );
  }
}
```
  • Loading branch information
Salakar authored and thymikee committed Apr 19, 2019
1 parent 6c575fd commit f1105bc
Show file tree
Hide file tree
Showing 3 changed files with 252 additions and 1 deletion.
Empty file.
250 changes: 250 additions & 0 deletions packages/platform-android/native_modules.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
import groovy.json.JsonSlurper
import org.gradle.initialization.DefaultSettings

def generatedFileName = "PackageList.java"
def generatedFileContentsTemplate = """
package com.facebook.react;
import android.app.Application;
import android.content.Context;
import android.content.res.Resources;
import com.facebook.react.ReactPackage;
import com.facebook.react.shell.MainReactPackage;
import java.util.Arrays;
import java.util.List;
{{ packageImports }}
public class PackageList {
private ReactNativeHost reactNativeHost;
public PackageList(ReactNativeHost reactNativeHost) {
this.reactNativeHost = reactNativeHost;
}
private ReactNativeHost getReactNativeHost() {
return this.reactNativeHost;
}
private Resources getResources() {
return this.getApplication().getResources();
}
private Application getApplication() {
return this.reactNativeHost.getApplication();
}
private Context getApplicationContext() {
return this.getApplication().getApplicationContext();
}
public List<ReactPackage> getPackages() {
return Arrays.<ReactPackage>asList(
new MainReactPackage(){{ packageClassInstances }}
);
}
}
"""

class ReactNativeModules {
private Logger logger
private Project project
private DefaultSettings defaultSettings
private ExtraPropertiesExtension extension
private ArrayList<HashMap<String, String>> reactNativeModules

private static String LOG_PREFIX = ":ReactNative:"
private static String REACT_NATIVE_CLI_BIN = "node_modules${File.separator}@react-native-community${File.separator}cli${File.separator}build${File.separator}index.js"
private static String REACT_NATIVE_CONFIG_CMD = "node ${REACT_NATIVE_CLI_BIN} config"

ReactNativeModules(Logger logger) {
this.logger = logger
}

void applySettingsGradle(DefaultSettings defaultSettings, ExtraPropertiesExtension extraPropertiesExtension) {
this.defaultSettings = defaultSettings
this.extension = extraPropertiesExtension
this.reactNativeModules = this.getReactNativeConfig()

addReactNativeModuleProjects()
}

void applyBuildGradle(Project project, ExtraPropertiesExtension extraPropertiesExtension) {
this.project = project
this.extension = extraPropertiesExtension
this.reactNativeModules = this.getReactNativeConfig()

addReactNativeModuleDependencies()
}

/**
* Include the react native modules android projects and specify their project directory
*/
void addReactNativeModuleProjects() {
reactNativeModules.forEach { reactNativeModule ->
String name = reactNativeModule["name"]
String androidSourceDir = reactNativeModule["androidSourceDir"]
defaultSettings.include(":${name}")
defaultSettings.project(":${name}").projectDir = new File("${androidSourceDir}")
}
}

/**
* Adds the react native modules as dependencies to the users `app` project
*/
void addReactNativeModuleDependencies() {
reactNativeModules.forEach { reactNativeModule ->
def name = reactNativeModule["name"]
project.dependencies {
// TODO(salakar): are other dependency scope methods such as `api` required?
implementation project(path: ":${name}")
}
}
}

/**
* This returns the users project root (e.g. where the node_modules dir is located).
*
* This defaults to up one directory from the root android directory unless the user has defined
* a `ext.reactNativeProjectRoot` extension property
*
* @return
*/
File getReactNativeProjectRoot() {
if (this.extension.has("reactNativeProjectRoot")) {
File rnRoot = File(this.extension.get("reactNativeProjectRoot"))
// allow custom React Native project roots for non-standard directory structures
this.logger.debug("${LOG_PREFIX}Using custom React Native project root path '${rnRoot.toString()}'")
return rnRoot
}

File androidRoot

if (this.project) {
androidRoot = this.project.rootProject.projectDir
} else {
androidRoot = this.defaultSettings.rootProject.projectDir
}

this.logger.debug("${LOG_PREFIX}Using default React Native project root path '${androidRoot.parentFile.toString()}'")
return androidRoot.parentFile
}

/**
* Code-gen a java file with all the detected ReactNativePackage instances automatically added
*
* @param outputDir
* @param generatedFileName
* @param generatedFileContentsTemplate
* @param applicationId
*/
void generatePackagesFile(File outputDir, String generatedFileName, String generatedFileContentsTemplate, String applicationId) {
ArrayList<HashMap<String, String>>[] packages = this.reactNativeModules

String packageImports = ""
String packageClassInstances = ""

if (packages.size() > 0) {
packageImports = "import ${applicationId}.BuildConfig;\n\n"
packageImports = packageImports + packages.collect {
"// ${it.name}\n${it.packageImportPath}"
}.join(';\n')
packageClassInstances = ",\n " + packages.collect { it.packageInstance }.join(',')
}

String generatedFileContents = generatedFileContentsTemplate
.replace("{{ packageImports }}", packageImports)
.replace("{{ packageClassInstances }}", packageClassInstances)

outputDir.mkdirs()
final FileTreeBuilder treeBuilder = new FileTreeBuilder(outputDir)
treeBuilder.file(generatedFileName).newWriter().withWriter { w ->
w << generatedFileContents
}
}

/**
* Runs a process to call the React Native CLI Config command and parses the output
*
* @return ArrayList < HashMap < String , String > >
*/
ArrayList<HashMap<String, String>> getReactNativeConfig() {
if (this.reactNativeModules != null) return this.reactNativeModules
ArrayList<HashMap<String, String>> reactNativeModules = new ArrayList<HashMap<String, String>>()

def cmdProcess

try {
cmdProcess = Runtime.getRuntime().exec(REACT_NATIVE_CONFIG_CMD, null, getReactNativeProjectRoot())
cmdProcess.waitFor()
} catch (Exception exception) {
this.logger.warn("${LOG_PREFIX}${exception.message}")
this.logger.warn("${LOG_PREFIX}Automatic import of native modules failed. (UNKNOWN)")
return reactNativeModules
}

def reactNativeConfigOutput = cmdProcess.in.text
def json = new JsonSlurper().parseText(reactNativeConfigOutput)
def dependencies = json["dependencies"]

dependencies.each { name, value ->
def platformsConfig = value["platforms"];
def androidConfig = platformsConfig["android"]

if (androidConfig != null && androidConfig["sourceDir"] != null) {
this.logger.info("${LOG_PREFIX}Automatically adding native module '${name}'")

HashMap reactNativeModuleConfig = new HashMap<String, String>()
reactNativeModuleConfig.put("name", name)
reactNativeModuleConfig.put("androidSourceDir", androidConfig["sourceDir"])
reactNativeModuleConfig.put("packageInstance", androidConfig["packageInstance"])
reactNativeModuleConfig.put("packageImportPath", androidConfig["packageImportPath"])
this.logger.trace("${LOG_PREFIX}'${name}': ${reactNativeModuleConfig.toMapString()}")

reactNativeModules.add(reactNativeModuleConfig)
} else {
this.logger.info("${LOG_PREFIX}Skipping native module '${name}'")
}
}

return reactNativeModules
}
}

/** -----------------------
* Exported Extensions
* ------------------------ */

def autoModules = new ReactNativeModules(logger)

ext.applyNativeModulesSettingsGradle = { DefaultSettings defaultSettings ->
autoModules.applySettingsGradle(defaultSettings, ext)
}

ext.applyNativeModulesAppBuildGradle = { Project project ->
autoModules.applyBuildGradle(project, ext)

def applicationId
def generatedSrcDir = new File(buildDir, "generated/rncli/src/main/java/com/facebook/react")

// TODO(salakar): not sure if this is the best way of getting the package name (used to import BuildConfig)
project.android.applicationVariants.all { variant ->
applicationId = [variant.mergedFlavor.applicationId, variant.buildType.applicationIdSuffix].findAll().join()
}

task generatePackageList << {
autoModules.generatePackagesFile(generatedSrcDir, generatedFileName, generatedFileContentsTemplate, applicationId)
}

preBuild.dependsOn generatePackageList

android {
sourceSets {
main {
java {
srcDirs += generatedSrcDir
}
}
}
}
}
3 changes: 2 additions & 1 deletion packages/platform-android/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"xmldoc": "^0.4.0"
},
"files": [
"build"
"build",
"native_modules.gradle"
]
}

0 comments on commit f1105bc

Please sign in to comment.