From ce0147dc8e50c822dde37ce488bfa55f0e0901d4 Mon Sep 17 00:00:00 2001 From: Gabriel Taveira Date: Mon, 27 Jul 2026 20:20:01 -0300 Subject: [PATCH 1/3] fix(android): apply codepush.gradle without an `apply from:` anchor `applyImplementation` searched app/build.gradle for one of four `apply from:` lines and threw when it found none. React Native 0.75 folded autolinking into `autolinkLibrariesWithApp()` inside the `react { }` block, so by Expo SDK 56 the file has no `apply from:` left and prebuild died with "Cannot find a suitable place to insert the CodePush buildscript dependency". Those anchors were never the requirement. `codepush.gradle` reads the `react` extension and iterates `android.buildTypes` as it is applied, so it has to run after both blocks are configured, and the end of the file is where that holds on every template. It also happens to be where the old anchor sat in RN 0.71-0.73, so SDK 49 and 50 land in the same place they always did. Closes #26 Claude-Session: https://claude.ai/code/session_01QsVEsfkynXpJWK4t33exG9 --- src/android/__tests__/templates.ts | 139 ++++++++++++++++++++++++++ src/android/buildscript-dependency.ts | 83 ++++++--------- 2 files changed, 171 insertions(+), 51 deletions(-) create mode 100644 src/android/__tests__/templates.ts diff --git a/src/android/__tests__/templates.ts b/src/android/__tests__/templates.ts new file mode 100644 index 0000000..d114505 --- /dev/null +++ b/src/android/__tests__/templates.ts @@ -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 { + 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; + } + } + ); +} +`; diff --git a/src/android/buildscript-dependency.ts b/src/android/buildscript-dependency.ts index f60ae6b..64bf8df 100644 --- a/src/android/buildscript-dependency.ts +++ b/src/android/buildscript-dependency.ts @@ -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: ` 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 `/build.gradle` by adding the codepush.gradle file - * as an additional build task definition underneath react.gradle + * Update `/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, ); From 2d764b53f46a4b421db38002ae6bc32c63ecabf2 Mon Sep 17 00:00:00 2001 From: Gabriel Taveira Date: Mon, 27 Jul 2026 20:20:12 -0300 Subject: [PATCH 2/3] fix(android): hook CodePush into MainApplication's ReactHost Expo SDK 56 dropped `ReactNativeHost` from the template, so there is no `getJSBundleFile()` left to override. `MainApplication` now builds a `ReactHost` through `ExpoReactHostFactory.getDefaultReactHost`, which takes `jsBundleFilePath` as an argument instead. The mod passes `CodePush.getJSBundleFile()` there. It has to be the last argument: Kotlin evaluates arguments in source order, and that call throws until `PackageList(this).packages` has constructed the CodePush instance. Finding the closing parenthesis means matching it rather than searching for one, since the template's argument list contains a commented-out `add(MyReactNativePackage())`. The import anchor moves with it. It used to be `import expo.modules.ReactNativeHostWrapper`, which SDK 56 also stopped emitting, and is now the end of the import block. The `ReactNativeHost` paths stay for SDK 49 through 55, minus the `getJSMainModuleName` anchor that only ever matched SDK 50's exact string. Claude-Session: https://claude.ai/code/session_01QsVEsfkynXpJWK4t33exG9 --- src/android/__tests__/expo-templates.test.ts | 123 ++++++++++++++ src/android/main-application-dependency.ts | 150 ++++++++---------- src/utils/__tests__/add-import.test.ts | 62 ++++++++ .../__tests__/insert-last-argument.test.ts | 75 +++++++++ src/utils/add-import.ts | 53 +++++++ src/utils/insert-last-argument.ts | 141 ++++++++++++++++ 6 files changed, 522 insertions(+), 82 deletions(-) create mode 100644 src/android/__tests__/expo-templates.test.ts create mode 100644 src/utils/__tests__/add-import.test.ts create mode 100644 src/utils/__tests__/insert-last-argument.test.ts create mode 100644 src/utils/add-import.ts create mode 100644 src/utils/insert-last-argument.ts diff --git a/src/android/__tests__/expo-templates.test.ts b/src/android/__tests__/expo-templates.test.ts new file mode 100644 index 0000000..5c4cf0e --- /dev/null +++ b/src/android/__tests__/expo-templates.test.ts @@ -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/, + ); + }); +}); diff --git a/src/android/main-application-dependency.ts b/src/android/main-application-dependency.ts index c870440..b774c8c 100644 --- a/src/android/main-application-dependency.ts +++ b/src/android/main-application-dependency.ts @@ -4,101 +4,87 @@ import type { ConfigPlugin } from "expo/config-plugins"; import { withMainApplication } from "expo/config-plugins"; import { addBelowAnchorIfNotFound } from "../utils/add-below-anchor-if-not-found"; +import { addImport } from "../utils/add-import"; +import { insertLastArgument } from "../utils/insert-last-argument"; + +type Language = "java" | "kt"; + +const CODE_PUSH_CLASS = "com.microsoft.codepush.react.CodePush"; + +/** Once this reads back, the file has already been through here. */ +const CODE_PUSH_BUNDLE_CALL = "CodePush.getJSBundleFile()"; /** - * Updates the `MainApplication.java` by adding the CodePush runtime initialization code - * https://github.com/microsoft/react-native-code-push/blob/master/docs/setup-android.md#plugin-installation-and-configuration-for-react-native-060-version-and-above-android + * Expo SDK 56 dropped `ReactNativeHost` from the template. `MainApplication` + * now builds a `ReactHost` through this factory, which takes the bundle path as + * an argument instead of exposing a method to override. */ -export const withAndroidMainApplicationDependency: ConfigPlugin< - PluginConfigType -> = (config) => { - return withMainApplication(config, (mainApplicationProps) => { - // Import the plugin class. - const hostWrapperClass = "import expo.modules.ReactNativeHostWrapper"; - const codePushClass = "import com.microsoft.codepush.react.CodePush"; - - // Expo 49 uses Java and requires the ; - if ( - mainApplicationProps.modResults.contents.includes(`${hostWrapperClass};`) - ) { - mainApplicationProps.modResults.contents = addBelowAnchorIfNotFound( - mainApplicationProps.modResults.contents, - `${hostWrapperClass};`, - `${codePushClass};`, - ); - } +const REACT_HOST_FACTORY = "ExpoReactHostFactory.getDefaultReactHost"; - // Expo 50 uses Kotlin and does not require the ; - else if ( - mainApplicationProps.modResults.contents.includes(hostWrapperClass) - ) { - mainApplicationProps.modResults.contents = addBelowAnchorIfNotFound( - mainApplicationProps.modResults.contents, - hostWrapperClass, - codePushClass, - ); - } +/** + * Points the React runtime at the bundle CodePush picked: the update it + * downloaded, or the one baked into the APK when there is nothing newer. + * https://github.com/microsoft/react-native-code-push/blob/master/docs/setup-android.md#plugin-installation-and-configuration-for-react-native-060-version-and-above-android + */ +export function applyMainApplication(contents: string, language: Language) { + if (contents.includes(CODE_PUSH_BUNDLE_CALL)) return contents; - /** - * Override the getJSBundleFile method in order to let - * the CodePush runtime determine where to get the JS - * bundle location from on each app start - */ + const withImport = addImport(contents, CODE_PUSH_CLASS, language); - // The default on Expo 50, which uses kotlin - const kotlinAnchor = `override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"`; - if (mainApplicationProps.modResults.contents.includes(kotlinAnchor)) { - const kotlinJSBundleFileOverride = ` - override fun getJSBundleFile(): String? { - return CodePush.getJSBundleFile() - } - `; - mainApplicationProps.modResults.contents = addBelowAnchorIfNotFound( - mainApplicationProps.modResults.contents, - kotlinAnchor, - kotlinJSBundleFileOverride, - ); - return mainApplicationProps; - } + // Expo SDK 56 and up. The argument has to be appended rather than slotted in + // anywhere: Kotlin evaluates arguments in source order, and + // `CodePush.getJSBundleFile()` throws until a `CodePush` instance exists, + // which the `packageList` argument above it is what creates. + const reactHost = insertLastArgument( + withImport, + REACT_HOST_FACTORY, + `jsBundleFilePath = ${CODE_PUSH_BUNDLE_CALL}`, + ); + if (reactHost !== null) return reactHost; - const javaJSBundleFileOverride = ` + // Expo SDK 49 through 55, and anything else still on `ReactNativeHost`. Both + // languages share the anchor: Kotlin writes + // `object : DefaultReactNativeHost(this) {`, Java writes + // `new DefaultReactNativeHost(this) {`. + const override = + language === "kt" + ? ` + override fun getJSBundleFile(): String? = ${CODE_PUSH_BUNDLE_CALL} +` + : ` @Override protected String getJSBundleFile() { - return CodePush.getJSBundleFile(); - }\n`; - - // The default on Expo 49 - const defaultReactNativeAnchor = "new DefaultReactNativeHost(this) {"; - if ( - mainApplicationProps.modResults.contents.includes( - defaultReactNativeAnchor, - ) - ) { - mainApplicationProps.modResults.contents = addBelowAnchorIfNotFound( - mainApplicationProps.modResults.contents, - defaultReactNativeAnchor, - javaJSBundleFileOverride, - ); + return ${CODE_PUSH_BUNDLE_CALL}; + } +`; - return mainApplicationProps; + for (const anchor of [ + "DefaultReactNativeHost(this) {", + "ReactNativeHost(this) {", + ]) { + if (withImport.includes(anchor)) { + return addBelowAnchorIfNotFound(withImport, anchor, override); } + } - // This is for compatibility, as it follows the Codepush instructions up-to-spec. - const reactNativeHostAnchor = "new ReactNativeHost(this) {"; - if ( - mainApplicationProps.modResults.contents.includes(reactNativeHostAnchor) - ) { - mainApplicationProps.modResults.contents = addBelowAnchorIfNotFound( - mainApplicationProps.modResults.contents, - reactNativeHostAnchor, - javaJSBundleFileOverride, - ); + throw new Error( + `Cannot find a suitable place to insert the CodePush getJSBundleFile code. MainApplication has neither a "${REACT_HOST_FACTORY}" call nor a "ReactNativeHost" subclass to hook into.`, + ); +} - return mainApplicationProps; - } - - throw new Error( - "Cannot find a suitable place to insert the CodePush getJSBundleFile code.", +/** + * Updates `MainApplication` so the CodePush runtime decides where the JS bundle + * comes from on each app start. + */ +export const withAndroidMainApplicationDependency: ConfigPlugin< + PluginConfigType +> = (config) => { + return withMainApplication(config, (mainApplicationProps) => { + mainApplicationProps.modResults.contents = applyMainApplication( + mainApplicationProps.modResults.contents, + mainApplicationProps.modResults.language, ); + + return mainApplicationProps; }); }; diff --git a/src/utils/__tests__/add-import.test.ts b/src/utils/__tests__/add-import.test.ts new file mode 100644 index 0000000..a7b2e58 --- /dev/null +++ b/src/utils/__tests__/add-import.test.ts @@ -0,0 +1,62 @@ +import { addImport } from "../add-import"; + +const kotlin = `package com.example + +import android.app.Application +import com.facebook.react.ReactApplication + +class MainApplication : Application() +`; + +describe("addImport", () => { + it("adds the import below the last one", () => { + const result = addImport( + kotlin, + "com.microsoft.codepush.react.CodePush", + "kt", + ); + + expect(result).toContain( + "import com.facebook.react.ReactApplication\nimport com.microsoft.codepush.react.CodePush\n", + ); + }); + + it("terminates Java imports with a semicolon", () => { + const java = `package com.example;\n\nimport android.app.Application;\n`; + + expect( + addImport(java, "com.microsoft.codepush.react.CodePush", "java"), + ).toContain("import com.microsoft.codepush.react.CodePush;"); + }); + + it("does not add the same import twice", () => { + const once = addImport( + kotlin, + "com.microsoft.codepush.react.CodePush", + "kt", + ); + + expect(addImport(once, "com.microsoft.codepush.react.CodePush", "kt")).toBe( + once, + ); + }); + + it("does not mistake a longer path for the same import", () => { + const result = addImport( + kotlin, + "com.facebook.react.ReactApplicationContext", + "kt", + ); + + expect(result).toContain( + "import com.facebook.react.ReactApplicationContext", + ); + expect(result).toContain("import com.facebook.react.ReactApplication\n"); + }); + + it("throws when there is no import block to anchor to", () => { + expect(() => addImport("package com.example\n", "a.B", "kt")).toThrow( + /no import statements/, + ); + }); +}); diff --git a/src/utils/__tests__/insert-last-argument.test.ts b/src/utils/__tests__/insert-last-argument.test.ts new file mode 100644 index 0000000..605f2a4 --- /dev/null +++ b/src/utils/__tests__/insert-last-argument.test.ts @@ -0,0 +1,75 @@ +import { insertLastArgument } from "../insert-last-argument"; + +describe("insertLastArgument", () => { + it("appends to a single-line call", () => { + expect(insertLastArgument(`foo(a, b)`, "foo", "c = 1")).toBe( + `foo(a, b,\n c = 1)`, + ); + }); + + it("fills an empty argument list", () => { + expect(insertLastArgument(`foo()`, "foo", "c = 1")).toBe(`foo(c = 1)`); + }); + + it("keeps the closing parenthesis on its own line", () => { + const source = ` foo(\n a = 1\n )\n`; + + expect(insertLastArgument(source, "foo", "b = 2")).toBe( + ` foo(\n a = 1,\n b = 2\n )\n`, + ); + }); + + it("ignores parentheses inside line comments", () => { + const source = `foo(\n a = 1 // ignore ) this\n)`; + + // The comma has to land before the comment, not after it, or the comment + // swallows it and the call stops compiling. + expect(insertLastArgument(source, "foo", "b = 2")).toBe( + `foo(\n a = 1,\n b = 2 // ignore ) this\n)`, + ); + }); + + it("ignores parentheses inside block comments", () => { + const source = `foo(a /* ) */, b)`; + + expect(insertLastArgument(source, "foo", "c")).toBe( + `foo(a /* ) */, b,\n c)`, + ); + }); + + it("ignores parentheses inside strings, escapes included", () => { + const source = `foo("a )", "b \\" )", c)`; + + expect(insertLastArgument(source, "foo", "d")).toBe( + `foo("a )", "b \\" )", c,\n d)`, + ); + }); + + it("ignores parentheses inside Kotlin raw strings", () => { + const source = `foo("""a ) b""", c)`; + + expect(insertLastArgument(source, "foo", "d")).toBe( + `foo("""a ) b""", c,\n d)`, + ); + }); + + it("matches the outermost parenthesis, not the first nested one", () => { + const source = `foo(bar(baz()), qux)`; + + expect(insertLastArgument(source, "foo", "extra")).toBe( + `foo(bar(baz()), qux,\n extra)`, + ); + }); + + it("returns null when the call is absent", () => { + expect(insertLastArgument(`bar(a)`, "foo", "b")).toBeNull(); + }); + + it("returns null when the parentheses never close", () => { + expect(insertLastArgument(`foo(a, bar(b)`, "foo", "c")).toBeNull(); + }); + + it("returns null when a string swallows the rest of the file", () => { + expect(insertLastArgument(`foo(a, "unterminated)`, "foo", "c")).toBeNull(); + }); +}); diff --git a/src/utils/add-import.ts b/src/utils/add-import.ts new file mode 100644 index 0000000..602266d --- /dev/null +++ b/src/utils/add-import.ts @@ -0,0 +1,53 @@ +const escapeForRegExp = (value: string) => + value.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); + +/** + * Adds an import to a Java or Kotlin source file, below the last import already + * there. + * + * The mods used to hang this off `import expo.modules.ReactNativeHostWrapper`, + * which prebuild stopped emitting once `MainApplication` moved to `ReactHost`. + * Every import block ends somewhere, so anchoring on the block itself survives + * the templates shuffling their imports around. + * + * @param source The file contents. + * @param importPath The class to import, e.g. `com.microsoft.codepush.react.CodePush`. + * @param language `java` needs the trailing semicolon, `kt` does not. + * @returns The source with the import added, or unchanged if it was already there. + */ +export function addImport( + source: string, + importPath: string, + language: "java" | "kt", +) { + const statement = + language === "java" ? `import ${importPath};` : `import ${importPath}`; + + const lines = source.split("\n"); + + // Static imports and Kotlin aliases both start the same way, so matching the + // path with a word boundary is enough to spot a duplicate. + const alreadyImported = lines.some((line) => + new RegExp( + `^\\s*import\\s+(static\\s+)?${escapeForRegExp(importPath)}\\b`, + ).test(line), + ); + if (alreadyImported) return source; + + let lastImport = -1; + for (let index = lines.length - 1; index >= 0; index--) { + if (/^\s*import\s+\S/.test(lines[index] ?? "")) { + lastImport = index; + break; + } + } + + if (lastImport === -1) { + throw new Error( + `Cannot add "${statement}": the file has no import statements to add it below.`, + ); + } + + lines.splice(lastImport + 1, 0, statement); + return lines.join("\n"); +} diff --git a/src/utils/insert-last-argument.ts b/src/utils/insert-last-argument.ts new file mode 100644 index 0000000..c1ab20a --- /dev/null +++ b/src/utils/insert-last-argument.ts @@ -0,0 +1,141 @@ +/** What sits at a given index while scanning a call's argument list. */ +type Scanned = + /** A comment or string literal whose last character is at `end`. */ + | { end: number; isLiteral: boolean } + /** A comment or literal that never closes, so the source cannot be read. */ + | "unterminated" + /** Anything else: a character that counts as code. */ + | "code"; + +/** + * Classifies the token starting at `index`, so the scanner below can step over + * the parentheses that live inside comments and strings. Covers what prebuild + * emits in Java and Kotlin: `//` and block comments, `"` strings with escapes, + * Kotlin's `"""` raw strings, and Java char literals. + */ +const scanAt = (source: string, index: number): Scanned => { + const character = source[index] ?? ""; + const next = source[index + 1]; + + if (character === "/" && next === "/") { + const lineEnd = source.indexOf("\n", index); + return lineEnd === -1 ? "unterminated" : { end: lineEnd, isLiteral: false }; + } + + if (character === "/" && next === "*") { + const commentEnd = source.indexOf("*/", index + 2); + return commentEnd === -1 + ? "unterminated" + : { end: commentEnd + 1, isLiteral: false }; + } + + if (source.startsWith('"""', index)) { + const rawEnd = source.indexOf('"""', index + 3); + return rawEnd === -1 + ? "unterminated" + : { end: rawEnd + 2, isLiteral: true }; + } + + if (character === '"' || character === "'") { + let cursor = index + 1; + while (cursor < source.length && source[cursor] !== character) { + // A backslash escapes whatever follows it, including the quote. + cursor += source[cursor] === "\\" ? 2 : 1; + } + return cursor >= source.length + ? "unterminated" + : { end: cursor, isLiteral: true }; + } + + return "code"; +}; + +type CallBounds = { + /** Index of the `)` that closes the call. */ + closeIndex: number; + /** + * Index just past the last character of code inside the call, so trailing + * whitespace and comments are excluded. A comma appended here cannot land + * inside a `//` comment, where it would be silently commented out. + */ + endOfArguments: number; +}; + +/** Walks from a call's opening parenthesis to its match. */ +const findCallBounds = ( + source: string, + openIndex: number, +): CallBounds | null => { + let depth = 0; + let endOfArguments = openIndex + 1; + + for (let index = openIndex; index < source.length; index++) { + const scanned = scanAt(source, index); + + if (scanned === "unterminated") return null; + + if (scanned === "code") { + const character = source[index] ?? ""; + + if (character === "(") depth++; + if (character === ")") depth--; + + // The scan starts on the call's own `(`, so depth is 1 from the first + // character on and only the matching `)` can bring it back to 0. + if (depth === 0) return { closeIndex: index, endOfArguments }; + + if (!/\s/.test(character)) endOfArguments = index + 1; + } else { + // A string is part of an argument; a comment is not. + if (scanned.isLiteral) endOfArguments = scanned.end + 1; + index = scanned.end; + } + } + + return null; +}; + +/** + * Appends an argument to a call, leaving the closing parenthesis where it is. + * + * Position is not cosmetic. Kotlin evaluates arguments in the order they are + * written, and `CodePush.getJSBundleFile()` throws until a `CodePush` instance + * exists, which only happens once `PackageList(this).packages` has been built. + * Appending is the only placement that holds regardless of how many arguments + * the template passes or how they are laid out. + * + * @param source The file contents. + * @param callee The call to extend, without its parenthesis, e.g. + * `ExpoReactHostFactory.getDefaultReactHost`. + * @param argument The argument to append, e.g. `jsBundleFilePath = null`. + * @returns The source with the argument appended, or `null` if the call is not + * there or its parentheses never balance. + */ +export function insertLastArgument( + source: string, + callee: string, + argument: string, +) { + const calleeIndex = source.indexOf(`${callee}(`); + if (calleeIndex === -1) return null; + + const openIndex = calleeIndex + callee.length; + const bounds = findCallBounds(source, openIndex); + if (bounds === null) return null; + + const { closeIndex, endOfArguments } = bounds; + + // An empty argument list has no trailing comma to add and nothing to line the + // argument up with, so write it straight between the parentheses. + if (source.slice(openIndex + 1, closeIndex).trim() === "") { + return `${source.slice(0, openIndex + 1)}${argument}${source.slice(closeIndex)}`; + } + + // Indent one level past the closing parenthesis when it sits on its own line, + // otherwise past the line the call opens on. + const lineStart = source.lastIndexOf("\n", closeIndex - 1) + 1; + const closeIndent = /^[\t ]*/.exec(source.slice(lineStart, closeIndex))?.[0]; + const indent = `${closeIndent ?? ""} `; + + return `${source.slice(0, endOfArguments)},\n${indent}${argument}${source.slice(endOfArguments)}`; +} From 2501509904108019f019ba21909845df5b1af76d Mon Sep 17 00:00:00 2001 From: Gabriel Taveira Date: Mon, 27 Jul 2026 20:20:12 -0300 Subject: [PATCH 3/3] docs: say which SDKs prebuild is tested against Android is covered on SDK 50 and 57. iOS writes Info.plist but leaves the Swift AppDelegate that SDK 52 and up generate untouched, since the mod still looks for the Objective-C `#import "AppDelegate.h"`. Claude-Session: https://claude.ai/code/session_01QsVEsfkynXpJWK4t33exG9 --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b7d1550..38e8b8a 100644 --- a/README.md +++ b/README.md @@ -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