1import { ExpoConfig } from '@expo/config-types'; 2import Debug from 'debug'; 3import fs from 'fs'; 4import { sync as globSync } from 'glob'; 5import path from 'path'; 6 7import { getAppBuildGradleFilePath, getProjectFilePath } from './Paths'; 8import { ConfigPlugin } from '../Plugin.types'; 9import { withAppBuildGradle } from '../plugins/android-plugins'; 10import { withDangerousMod } from '../plugins/withDangerousMod'; 11import { directoryExistsAsync } from '../utils/modules'; 12import { addWarningAndroid } from '../utils/warnings'; 13 14const debug = Debug('expo:config-plugins:android:package'); 15 16export const withPackageGradle: ConfigPlugin = (config) => { 17 return withAppBuildGradle(config, (config) => { 18 if (config.modResults.language === 'groovy') { 19 config.modResults.contents = setPackageInBuildGradle(config, config.modResults.contents); 20 } else { 21 addWarningAndroid( 22 'android.package', 23 `Cannot automatically configure app build.gradle if it's not groovy` 24 ); 25 } 26 return config; 27 }); 28}; 29 30export const withPackageRefactor: ConfigPlugin = (config) => { 31 return withDangerousMod(config, [ 32 'android', 33 async (config) => { 34 await renamePackageOnDisk(config, config.modRequest.projectRoot); 35 return config; 36 }, 37 ]); 38}; 39 40export function getPackage(config: Pick<ExpoConfig, 'android'>) { 41 return config.android?.package ?? null; 42} 43 44function getPackageRoot(projectRoot: string, type: 'main' | 'debug') { 45 return path.join(projectRoot, 'android', 'app', 'src', type, 'java'); 46} 47 48function getCurrentPackageName(projectRoot: string, packageRoot: string) { 49 const mainApplication = getProjectFilePath(projectRoot, 'MainApplication'); 50 const packagePath = path.dirname(mainApplication); 51 const packagePathParts = path.relative(packageRoot, packagePath).split(path.sep).filter(Boolean); 52 53 return packagePathParts.join('.'); 54} 55 56function getCurrentPackageForProjectFile( 57 projectRoot: string, 58 packageRoot: string, 59 fileName: string, 60 type: string 61) { 62 const filePath = globSync( 63 path.join(projectRoot, `android/app/src/${type}/java/**/${fileName}.@(java|kt)`) 64 )[0]; 65 66 if (!filePath) { 67 return null; 68 } 69 70 const packagePath = path.dirname(filePath); 71 const packagePathParts = path.relative(packageRoot, packagePath).split(path.sep).filter(Boolean); 72 73 return packagePathParts.join('.'); 74} 75 76function getCurrentPackageNameForType(projectRoot: string, type: string): string | null { 77 const packageRoot = getPackageRoot(projectRoot, type as any); 78 79 if (type === 'main') { 80 return getCurrentPackageName(projectRoot, packageRoot); 81 } 82 // debug, etc.. 83 return getCurrentPackageForProjectFile(projectRoot, packageRoot, '*', type); 84} 85 86// NOTE(brentvatne): this assumes that our MainApplication.java file is in the root of the package 87// this makes sense for standard react-native projects but may not apply in customized projects, so if 88// we want this to be runnable in any app we need to handle other possibilities 89export async function renamePackageOnDisk( 90 config: Pick<ExpoConfig, 'android'>, 91 projectRoot: string 92) { 93 const newPackageName = getPackage(config); 94 if (newPackageName === null) { 95 return; 96 } 97 98 for (const type of ['debug', 'main', 'release']) { 99 await renameJniOnDiskForType({ projectRoot, type, packageName: newPackageName }); 100 await renamePackageOnDiskForType({ projectRoot, type, packageName: newPackageName }); 101 } 102} 103 104export async function renameJniOnDiskForType({ 105 projectRoot, 106 type, 107 packageName, 108}: { 109 projectRoot: string; 110 type: string; 111 packageName: string; 112}) { 113 if (!packageName) { 114 return; 115 } 116 117 const currentPackageName = getCurrentPackageNameForType(projectRoot, type); 118 if (!currentPackageName || !packageName || currentPackageName === packageName) { 119 return; 120 } 121 122 const jniRoot = path.join(projectRoot, 'android', 'app', 'src', type, 'jni'); 123 const filesToUpdate = [...globSync('**/*', { cwd: jniRoot, absolute: true })]; 124 // Replace all occurrences of the path in the project 125 filesToUpdate.forEach((filepath: string) => { 126 try { 127 if (fs.lstatSync(filepath).isFile() && ['.h', '.cpp'].includes(path.extname(filepath))) { 128 let contents = fs.readFileSync(filepath).toString(); 129 contents = contents.replace( 130 new RegExp(transformJavaClassDescriptor(currentPackageName).replace(/\//g, '\\/'), 'g'), 131 transformJavaClassDescriptor(packageName) 132 ); 133 fs.writeFileSync(filepath, contents); 134 } 135 } catch { 136 debug(`Error updating "${filepath}" for type "${type}"`); 137 } 138 }); 139} 140 141export async function renamePackageOnDiskForType({ 142 projectRoot, 143 type, 144 packageName, 145}: { 146 projectRoot: string; 147 type: string; 148 packageName: string; 149}) { 150 if (!packageName) { 151 return; 152 } 153 154 const currentPackageName = getCurrentPackageNameForType(projectRoot, type); 155 debug(`Found package "${currentPackageName}" for type "${type}"`); 156 if (!currentPackageName || currentPackageName === packageName) { 157 return; 158 } 159 debug(`Refactor "${currentPackageName}" to "${packageName}" for type "${type}"`); 160 const packageRoot = getPackageRoot(projectRoot, type as any); 161 // Set up our paths 162 if (!(await directoryExistsAsync(packageRoot))) { 163 debug(`- skipping refactor of missing directory: ${packageRoot}`); 164 return; 165 } 166 167 const currentPackagePath = path.join(packageRoot, ...currentPackageName.split('.')); 168 const newPackagePath = path.join(packageRoot, ...packageName.split('.')); 169 170 // Create the new directory 171 fs.mkdirSync(newPackagePath, { recursive: true }); 172 173 // Move everything from the old directory over 174 globSync('**/*', { cwd: currentPackagePath }).forEach((relativePath) => { 175 const filepath = path.join(currentPackagePath, relativePath); 176 if (fs.lstatSync(filepath).isFile()) { 177 moveFileSync(filepath, path.join(newPackagePath, relativePath)); 178 } else { 179 fs.mkdirSync(filepath, { recursive: true }); 180 } 181 }); 182 183 // Remove the old directory recursively from com/old/package to com/old and com, 184 // as long as the directories are empty 185 const oldPathParts = currentPackageName.split('.'); 186 while (oldPathParts.length) { 187 const pathToCheck = path.join(packageRoot, ...oldPathParts); 188 try { 189 const files = fs.readdirSync(pathToCheck); 190 if (files.length === 0) { 191 fs.rmdirSync(pathToCheck); 192 } 193 } finally { 194 oldPathParts.pop(); 195 } 196 } 197 198 const filesToUpdate = [...globSync('**/*', { cwd: newPackagePath, absolute: true })]; 199 // Only update the BUCK file to match the main package name 200 if (type === 'main') { 201 // NOTE(EvanBacon): We dropped this file in SDK 48 but other templates may still use it. 202 filesToUpdate.push(path.join(projectRoot, 'android', 'app', 'BUCK')); 203 } 204 // Replace all occurrences of the path in the project 205 filesToUpdate.forEach((filepath: string) => { 206 try { 207 if (fs.lstatSync(filepath).isFile()) { 208 let contents = fs.readFileSync(filepath).toString(); 209 contents = contents.replace(new RegExp(currentPackageName!, 'g'), packageName); 210 if (['.h', '.cpp'].includes(path.extname(filepath))) { 211 contents = contents.replace( 212 new RegExp(transformJavaClassDescriptor(currentPackageName).replace(/\//g, '\\'), 'g'), 213 transformJavaClassDescriptor(packageName) 214 ); 215 } 216 fs.writeFileSync(filepath, contents); 217 } 218 } catch { 219 debug(`Error updating "${filepath}" for type "${type}"`); 220 } 221 }); 222} 223 224function moveFileSync(src: string, dest: string) { 225 fs.mkdirSync(path.dirname(dest), { recursive: true }); 226 fs.renameSync(src, dest); 227} 228 229export function setPackageInBuildGradle(config: Pick<ExpoConfig, 'android'>, buildGradle: string) { 230 const packageName = getPackage(config); 231 if (packageName === null) { 232 return buildGradle; 233 } 234 235 const pattern = new RegExp(`(applicationId|namespace) ['"].*['"]`, 'g'); 236 return buildGradle.replace(pattern, `$1 '${packageName}'`); 237} 238 239export async function getApplicationIdAsync(projectRoot: string): Promise<string | null> { 240 const buildGradlePath = getAppBuildGradleFilePath(projectRoot); 241 if (!fs.existsSync(buildGradlePath)) { 242 return null; 243 } 244 const buildGradle = await fs.promises.readFile(buildGradlePath, 'utf8'); 245 const matchResult = buildGradle.match(/applicationId ['"](.*)['"]/); 246 // TODO add fallback for legacy cases to read from AndroidManifest.xml 247 return matchResult?.[1] ?? null; 248} 249 250/** 251 * Transform a java package name to java class descriptor, 252 * e.g. `com.helloworld` -> `Lcom/helloworld`. 253 */ 254function transformJavaClassDescriptor(packageName: string) { 255 return `L${packageName.replace(/\./g, '/')}`; 256} 257