Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ Config plugin to auto-configure [`react-native-code-push`][lib] when the native

### Add the package to your npm dependencies

> Tested against Expo SDK 50
> Prebuild is tested against Expo SDK 50 and SDK 57. Android is covered on both;
> on iOS the plugin writes `Info.plist` but does not yet touch the Swift
> `AppDelegate` that SDK 52 and up generate.

```
yarn add react-native-code-push react-native-code-push-plugin
Expand Down
123 changes: 123 additions & 0 deletions src/android/__tests__/expo-templates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { applyImplementation } from "../buildscript-dependency";
import { applyMainApplication } from "../main-application-dependency";
import {
sdk49MainApplication,
sdk50AppBuildGradle,
sdk50MainApplication,
sdk57AppBuildGradle,
sdk57MainApplication,
} from "./templates";

describe("app/build.gradle", () => {
it("applies codepush.gradle on SDK 57, where no `apply from:` is left", () => {
expect(sdk57AppBuildGradle).not.toContain("apply from:");

const result = applyImplementation(sdk57AppBuildGradle);

expect(result).toContain("apply from:");
expect(result).toContain('"android/codepush.gradle"');
});

it("applies codepush.gradle last, after `android` and `react` are configured", () => {
for (const template of [sdk50AppBuildGradle, sdk57AppBuildGradle]) {
const result = applyImplementation(template);

expect(result.trimEnd().split("\n").at(-1)).toContain(
"android/codepush.gradle",
);
// codepush.gradle iterates `android.buildTypes` as it is applied.
expect(result.indexOf("codepush.gradle")).toBeGreaterThan(
result.indexOf("android {"),
);
}
});

it("leaves everything above it alone", () => {
const result = applyImplementation(sdk57AppBuildGradle);

expect(result.startsWith(sdk57AppBuildGradle.trimEnd())).toBe(true);
});

it("is idempotent on both templates", () => {
for (const template of [sdk50AppBuildGradle, sdk57AppBuildGradle]) {
const once = applyImplementation(template);

expect(applyImplementation(once)).toBe(once);
}
});
});

describe("MainApplication", () => {
it("passes the CodePush bundle to the SDK 57 ReactHost factory", () => {
const result = applyMainApplication(sdk57MainApplication, "kt");

expect(result).toContain("import com.microsoft.codepush.react.CodePush");
expect(result).toContain("jsBundleFilePath = CodePush.getJSBundleFile()");
// Kotlin evaluates arguments in order, and getJSBundleFile() throws until
// PackageList has constructed the CodePush instance.
expect(result.indexOf("jsBundleFilePath")).toBeGreaterThan(
result.indexOf("PackageList(this).packages"),
);
});

it("closes the SDK 57 factory call where it found it", () => {
const result = applyMainApplication(sdk57MainApplication, "kt");

// The commented-out `add(MyReactNativePackage())` sits between the opening
// parenthesis and the closing one, so a naive search finds the wrong `)`.
expect(
result.slice(
result.indexOf(" ExpoReactHostFactory"),
result.indexOf("\n }\n"),
),
).toBe(` ExpoReactHostFactory.getDefaultReactHost(
context = applicationContext,
packageList =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
},
jsBundleFilePath = CodePush.getJSBundleFile()
)`);
});

it("still overrides getJSBundleFile on the Kotlin ReactNativeHost template", () => {
const result = applyMainApplication(sdk50MainApplication, "kt");

expect(result).toContain("import com.microsoft.codepush.react.CodePush");
expect(result).toContain(
"override fun getJSBundleFile(): String? = CodePush.getJSBundleFile()",
);
expect(result).not.toContain("jsBundleFilePath");
});

it("still overrides getJSBundleFile on the Java template, semicolons included", () => {
const result = applyMainApplication(sdk49MainApplication, "java");

expect(result).toContain("import com.microsoft.codepush.react.CodePush;");
expect(result).toContain("return CodePush.getJSBundleFile();");
expect(result).toContain("@Override");
});

it("is idempotent on every template", () => {
const cases = [
[sdk57MainApplication, "kt"],
[sdk50MainApplication, "kt"],
[sdk49MainApplication, "java"],
] as const;

for (const [template, language] of cases) {
const once = applyMainApplication(template, language);

expect(applyMainApplication(once, language)).toBe(once);
}
});

it("says what it looked for when the template has neither hook", () => {
const unknown = `package com.example\n\nimport android.app.Application\n\nclass MainApplication : Application()\n`;

expect(() => applyMainApplication(unknown, "kt")).toThrow(
/ExpoReactHostFactory\.getDefaultReactHost/,
);
});
});
139 changes: 139 additions & 0 deletions src/android/__tests__/templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* Trimmed copies of what `npx expo prebuild` writes, kept verbatim around the
* parts the mods touch. Regenerate by prebuilding a blank app on the SDK named.
*/

/** Expo SDK 50, react-native 0.73. Autolinking is applied from the app module. */
export const sdk50AppBuildGradle = `apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"

