1import glob from 'fast-glob';
2import fs from 'fs-extra';
3import path from 'path';
4
5import { ModuleDescriptorAndroid, PackageRevision } from '../types';
6
7/**
8 * Generates Java file that contains all autolinked packages.
9 */
10export async function generatePackageListAsync(
11  modules: ModuleDescriptorAndroid[],
12  targetPath: string,
13  namespace: string
14): Promise<void> {
15  const generatedFileContent = await generatePackageListFileContentAsync(modules, namespace);
16  await fs.outputFile(targetPath, generatedFileContent);
17}
18
19async function findGradleFilesAsync(revision: PackageRevision): Promise<string[]> {
20  const configGradlePaths = revision.config?.androidGradlePaths();
21  if (configGradlePaths && configGradlePaths.length) {
22    return configGradlePaths;
23  }
24
25  const buildGradleFiles = await glob('*/build.gradle', {
26    cwd: revision.path,
27    ignore: ['**/node_modules/**'],
28  });
29
30  return buildGradleFiles;
31}
32
33export async function resolveModuleAsync(
34  packageName: string,
35  revision: PackageRevision
36): Promise<ModuleDescriptorAndroid | null> {
37  // TODO: Relative source dir should be configurable through the module config.
38
39  // Don't link itself... :D
40  if (packageName === '@unimodules/react-native-adapter') {
41    return null;
42  }
43
44  const buildGradleFiles = await findGradleFilesAsync(revision);
45  // Just in case where the module doesn't have its own `build.gradle`.
46  if (!buildGradleFiles.length) {
47    return null;
48  }
49
50  const projects = buildGradleFiles.map((buildGradleFile) => {
51    const gradleFilePath = path.join(revision.path, buildGradleFile);
52    return {
53      name: convertPackageNameToProjectName(
54        packageName,
55        path.relative(revision.path, gradleFilePath)
56      ),
57      sourceDir: path.dirname(gradleFilePath),
58    };
59  });
60
61  const plugins = (revision.config?.androidGradlePlugins() ?? []).map(
62    ({ id, group, sourceDir }) => ({
63      id,
64      group,
65      sourceDir: path.join(revision.path, sourceDir),
66    })
67  );
68
69  return {
70    packageName,
71    projects,
72    ...(plugins.length > 0 ? { plugins } : {}),
73    modules: revision.config?.androidModules() ?? [],
74  };
75}
76
77/**
78 * Generates the string to put into the generated package list.
79 */
80async function generatePackageListFileContentAsync(
81  modules: ModuleDescriptorAndroid[],
82  namespace: string
83): Promise<string> {
84  // TODO: Instead of ignoring `expo` here, make the package class paths configurable from `expo-module.config.json`.
85  const packagesClasses = await findAndroidPackagesAsync(
86    modules.filter((module) => module.packageName !== 'expo')
87  );
88
89  const modulesClasses = await findAndroidModules(modules);
90
91  return `package ${namespace};
92
93import java.util.Arrays;
94import java.util.List;
95import expo.modules.core.interfaces.Package;
96import expo.modules.kotlin.modules.Module;
97import expo.modules.kotlin.ModulesProvider;
98
99public class ExpoModulesPackageList implements ModulesProvider {
100  private static class LazyHolder {
101    static final List<Package> packagesList = Arrays.<Package>asList(
102${packagesClasses.map((packageClass) => `      new ${packageClass}()`).join(',\n')}
103    );
104
105    static final List<Class<? extends Module>> modulesList = Arrays.<Class<? extends Module>>asList(
106      ${modulesClasses.map((moduleClass) => `      ${moduleClass}.class`).join(',\n')}
107    );
108  }
109
110  public static List<Package> getPackageList() {
111    return LazyHolder.packagesList;
112  }
113
114  @Override
115  public List<Class<? extends Module>> getModulesList() {
116    return LazyHolder.modulesList;
117  }
118}
119`;
120}
121
122function findAndroidModules(modules: ModuleDescriptorAndroid[]): string[] {
123  const modulesToProvide = modules.filter((module) => module.modules.length > 0);
124  const classNames = ([] as string[]).concat(...modulesToProvide.map((module) => module.modules));
125  return classNames;
126}
127
128async function findAndroidPackagesAsync(modules: ModuleDescriptorAndroid[]): Promise<string[]> {
129  const classes: string[] = [];
130
131  const flattenedSourceDirList: string[] = [];
132  for (const module of modules) {
133    for (const project of module.projects) {
134      flattenedSourceDirList.push(project.sourceDir);
135    }
136  }
137
138  await Promise.all(
139    flattenedSourceDirList.map(async (sourceDir) => {
140      const files = await glob('**/*Package.{java,kt}', {
141        cwd: sourceDir,
142      });
143
144      for (const file of files) {
145        const fileContent = await fs.readFile(path.join(sourceDir, file), 'utf8');
146
147        const packageRegex = (() => {
148          if (process.env.EXPO_SHOULD_USE_LEGACY_PACKAGE_INTERFACE) {
149            return /\bimport\s+org\.unimodules\.core\.(interfaces\.Package|BasePackage)\b/;
150          } else {
151            return /\bimport\s+expo\.modules\.core\.(interfaces\.Package|BasePackage)\b/;
152          }
153        })();
154
155        // Very naive check to skip non-expo packages
156        if (!packageRegex.test(fileContent)) {
157          continue;
158        }
159
160        const classPathMatches = fileContent.match(/^package ([\w.]+)\b/m);
161
162        if (classPathMatches) {
163          const basename = path.basename(file, path.extname(file));
164          classes.push(`${classPathMatches[1]}.${basename}`);
165        }
166      }
167    })
168  );
169  return classes.sort();
170}
171
172/**
173 * Converts the package name and gradle file path to Android's project name.
174 *   `$` to indicate subprojects
175 *   `/` path will transform as `-`
176 *
177 * Example: `@expo/example` + `android/build.gradle` → `expo-example`
178 *
179 * Example: multiple projects
180 *   - `expo-test` + `android/build.gradle` → `react-native-third-party`
181 *   - `expo-test` + `subproject/build.gradle` → `react-native-third-party$subproject`
182 */
183export function convertPackageNameToProjectName(
184  packageName: string,
185  buildGradleFile: string
186): string {
187  const name = packageName.replace(/^@/g, '').replace(/\W+/g, '-');
188  const baseDir = path.dirname(buildGradleFile).replace(/\//g, '-');
189  return baseDir === 'android' ? name : `${name}$${baseDir}`;
190}
191