1import groovy.json.JsonSlurper
2import java.nio.file.Paths
3
4
5// Object representing a gradle project.
6class ExpoModuleGradleProject {
7  // Name of the Android project
8  String name
9
10  // Path to the folder with Android project
11  String sourceDir
12
13  ExpoModuleGradleProject(Object data) {
14    this.name = data.name
15    this.sourceDir = data.sourceDir
16  }
17}
18
19// Object representing a gradle plugin
20class ExpoModuleGradlePlugin {
21  // ID of the gradle plugin
22  String id
23
24  // Artifact group
25  String group
26
27  // Path to the plugin folder
28  String sourceDir
29
30  ExpoModuleGradlePlugin(Object data) {
31    this.id = data.id
32    this.group = data.group
33    this.sourceDir = data.sourceDir
34  }
35}
36
37// Object representing a module.
38class ExpoModule {
39  // Name of the JavaScript package
40  String name
41
42  // Version of the package, loaded from `package.json`
43  String version
44
45  // Gradle projects
46  ExpoModuleGradleProject[] projects
47
48  // Gradle plugins
49  ExpoModuleGradlePlugin[] plugins
50
51  ExpoModule(Object data) {
52    this.name = data.packageName
53    this.version = data.packageVersion
54    this.projects = data.projects.collect { new ExpoModuleGradleProject(it) }
55    this.plugins = data.plugins.collect { new ExpoModuleGradlePlugin(it) }
56  }
57}
58
59// Object representing a maven repository.
60class MavenRepo {
61  String url
62
63  MavenRepo(Object data) {
64    this.url = data
65  }
66}
67
68class ExpoAutolinkingManager {
69  private File projectDir
70  private Map options
71  private Object cachedResolvingResults
72
73  static String generatedPackageListNamespace = 'expo.modules'
74  static String generatedPackageListFilename = 'ExpoModulesPackageList.java'
75  static String generatedFilesSrcDir = 'generated/expo/src/main/java'
76
77  ExpoAutolinkingManager(File projectDir, Map options = [:]) {
78    this.projectDir = projectDir
79    this.options = options
80  }
81
82  Object resolve(shouldUseCachedValue=false) {
83    if (cachedResolvingResults) {
84      return cachedResolvingResults
85    }
86    if (shouldUseCachedValue) {
87      logger.warn("Warning: Expo modules were resolved multiple times. Probably something is wrong with the project configuration.")
88    }
89    String[] args = convertOptionsToCommandArgs('resolve', this.options)
90    args += ['--json']
91
92    String output = exec(args, projectDir)
93    Object json = new JsonSlurper().parseText(output)
94
95    cachedResolvingResults = json
96    return json
97  }
98
99  boolean shouldUseAAR() {
100    return options?.useAAR == true
101  }
102
103  ExpoModule[] getModules(shouldUseCachedValue=false) {
104    Object json = resolve(shouldUseCachedValue)
105    return json.modules.collect { new ExpoModule(it) }
106  }
107
108  MavenRepo[] getExtraMavenRepos(shouldUseCachedValue=false) {
109    Object json = resolve(shouldUseCachedValue)
110    return json.extraDependencies.androidMavenRepos.collect { new MavenRepo(it) }
111  }
112
113  static String getGeneratedFilePath(Project project) {
114    return Paths.get(
115      project.buildDir.toString(),
116      generatedFilesSrcDir,
117      generatedPackageListNamespace.replace('.', '/'),
118      generatedPackageListFilename
119    ).toString()
120  }
121
122  static void generatePackageList(Project project, Map options) {
123    String[] args = convertOptionsToCommandArgs('generate-package-list', options)
124
125    // Construct absolute path to generated package list.
126    def generatedFilePath = getGeneratedFilePath(project)
127
128    args += [
129      '--namespace',
130      generatedPackageListNamespace,
131      '--target',
132      generatedFilePath
133    ]
134
135    if (options == null) {
136      // Options are provided only when settings.gradle was configured.
137      // If not or opted-out from autolinking, the generated list should be empty.
138      args += '--empty'
139    }
140
141    exec(args, project.rootDir)
142  }
143
144  static String exec(String[] commandArgs, File dir) {
145    Process proc = commandArgs.execute(null, dir)
146    StringBuffer outputStream = new StringBuffer()
147    proc.waitForProcessOutput(outputStream, System.err)
148    return outputStream.toString()
149  }
150
151  static private String[] convertOptionsToCommandArgs(String command, Map options) {
152    String[] args = [
153      'node',
154      '--no-warnings',
155      '--eval',
156      // Resolve the `expo` > `expo-modules-autolinking` chain from the project root
157      'require(require.resolve(\'expo-modules-autolinking\', { paths: [require.resolve(\'expo\')] }))(process.argv.slice(1))',
158      '--',
159      command,
160      '--platform',
161      'android'
162    ]
163
164    def searchPaths = options?.get("searchPaths", options?.get("modulesPaths", null))
165    if (searchPaths) {
166      args += searchPaths
167    }
168
169    if (options?.ignorePaths) {
170      args += '--ignore-paths'
171      args += options.ignorePaths
172    }
173
174    if (options?.exclude) {
175      args += '--exclude'
176      args += options.exclude
177    }
178
179    return args
180  }
181}
182
183class Colors {
184  static final String GREEN = "\u001B[32m"
185  static final String YELLOW = "\u001B[33m"
186  static final String RESET = "\u001B[0m"
187}
188class Emojis {
189  static final String INFORMATION = "\u2139\uFE0F"
190}
191
192// We can't cast a manager that is created in `settings.gradle` to the `ExpoAutolinkingManager`
193// because if someone is using `buildSrc`, the `ExpoAutolinkingManager` class
194// will be loaded by two different class loader - `settings.gradle` will use a diffrent loader.
195// In the JVM, classes are equal only if were loaded by the same loader.
196// There is nothing that we can do in that case, but to make our code safer, we check if the class name is the same.
197def validateExpoAutolinkingManager(manager) {
198  assert ExpoAutolinkingManager.name == manager.getClass().name
199  return manager
200}
201
202// Here we split the implementation, depending on Gradle context.
203// `rootProject` is a `ProjectDescriptor` if this file is imported in `settings.gradle` context,
204// otherwise we can assume it is imported in `build.gradle`.
205if (rootProject instanceof ProjectDescriptor) {
206  // Method to be used in `settings.gradle`. Options passed here will have an effect in `build.gradle` context as well,
207  // i.e. adding the dependencies and generating the package list.
208  ext.useExpoModules = { Map options = [:] ->
209    ExpoAutolinkingManager manager = new ExpoAutolinkingManager(rootProject.projectDir, options)
210    ExpoModule[] modules = manager.getModules()
211    MavenRepo[] extraMavenRepos = manager.getExtraMavenRepos()
212
213    for (module in modules) {
214      for (moduleProject in module.projects) {
215        include(":${moduleProject.name}")
216        project(":${moduleProject.name}").projectDir = new File(moduleProject.sourceDir)
217      }
218      for (modulePlugin in module.plugins) {
219        includeBuild(new File(modulePlugin.sourceDir))
220      }
221    }
222
223    gradle.beforeProject { project ->
224      if (project !== project.rootProject) {
225        return
226      }
227      def rootProject = project
228
229      // Add plugin classpath to the root project
230      for (module in modules) {
231        for (modulePlugin in module.plugins) {
232          rootProject.buildscript.dependencies.add('classpath', "${modulePlugin.group}:${modulePlugin.id}")
233        }
234      }
235
236      // Add extra maven repositories to allprojects
237      for (mavenRepo in extraMavenRepos) {
238        println "Adding extra maven repository - '${mavenRepo.url}'"
239      }
240      rootProject.allprojects { eachProject ->
241        eachProject.repositories {
242          for (mavenRepo in extraMavenRepos) {
243            maven { url = mavenRepo.url }
244          }
245        }
246      }
247    }
248
249    // Apply plugins for all app projects
250    gradle.afterProject { project ->
251      if (!project.plugins.hasPlugin('com.android.application')) {
252        return
253      }
254      for (module in modules) {
255        for (modulePlugin in module.plugins) {
256          println " ${Emojis.INFORMATION}  ${Colors.YELLOW}Applying gradle plugin${Colors.RESET} '${Colors.GREEN}${modulePlugin.id}${Colors.RESET}' (${module.name}@${module.version})"
257          project.plugins.apply(modulePlugin.id)
258        }
259      }
260    }
261
262    // Save the manager in the shared context, so that we can later use it in `build.gradle`.
263    gradle.ext.expoAutolinkingManager = manager
264  }
265} else {
266  def addModule = { DependencyHandler handler, String projectName, Boolean useAAR ->
267    Project dependency = rootProject.project(":${projectName}")
268
269    if (useAAR) {
270      handler.add('api', "${dependency.group}:${projectName}:${dependency.version}")
271    } else {
272      handler.add('api', dependency)
273    }
274  }
275
276  def addDependencies = { DependencyHandler handler, Project project ->
277    def manager = validateExpoAutolinkingManager(gradle.ext.expoAutolinkingManager)
278    def modules = manager.getModules(true)
279
280    if (!modules.length) {
281      return
282    }
283
284    println ''
285    println 'Using expo modules'
286
287    for (module in modules) {
288      // Don't link itself
289      if (module.name == project.name) {
290        continue
291      }
292      // Can remove this once we move all the interfaces into the core.
293      if (module.name.endsWith('-interface')) {
294        continue
295      }
296
297      for (moduleProject in module.projects) {
298        addModule(handler, moduleProject.name, manager.shouldUseAAR())
299        println "  - ${Colors.GREEN}${moduleProject.name}${Colors.RESET} (${module.version})"
300      }
301    }
302
303    println ''
304  }
305
306  // Adding dependencies
307  ext.addExpoModulesDependencies = { DependencyHandler handler, Project project ->
308    // Return early if `useExpoModules` was not called in `settings.gradle`
309    if (!gradle.ext.has('expoAutolinkingManager')) {
310      logger.error('Error: Autolinking is not set up in `settings.gradle`: expo modules won\'t be autolinked.')
311      return
312    }
313
314    def manager = validateExpoAutolinkingManager(gradle.ext.expoAutolinkingManager)
315
316    if (rootProject.findProject(':expo-modules-core')) {
317      // `expo` requires `expo-modules-core` as a dependency, even if autolinking is turned off.
318      addModule(handler, 'expo-modules-core', manager.shouldUseAAR())
319    } else {
320      logger.error('Error: `expo-modules-core` project is not included by autolinking.')
321    }
322
323    // If opted-in not to autolink modules as dependencies
324    if (manager.options == null) {
325      return
326    }
327
328    addDependencies(handler, project)
329  }
330
331  // Generating the package list
332  ext.generatedFilesSrcDir = ExpoAutolinkingManager.generatedFilesSrcDir
333
334  ext.generateExpoModulesPackageList = {
335    // Get options used in `settings.gradle` or null if it wasn't set up.
336    Map options = gradle.ext.has('expoAutolinkingManager') ? gradle.ext.expoAutolinkingManager.options : null
337
338    if (options == null) {
339      // TODO(@tsapeta): Temporarily muted this error — uncomment it once we start migrating from autolinking v1 to v2
340      // logger.error('Autolinking is not set up in `settings.gradle`: generated package list with expo modules will be empty.')
341    }
342    ExpoAutolinkingManager.generatePackageList(project, options)
343  }
344
345  ext.getGenerateExpoModulesPackagesListPath = {
346    return ExpoAutolinkingManager.getGeneratedFilePath(project)
347  }
348
349  ext.getModulesConfig = {
350    if (!gradle.ext.has('expoAutolinkingManager')) {
351      return null
352    }
353
354    def modules = gradle.ext.expoAutolinkingManager.resolve(true).modules
355    return modules.toString()
356  }
357
358  ext.ensureDependeciesWereEvaluated = { Project project ->
359    if (!gradle.ext.has('expoAutolinkingManager')) {
360      return
361    }
362
363    def modules = gradle.ext.expoAutolinkingManager.getModules(true)
364    for (module in modules) {
365      for (moduleProject in module.projects) {
366        def dependency = project.findProject(":${moduleProject.name}")
367        if (dependency == null) {
368          logger.warn("Coudn't find project ${moduleProject.name}. Please, make sure that `useExpoModules` was called in `settings.gradle`.")
369          continue
370        }
371
372        // Prevent circular dependencies
373        if (moduleProject.name == project.name) {
374          continue
375        }
376
377        project.evaluationDependsOn(":${moduleProject.name}")
378      }
379    }
380  }
381}
382