react {
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
bundleCommand = "export:embed"
}

android {
namespace "com.anonymous.sdk50app"
}

dependencies {
implementation("com.facebook.react:react-android")
}

apply from: new File(["node", "--print", "require.resolve('@react-native-community/cli-platform-android/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim(), "../native_modules.gradle");
applyNativeModulesAppBuildGradle(project)
`;

/**
* Expo SDK 57, react-native 0.86. Autolinking moved into
* `autolinkLibrariesWithApp()`, so the file has no `apply from:` left.
*/
export const sdk57AppBuildGradle = `apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"

react {
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
bundleCommand = "export:embed"

/* Autolinking */
autolinkLibrariesWithApp()
}

android {
namespace "com.anonymous.sdk57app"
}

dependencies {
implementation("com.facebook.react:react-android")
}
`;

/** Expo SDK 50, Kotlin, `ReactNativeHost` wrapped by Expo. */
export const sdk50MainApplication = `package com.anonymous.sdk50app

import android.app.Application

import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactNativeHost
import com.facebook.react.defaults.DefaultReactNativeHost

import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ReactNativeHostWrapper

class MainApplication : Application(), ReactApplication {

override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
this,
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> {
return PackageList(this).packages
}

override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"

override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
}
)
}
`;

/** Expo SDK 57, Kotlin. No `ReactNativeHost`, no method left to override. */
export const sdk57MainApplication = `package com.anonymous.sdk57app

import android.app.Application

import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.ReactHost

import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ExpoReactHostFactory

class MainApplication : Application(), ReactApplication {

override val reactHost: ReactHost by lazy {
ExpoReactHostFactory.getDefaultReactHost(
context = applicationContext,
packageList =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
}
)
}

override fun onCreate() {
super.onCreate()
loadReactNative(this)
ApplicationLifecycleDispatcher.onApplicationCreate(this)
}
}
`;

/** Expo SDK 49, Java. */
export const sdk49MainApplication = `package com.anonymous.sdk49app;

import android.app.Application;

import com.facebook.react.ReactNativeHost;
import com.facebook.react.defaults.DefaultReactNativeHost;

import expo.modules.ApplicationLifecycleDispatcher;
import expo.modules.ReactNativeHostWrapper;

