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