Skip to content

Commit

Permalink
Merge pull request #2092 from bugsnag/PLAT-11696/rn-cli-073-fix
Browse files Browse the repository at this point in the history
fix(react-native-cli): ensure kotlin files are updated
  • Loading branch information
yousif-bugsnag authored Mar 5, 2024
2 parents 030ac98 + 8b92f0b commit d2aab02
Show file tree
Hide file tree
Showing 9 changed files with 361 additions and 27 deletions.
27 changes: 21 additions & 6 deletions packages/react-native-cli/src/lib/Gradle.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Logger } from '../Logger'
import { promises as fs } from 'fs'
import path from 'path'
import { detectInstalledVersion } from './Npm'
import semver from 'semver'

const GRADLE_PLUGIN_IMPORT = (version: string) => `classpath("com.bugsnag:bugsnag-android-gradle-plugin:${version}")`
const GRADLE_PLUGIN_IMPORT_REGEX = /classpath\(["']com\.bugsnag:bugsnag-android-gradle-plugin:.*["']\)/
Expand All @@ -12,6 +14,9 @@ const BUGSNAG_CONFIGURATION_BLOCK = 'bugsnag {\n}\n'
const BUGSNAG_CONFIGURATION_BLOCK_REGEX = /^\s*bugsnag {[^}]*?}/m
const UPLOAD_ENDPOINT_REGEX = /^\s*bugsnag {[^}]*endpoint[^}]*?}/m
const BUILD_ENDPOINT_REGEX = /^\s*bugsnag {[^}]*releasesEndpoint[^}]*?}/m
const GRADLE_VERSION_FAIL_MSG = `Cannot determine an appropriate version of the Bugsnag Android Gradle plugin for use in this project.
Please see ${DOCS_LINK} for information on Gradle and the Android Gradle Plugin (AGP) compatibility`