public class MainApplication extends Application implements ReactApplication {
private final ReactNativeHost mReactNativeHost = new ReactNativeHostWrapper(
this,
new DefaultReactNativeHost(this) {
@Override
public boolean getUseDeveloperSupport() {
return BuildConfig.DEBUG;
}
}
);
}
`;
83 changes: 32 additions & 51 deletions src/android/buildscript-dependency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,74 +3,55 @@ import type { ConfigPlugin } from "expo/config-plugins";

import { withAppBuildGradle } from "expo/config-plugins";

import { addBelowAnchorIfNotFound } from "../utils/add-below-anchor-if-not-found";
import { codePushGradlePath } from "../utils/code-push-gradle-path";

/**
* Appends `apply from: <codepush.gradle>` to the app module's build.gradle.
*
* This used to hunt for the `apply from: ... native_modules.gradle` line and
* insert below it. That line was never the point: it just happened to be the
* last statement in the RN 0.71-0.73 template. RN 0.75 folded autolinking into
* `autolinkLibrariesWithApp()` inside the `react { }` block, so by Expo SDK 56
* there is no `apply from:` in the file at all and the search threw.
*
* The end of the file is where the apply has to go regardless of template.
* `codepush.gradle` reads the `react` extension and iterates
* `android.buildTypes` while it is being applied, so it has to run after both
* blocks have been configured. Nothing in it depends on autolinking, so running
* after that is fine too.
*/
export function applyImplementation(appBuildGradle: string) {
const codePushImplementation = `apply from: ${codePushGradlePath(
"android/codepush.gradle",
)}`;

// Make sure the project does not have the dependency already, in any of the
// shapes we have generated over time.
// Every shape this plugin has ever written names the file, so one check
// covers projects prebuilt by an older version.
if (appBuildGradle.includes("codepush.gradle")) {
return appBuildGradle;
}

// The default on Expo 50
const reactNative73Include = `apply from: new File(["node", "--print", "require.resolve('@react-native-community/cli-platform-android/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim(), "../native_modules.gradle");`;
if (appBuildGradle.includes(reactNative73Include)) {
return addBelowAnchorIfNotFound(
appBuildGradle,
reactNative73Include,
codePushImplementation,
);
}

// Seems to be the default on Expo 49
const reactNative71Include = `apply from: new File(["node", "--print", "require.resolve('@react-native-community/cli-platform-android/package.json')"].execute(null, rootDir).text.trim(), "../native_modules.gradle");`;
if (appBuildGradle.includes(reactNative71Include)) {
return addBelowAnchorIfNotFound(
appBuildGradle,
reactNative71Include,
codePushImplementation,
);
}

// For compatibility
const reactNativeFileClassGradleInclude = `'apply from: new File(reactNativeRoot, "react.gradle")`;
if (appBuildGradle.includes(reactNativeFileClassGradleInclude)) {
return addBelowAnchorIfNotFound(
appBuildGradle,
reactNativeFileClassGradleInclude,
codePushImplementation,
);
}

// For compatibility
const reactNativeRawGradleInclude = `apply from: "../../node_modules/react-native/react.gradle"`;
if (appBuildGradle.includes(reactNativeRawGradleInclude)) {
return addBelowAnchorIfNotFound(
appBuildGradle,
reactNativeRawGradleInclude,
codePushImplementation,
);
}
const codePushImplementation = `apply from: ${codePushGradlePath(
"android/codepush.gradle",
)}`;

throw new Error(
"Cannot find a suitable place to insert the CodePush buildscript dependency.",
);
return `${appBuildGradle.trimEnd()}\n\n${codePushImplementation}\n`;
}

/**
* Update `<project>/build.gradle` by adding the codepush.gradle file
* as an additional build task definition underneath react.gradle
* Update `<project>/android/app/build.gradle` by applying the codepush.gradle
* file, which registers the bundle-hash tasks CodePush needs at build time.
* https://github.com/microsoft/react-native-code-push/blob/master/docs/setup-android.md#plugin-installation-and-configuration-for-react-nactive-060-version-and-above-android
*/
export const withAndroidBuildscriptDependency: ConfigPlugin<
PluginConfigType
> = (config) => {
return withAppBuildGradle(config, (buildGradleProps) => {
// `apply from:` is Groovy. Expo's template is Groovy, but a project that
// switched to the Kotlin DSL would get a build file that no longer parses,
// so say what happened instead of writing it.
if (buildGradleProps.modResults.language !== "groovy") {
throw new Error(
`Cannot apply codepush.gradle: android/app/build.gradle is ${buildGradleProps.modResults.language}, and this plugin only writes the Groovy syntax Expo's template uses.`,
);
}

buildGradleProps.modResults.contents = applyImplementation(
buildGradleProps.modResults.contents,
);
Expand Down
Loading
Loading