packages/react-native-cli/src/lib/Gradle.ts
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:.*["']\)/
const GRADLE_PLUGIN_APPLY = 'apply plugin: "com.bugsnag.android.gradle"'
const GRADLE_PLUGIN_APPLY_REGEX = /apply plugin: ["']com\.bugsnag\.android\.gradle["']/
const GRADLE_ANDROID_PLUGIN_REGEX = /classpath\(["']com.android.tools.build:gradle:[^0-9]*([^'"]+)["']\)/
const DOCS_LINK = 'https://docs.bugsnag.com/build-integrations/gradle/#installation'
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
try {
fileContents = await fs.readFile(path.join(projectRoot, 'android', 'build.gradle'), 'utf8')
} catch (e) {
return '5+'
}
const versionMatchResult = fileContents.match(GRADLE_ANDROID_PLUGIN_REGEX)
const value = versionMatchResult?.[1]
const major = parseInt(value?.match(/^([0-9]+)/)?.[1] ?? '', 10)
if (major < 7) {
return '5.+'
} else if (major === 7) {
return '7.+'
} else {
// 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 {
return '8.+'
}
}
}
export async function modifyRootBuildGradle (projectRoot: string, pluginVersion: string, logger: Logger): Promise<void> {
logger.debug('Looking for android/build.gradle')
const topLevelBuildGradlePath = path.join(projectRoot, 'android', 'build.gradle')
logger.debug('Adding \'bugsnag-android-gradle-plugin\' to the build script classpath')
try {
await insertValueAfterPattern(
topLevelBuildGradlePath,
[/[\r\n]\s*classpath\(["']com.android.tools.build:gradle:.+["']\)/, /[\r\n]\s*classpath\(["']com.android.tools.build:gradle["']\)/],
GRADLE_PLUGIN_IMPORT(pluginVersion),
GRADLE_PLUGIN_IMPORT_REGEX,
logger
)
} catch (e: any) {
if (e.message === 'Pattern not found') {
logger.warn(
`The gradle file was in an unexpected format and so couldn't be updated automatically.
Add '${GRADLE_PLUGIN_IMPORT(pluginVersion)}' to the 'buildscript.dependencies section of android/build.gradle
See ${DOCS_LINK} for more information`
)
} else if (e.code === 'ENOENT') {
logger.warn(
`A gradle file was not found at the expected location and so couldn't be updated automatically.
Add '${GRADLE_PLUGIN_IMPORT(pluginVersion)}' to the 'buildscript.dependencies section of your project's build.gradle
See ${DOCS_LINK} for more information`
)
} else {
throw e
}
}
logger.success('Finished modifying android/build.gradle')
}
export async function modifyAppBuildGradle (projectRoot: string, logger: Logger): Promise<void> {
logger.debug('Looking for android/app/build.gradle')
const appBuildGradlePath = path.join(projectRoot, 'android', 'app', 'build.gradle')
logger.debug('Applying com.bugsnag.android.gradle plugin')
try {
await insertValueAfterPattern(
appBuildGradlePath,
[/^apply from: ["']\.\.\/\.\.\/node_modules\/react-native\/react\.gradle["']$/m, /^apply from: file\(["']..\/\.\.\/node_modules\/@react-native-community\/cli-platform-android\/native_modules\.gradle["']\); applyNativeModulesAppBuildGradle\(project\)$/m],
GRADLE_PLUGIN_APPLY,
GRADLE_PLUGIN_APPLY_REGEX,
logger
)
} catch (e: any) {
if (e.message === 'Pattern not found') {
logger.warn(
`The gradle file was in an unexpected format and so couldn't be updated automatically.
Add '${GRADLE_PLUGIN_APPLY}' to android/app/build.gradle
See ${DOCS_LINK} for more information`
)
} else if (e.code === 'ENOENT') {
logger.warn(
`A gradle file was not found at the expected location and so couldn't be updated automatically.
Add '${GRADLE_PLUGIN_APPLY}' to your app module's build.gradle
See ${DOCS_LINK} for more information`
)
} else {
throw e
}
}
logger.success('Finished modifying android/app/build.gradle')
}
export async function checkReactNativeMappings (
projectRoot: string,
logger: Logger
): Promise<void> {
logger.debug('Enabling Bugsnag Android Gradle plugin React Native mappings')
const appBuildGradlePath = path.join(projectRoot, 'android', 'app', 'build.gradle')
try {
const fileContents = await fs.readFile(appBuildGradlePath, 'utf8')
if (/^\s*uploadReactNativeMappings\s*=\s*true/m.test(fileContents)) {
logger.warn(
`The uploadReactNativeMappings option for the Bugsnag Gradle plugin is currently enabled in ${appBuildGradlePath}.
This is no longer required as mappings will be uploaded by the BugSnag CLI.
Please remove this line or disable it in your builds to prevent duplicate uploads.`
)
}
} catch (e) {
// No action required
}
}
async function insertBugsnagConfigBlock (
appBuildGradlePath: string,
logger: Logger
): Promise<void> {
logger.debug('Inserting Bugsnag config block')
await insertValueAfterPattern(
appBuildGradlePath,
[/$/],
BUGSNAG_CONFIGURATION_BLOCK,
BUGSNAG_CONFIGURATION_BLOCK_REGEX,
logger
)
logger.success('Bugsnag config block inserted into android/app/build.gradle')
}
export async function addUploadEndpoint (projectRoot: string, uploadEndpoint: string, logger: Logger): Promise<void> {
try {
const appBuildGradlePath = path.join(projectRoot, 'android', 'app', 'build.gradle')
await insertBugsnagConfigBlock(appBuildGradlePath, logger)
await insertValueAfterPattern(
appBuildGradlePath,
[/^\s*bugsnag {[^}]*?(?=})/m],
` endpoint = "${uploadEndpoint}"\n`,
UPLOAD_ENDPOINT_REGEX,
logger
)
} catch (e: any) {
if (e.message === 'Pattern not found') {
logger.warn(
`The gradle file was in an unexpected format and so couldn't be updated automatically.
Add your upload endpoint to your app module's build.gradle:
bugsnag {
endpoint = "${uploadEndpoint}"
}
See ${DOCS_LINK} for more information`
)
} else if (e.code === 'ENOENT') {
logger.warn(
`A gradle file was not found at the expected location and so couldn't be updated automatically.
Add your upload endpoint to your app module's build.gradle:
bugsnag {
endpoint = "${uploadEndpoint}"
}
See ${DOCS_LINK} for more information`
)
} else {
throw e
}
}
}
export async function addBuildEndpoint (projectRoot: string, buildEndpoint: string, logger: Logger): Promise<void> {
try {
const appBuildGradlePath = path.join(projectRoot, 'android', 'app', 'build.gradle')
await insertBugsnagConfigBlock(appBuildGradlePath, logger)
await insertValueAfterPattern(
appBuildGradlePath,
[/^\s*bugsnag {[^}]*?(?=})/m, /''/],
` releasesEndpoint = "${buildEndpoint}"\n`,
BUILD_ENDPOINT_REGEX,
logger
)
} catch (e: any) {
if (e.message === 'Pattern not found') {
logger.warn(
`The gradle file was in an unexpected format and so couldn't be updated automatically.
Add your build endpoint to your app module's build.gradle:
bugsnag {
releasesEndpoint = "${buildEndpoint}"
}
See ${DOCS_LINK} for more information`
)
} else if (e.code === 'ENOENT') {
logger.warn(
`A gradle file was not found at the expected location and so couldn't be updated automatically.
Add your build endpoint to your app module's build.gradle:
bugsnag {
releasesEndpoint = "${buildEndpoint}"
}
See ${DOCS_LINK} for more information`
)
} else {
throw e
}
}
}
async function insertValueAfterPattern (file: string, patterns: RegExp[], value: string, presencePattern: RegExp, logger: Logger): Promise<void> {
const fileContents = await fs.readFile(file, 'utf8')
if (presencePattern.test(fileContents)) {
logger.warn('Value already found in file, skipping.')
return
}
const match = patterns.map(search => fileContents.match(search)).find(m => !!m)
if (!match || match.index === undefined || !match.input) {
throw new Error('Pattern not found')
}
const splitLocation = match.index + match[0].length
const [indent] = match[0].match(/[\r\n]\s*/) || ['\n']
const firstChunk = fileContents.substr(0, splitLocation)
const lastChunk = fileContents.substring(splitLocation)
const output = `${firstChunk}${indent}${value}${lastChunk}`
await fs.writeFile(file, output, 'utf8')
}