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