export async function getSuggestedBugsnagGradleVersion (projectRoot: string, logger: Logger): Promise<string> {
let fileContents: string
Expand All @@ -30,14 +35,24 @@ export async function getSuggestedBugsnagGradleVersion (projectRoot: string, log
} else if (major === 7) {
return '7.+'
} else {
const versionMatchResult = fileContents.match(/classpath\(["']com.android.tools.build:gradle["']\)/)
if (versionMatchResult) {
// if the AGP version isn't set explicitly in the build.gradle file,
// try to suggest a version based on the detected react-native version
const noVersionMatchResult = fileContents.match(/classpath\(["']com.android.tools.build:gradle["']\)/)
let reactNativeVersion
try {
reactNativeVersion = await detectInstalledVersion('react-native', projectRoot)
} catch (e) {}

if (!noVersionMatchResult || !reactNativeVersion) {
logger.warn(GRADLE_VERSION_FAIL_MSG)
return ''
}

// RN 0.73+ requires AGP 8.+
if (semver.lt(reactNativeVersion, '0.73.0')) {
return '7.+'
} else {
logger.warn(`Cannot determine an appropriate version of the Bugsnag Android Gradle plugin for use in this project.
Please see ${DOCS_LINK} for information on Gradle and the Android Gradle Plugin (AGP) compatibility`)
return ''
return '8.+'
}
}
}
Expand Down
32 changes: 18 additions & 14 deletions packages/react-native-cli/src/lib/Insert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ const BUGSNAG_JAVA_IMPORT = 'import com.bugsnag.android.Bugsnag;'
const BUGSNAG_JAVA_INIT = 'Bugsnag.start(this);'
const JAVA_APP_ON_CREATE_REGEX = /(public void onCreate\s*\(\)\s*\{[^]*super\.onCreate\(\);(\s*))\S/

const BUGSNAG_KOTLIN_IMPORT = 'import com.bugsnag.android.Bugsnag'
const BUGSNAG_KOTLIN_INIT = 'Bugsnag.start(this)'
const KOTLIN_APP_ON_CREATE_REGEX = /(override fun onCreate\s*\(\)\s*\{[^]*super\.onCreate\(\)(\s*))\S/

const DOCS_LINK = 'https://docs.bugsnag.com/platforms/react-native/react-native/#basic-configuration'
const FAIL_MSG = (filename: string) =>
`Failed to update "${filename}" automatically. The file may not exist or it may be in an unexpected format or location.
Expand Down Expand Up @@ -114,39 +118,39 @@ export async function insertAndroid (projectRoot: string, logger: Logger): Promi
cwd: javaDir
}))[0]

const relativeMainApplicationPathOther = (await asyncGlob('**/*/MainApplication', {
cwd: javaDir
}))[0]

const relativeMainApplicationPath = relativeMainApplicationPathJava || relativeMainApplicationPathKotlin || relativeMainApplicationPathOther
const relativeMainApplicationPath = relativeMainApplicationPathJava || relativeMainApplicationPathKotlin

if (!relativeMainApplicationPath) {
return logger.warn(FAIL_MSG('MainApplication.java, MainApplication.kt or MainApplication'))
return logger.warn(FAIL_MSG('MainApplication'))
}
mainApplicationPath = path.join(javaDir, relativeMainApplicationPath)
} catch (e) {
logger.warn(FAIL_MSG('MainApplication.java, MainApplication.kt or MainApplication'))
logger.warn(FAIL_MSG('MainApplication'))
return
}

try {
const mainApplication = await fs.readFile(mainApplicationPath, 'utf8')
const isKotlin = path.extname(mainApplicationPath) === '.kt'
const bugsnagImport = isKotlin ? BUGSNAG_KOTLIN_IMPORT : BUGSNAG_JAVA_IMPORT
const bugsnagInit = isKotlin ? BUGSNAG_KOTLIN_INIT : BUGSNAG_JAVA_INIT
const onCreateRegex = isKotlin ? KOTLIN_APP_ON_CREATE_REGEX : JAVA_APP_ON_CREATE_REGEX

if (mainApplication.includes(BUGSNAG_JAVA_IMPORT) || mainApplication.includes(BUGSNAG_JAVA_INIT)) {
const mainApplication = await fs.readFile(mainApplicationPath, 'utf8')
if (mainApplication.includes(bugsnagImport) || mainApplication.includes(bugsnagInit)) {
logger.warn('Bugsnag is already included, skipping')
return
}

const mainApplicationWithImport = mainApplication.replace('import', `${BUGSNAG_JAVA_IMPORT}\nimport`)
const onCreateRes = JAVA_APP_ON_CREATE_REGEX.exec(mainApplicationWithImport)
const mainApplicationWithImport = mainApplication.replace('import', `${bugsnagImport}\nimport`)
const onCreateRes = onCreateRegex.exec(mainApplicationWithImport)
if (!onCreateRes) {
logger.warn(FAIL_MSG('MainApplication.java, MainApplication.kt or MainApplication'))
logger.warn(FAIL_MSG('MainApplication'))
return
}

await fs.writeFile(mainApplicationPath, mainApplicationWithImport.replace(onCreateRes[1], `${onCreateRes[1]}${BUGSNAG_JAVA_INIT}${onCreateRes[2]}`), 'utf8')
await fs.writeFile(mainApplicationPath, mainApplicationWithImport.replace(onCreateRes[1], `${onCreateRes[1]}${bugsnagInit}${onCreateRes[2]}`), 'utf8')
logger.success('Done')
} catch (e) {
logger.error(FAIL_MSG('MainApplication.java, MainApplication.kt or MainApplication'))
logger.error(FAIL_MSG('MainApplication'))
}
}
28 changes: 28 additions & 0 deletions packages/react-native-cli/src/lib/__test__/Gradle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,3 +512,31 @@ test('getSuggestedBugsnagGradleVersion(): success with bracketed AGP version', a
const version = await getSuggestedBugsnagGradleVersion('/random/path', logger)
expect(version).toBe('7.+')
})

test('getSuggestedBugsnagGradleVersion(): success with unspecified AGP version', async () => {
const buildGradle = await loadFixture(path.join(__dirname, 'fixtures', 'root-build-before-without-agp-version.gradle'))

const readFileMock = fs.readFile as jest.MockedFunction<typeof fs.readFile>
readFileMock.mockResolvedValueOnce(buildGradle)
readFileMock.mockResolvedValueOnce('{"dependencies": { "react-native": "0.73.0"} }')

let version = await getSuggestedBugsnagGradleVersion('/random/path', logger)
expect(version).toBe('8.+')

readFileMock.mockResolvedValueOnce(buildGradle)
readFileMock.mockResolvedValueOnce('{"dependencies": { "react-native": "0.72.11"} }')

version = await getSuggestedBugsnagGradleVersion('/random/path', logger)
expect(version).toBe('7.+')
})

test('getSuggestedBugsnagGradleVersion(): null with unspecified AGP and react native versions', async () => {
const buildGradle = await loadFixture(path.join(__dirname, 'fixtures', 'root-build-before-without-agp-version.gradle'))

const readFileMock = fs.readFile as jest.MockedFunction<typeof fs.readFile>
readFileMock.mockResolvedValueOnce(buildGradle)
readFileMock.mockResolvedValueOnce('')

const version = await getSuggestedBugsnagGradleVersion('/random/path', logger)
expect(version).toBe('')
})
78 changes: 71 additions & 7 deletions packages/react-native-cli/src/lib/__test__/Insert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ test('insertIos(): no identifiable app launch method', async () => {
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to update "AppDelegate.mm" automatically.'))
})

test('insertAndroid(): success', async () => {
test('insertAndroid(): success (java)', async () => {
const globMock = glob as unknown as jest.MockedFunction<typeof glob>
globMock.mockImplementation((glob, opts, cb) => cb(null, ['com/bugsnagreactnativeclitest/MainApplication.java']))

Expand All @@ -207,7 +207,29 @@ test('insertAndroid(): success', async () => {
)
})

test('insertAndroid(): success, tolerates some differences in source', async () => {
test('insertAndroid(): success (kotlin)', async () => {
const globMock = glob as unknown as jest.MockedFunction<typeof glob>
globMock.mockImplementation((glob, opts, cb) => cb(null, ['com/bugsnagreactnativeclitest/MainApplication.kt']))

const mainApplication = await loadFixture(path.join(__dirname, 'fixtures', 'MainApplication-before.kt'))
const readFileMock = fs.readFile as jest.MockedFunction<typeof fs.readFile>
readFileMock.mockResolvedValue(mainApplication)

const writeFileMock = fs.writeFile as jest.MockedFunction<typeof fs.writeFile>

await insertAndroid('/random/path', logger)
expect(readFileMock).toHaveBeenCalledWith(
'/random/path/android/app/src/main/java/com/bugsnagreactnativeclitest/MainApplication.kt',
'utf8'
)
expect(writeFileMock).toHaveBeenCalledWith(
'/random/path/android/app/src/main/java/com/bugsnagreactnativeclitest/MainApplication.kt',
await loadFixture(path.join(__dirname, 'fixtures', 'MainApplication-after.kt')),
'utf8'
)
})

test('insertAndroid(): success, tolerates some differences in source (java)', async () => {
const globMock = glob as unknown as jest.MockedFunction<typeof glob>
globMock.mockImplementation((glob, opts, cb) => cb(null, ['com/bugsnagreactnativeclitest/MainApplication.java']))

Expand All @@ -229,7 +251,29 @@ test('insertAndroid(): success, tolerates some differences in source', async ()
)
})

test('insertAndroid(): already present', async () => {
test('insertAndroid(): success, tolerates some differences in source (kotlin)', async () => {
const globMock = glob as unknown as jest.MockedFunction<typeof glob>
globMock.mockImplementation((glob, opts, cb) => cb(null, ['com/bugsnagreactnativeclitest/MainApplication.kt']))

const mainApplication = await loadFixture(path.join(__dirname, 'fixtures', 'MainApplication-before-2.kt'))
const readFileMock = fs.readFile as jest.MockedFunction<typeof fs.readFile>
readFileMock.mockResolvedValue(mainApplication)

const writeFileMock = fs.writeFile as jest.MockedFunction<typeof fs.writeFile>

await insertAndroid('/random/path', logger)
expect(readFileMock).toHaveBeenCalledWith(
'/random/path/android/app/src/main/java/com/bugsnagreactnativeclitest/MainApplication.kt',
'utf8'
)
expect(writeFileMock).toHaveBeenCalledWith(
'/random/path/android/app/src/main/java/com/bugsnagreactnativeclitest/MainApplication.kt',
await loadFixture(path.join(__dirname, 'fixtures', 'MainApplication-after-2.kt')),
'utf8'
)
})

test('insertAndroid(): already present (java)', async () => {
const globMock = glob as unknown as jest.MockedFunction<typeof glob>
globMock.mockImplementation((glob, opts, cb) => cb(null, ['com/bugsnagreactnativeclitest/MainApplication.java']))

Expand All @@ -249,6 +293,26 @@ test('insertAndroid(): already present', async () => {
expect(logger.warn).toHaveBeenCalledWith('Bugsnag is already included, skipping')
})

test('insertAndroid(): already present (kotlin)', async () => {
const globMock = glob as unknown as jest.MockedFunction<typeof glob>
globMock.mockImplementation((glob, opts, cb) => cb(null, ['com/bugsnagreactnativeclitest/MainApplication.kt']))

const mainApplication = await loadFixture(path.join(__dirname, 'fixtures', 'MainApplication-after.kt'))
const readFileMock = fs.readFile as jest.MockedFunction<typeof fs.readFile>
readFileMock.mockResolvedValue(mainApplication)

const writeFileMock = fs.writeFile as jest.MockedFunction<typeof fs.writeFile>

await insertAndroid('/random/path', logger)
expect(readFileMock).toHaveBeenCalledWith(
'/random/path/android/app/src/main/java/com/bugsnagreactnativeclitest/MainApplication.kt',
'utf8'
)
expect(writeFileMock).not.toHaveBeenCalled()

expect(logger.warn).toHaveBeenCalledWith('Bugsnag is already included, skipping')
})

test('insertAndroid(): failure to locate file', async () => {
const globMock = glob as unknown as jest.MockedFunction<typeof glob>
globMock.mockImplementation((glob, opts, cb) => cb(null, ['com/bugsnagreactnativeclitest/MainApplication.java']))
Expand All @@ -265,7 +329,7 @@ test('insertAndroid(): failure to locate file', async () => {
)
expect(writeFileMock).not.toHaveBeenCalled()

expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Failed to update "MainApplication.java, MainApplication.kt or MainApplication" automatically.'))
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Failed to update "MainApplication" automatically.'))
})

test('insertAndroid(): failure to locate package directory', async () => {
Expand All @@ -279,7 +343,7 @@ test('insertAndroid(): failure to locate package directory', async () => {
expect(readFileMock).not.toHaveBeenCalled()
expect(writeFileMock).not.toHaveBeenCalled()

expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to update "MainApplication.java, MainApplication.kt or MainApplication" automatically.'))
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to update "MainApplication" automatically.'))
})

test('insertAndroid(): project directory error', async () => {
Expand All @@ -293,7 +357,7 @@ test('insertAndroid(): project directory error', async () => {
expect(readFileMock).not.toHaveBeenCalled()
expect(writeFileMock).not.toHaveBeenCalled()

expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to update "MainApplication.java, MainApplication.kt or MainApplication" automatically.'))
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to update "MainApplication" automatically.'))
})

test('insertAndroid(): no identifiable onCreate method', async () => {
Expand All @@ -311,5 +375,5 @@ test('insertAndroid(): no identifiable onCreate method', async () => {
)
expect(writeFileMock).not.toHaveBeenCalled()

expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to update "MainApplication.java, MainApplication.kt or MainApplication" automatically.'))
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to update "MainApplication" automatically.'))
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.bugsnagreactnativeclitest

import com.bugsnag.android.Bugsnag
import android.app.Application
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.DefaultNewArchitectureEntryPoint.load
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.react.flipper.ReactNativeFlipper
import com.facebook.soloader.SoLoader

class MainApplication : Application(), ReactApplication {

override val reactNativeHost: ReactNativeHost =
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
}

override fun getJSMainModuleName(): String = "index"

override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG

override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
}

override val reactHost: ReactHost
get() = getDefaultReactHost(this.applicationContext, reactNativeHost)

override fun onCreate() {
// something between curly brace and super call
super.onCreate()
Bugsnag.start(this)
SoLoader.init(this, false)
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// If you opted-in for the New Architecture, we load the native entry point for this app.
load()
}
ReactNativeFlipper.initializeFlipper(this, reactNativeHost.reactInstanceManager)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.bugsnagreactnativeclitest

import com.bugsnag.android.Bugsnag
import android.app.Application
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.DefaultNewArchitectureEntryPoint.load
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.react.flipper.ReactNativeFlipper
import com.facebook.soloader.SoLoader

class MainApplication : Application(), ReactApplication {

override val reactNativeHost: ReactNativeHost =
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
}

override fun getJSMainModuleName(): String = "index"

override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG

override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
}

override val reactHost: ReactHost
get() = getDefaultReactHost(this.applicationContext, reactNativeHost)

override fun onCreate() {
super.onCreate()
Bugsnag.start(this)
SoLoader.init(this, false)
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// If you opted-in for the New Architecture, we load the native entry point for this app.
load()
}
ReactNativeFlipper.initializeFlipper(this, reactNativeHost.reactInstanceManager)
}
}
Loading

0 comments on commit d2aab02

Please sign in to comment.