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      'require(\'expo-modules-autolinking\')(process.argv.slice(1))',
157      '--',
158      command,
159      '--platform',
160      'android'
161    ]
162
163    def searchPaths = options?.get("searchPaths", options?.get("modulesPaths", null))
164    if (searchPaths) {
165      args += searchPaths
166    }
167
168    if (options?.ignorePaths) {
169      args += '--ignore-paths'
170      args += options.ignorePaths
171    }
172
173    if (options?.exclude) {
174      args += '--exclude'
175      args += options.exclude
176    }
177
178    return args
179  }
180}
181
182class Colors {
183  static final String GREEN = "\u001B[32m"
184  static final String YELLOW = "\u001B[33m"
185  static final String RESET = "\u001B[0m"
186}
187class Emojis {
188  static final String INFORMATION = "\u2139\uFE0F"
189}
190
191// We can't cast a manager that is created in `settings.gradle` to the `ExpoAutolinkingManager`
192// because if someone is using `buildSrc`, the `ExpoAutolinkingManager` class
193// will be loaded by two different class loader - `settings.gradle` will use a diffrent loader.
194// In the JVM, classes are equal only if were loaded by the same loader.
195// 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.
196def validateExpoAutolinkingManager(manager) {
197  assert ExpoAutolinkingManager.name == manager.getClass().name
198  return manager
199}
200
201// Here we split the implementation, depending on Gradle context.
202// `rootProject` is a `ProjectDescriptor` if this file is imported in `settings.gradle` context,
203// otherwise we can assume it is imported in `build.gradle`.
204if (rootProject instanceof ProjectDescriptor) {
205  // Method to be used in `settings.gradle`. Options passed here will have an effect in `build.gradle` context as well,
206  // i.e. adding the dependencies and generating the package list.
207  ext.useExpoModules = { Map options = [:] ->
208    ExpoAutolinkingManager manager = new ExpoAutolinkingManager(rootProject.projectDir, options)
209    ExpoModule[] modules = manager.getModules()
210    MavenRepo[] extraMavenRepos = manager.getExtraMavenRepos()
211
212    for (module in modules) {
213      for (moduleProject in module.projects) {
214        include(":${moduleProject.name}")
215        project(":${moduleProject.name}").projectDir = new File(moduleProject.sourceDir)
216      }
217      for (modulePlugin in module.plugins) {
218        includeBuild(new File(modulePlugin.sourceDir))
219      }
220    }
221
222    gradle.beforeProject { project ->
223      if (project !== project.rootProject) {
224        return
225      }
226      def rootProject = project
227
228      // Add plugin classpath to the root project
229      for (module in modules) {
230        for (modulePlugin in module.plugins) {
231          rootProject.buildscript.dependencies.add('classpath', "${modulePlugin.group}:${modulePlugin.id}")
232        }
233      }
234
235      // Add extra maven repositories to allprojects
236      for (mavenRepo in extraMavenRepos) {
237        println "Adding extra maven repository - '${mavenRepo.url}'"
238      }
239      rootProject.allprojects { eachProject ->
240        eachProject.repositories {
241          for (mavenRepo in extraMavenRepos) {
242            maven { url = mavenRepo.url }
243          }
244        }
245      }
246    }
247
248    // Apply plugins for all app projects
249    gradle.afterProject { project ->
250      if (!project.plugins.hasPlugin('com.android.application')) {
251        return
252      }
253      for (module in modules) {
254        for (modulePlugin in module.plugins) {
255          println " ${Emojis.INFORMATION}  ${Colors.YELLOW}Applying gradle plugin${Colors.RESET} '${Colors.GREEN}${modulePlugin.id}${Colors.RESET}' (${module.name}@${module.version})"
256          project.plugins.apply(modulePlugin.id)
257        }
258      }
259    }
260
261    // Save the manager in the shared context, so that we can later use it in `build.gradle`.
262    gradle.ext.expoAutolinkingManager = manager
263  }
264} else {
265  def addModule = { DependencyHandler handler, String projectName, Boolean useAAR ->
266    Project dependency = rootProject.project(":${projectName}")
267
268    if (useAAR) {
269      handler.add('api', "${dependency.group}:${projectName}:${dependency.version}")
270    } else {
271      handler.add('api', dependency)
272    }
273  }
274
275  def addDependencies = { DependencyHandler handler, Project project ->
276    def manager = validateExpoAutolinkingManager(gradle.ext.expoAutolinkingManager)
277    def modules = manager.getModules(true)
278
279    if (!modules.length) {
280      return
281    }
282
283    println ''
284    println 'Using expo modules'
285
286    for (module in modules) {
287      // Don't link itself
288      if (module.name == project.name) {
289        continue
290      }
291      // Can remove this once we move all the interfaces into the core.
292      if (module.name.endsWith('-interface')) {
293        continue
294      }
295
296      for (moduleProject in module.projects) {
297        addModule(handler, moduleProject.name, manager.shouldUseAAR())
298        println "  - ${Colors.GREEN}${moduleProject.name}${Colors.RESET} (${module.version})"
299      }
300    }
301
302    println ''
303  }
304
305  // Adding dependencies
306  ext.addExpoModulesDependencies = { DependencyHandler handler, Project project ->
307    // Return early if `useExpoModules` was not called in `settings.gradle`
308    if (!gradle.ext.has('expoAutolinkingManager')) {
309      logger.error('Error: Autolinking is not set up in `settings.gradle`: expo modules won\'t be autolinked.')
310      return
311    }
312
313    def manager = validateExpoAutolinkingManager(gradle.ext.expoAutolinkingManager)
314
315    if (rootProject.findProject(':expo-modules-core')) {
316      // `expo` requires `expo-modules-core` as a dependency, even if autolinking is turned off.
317      addModule(handler, 'expo-modules-core', manager.shouldUseAAR())
318    } else {
319      logger.error('Error: `expo-modules-core` project is not included by autolinking.')
320    }
321
322    // If opted-in not to autolink modules as dependencies
323    if (manager.options == null) {
324      return
325    }
326
327    addDependencies(handler, project)
328  }
329
330  // Generating the package list
331  ext.generatedFilesSrcDir = ExpoAutolinkingManager.generatedFilesSrcDir
332
333  ext.generateExpoModulesPackageList = {
334    // Get options used in `settings.gradle` or null if it wasn't set up.
335    Map options = gradle.ext.has('expoAutolinkingManager') ? gradle.ext.expoAutolinkingManager.options : null
336
337    if (options == null) {
338      // TODO(@tsapeta): Temporarily muted this error — uncomment it once we start migrating from autolinking v1 to v2
339      // logger.error('Autolinking is not set up in `settings.gradle`: generated package list with expo modules will be empty.')
340    }
341    ExpoAutolinkingManager.generatePackageList(project, options)
342  }
343
344  ext.getGenerateExpoModulesPackagesListPath = {
345    return ExpoAutolinkingManager.getGeneratedFilePath(project)
346  }
347
348  ext.getModulesConfig = {
349    if (!gradle.ext.has('expoAutolinkingManager')) {
350      return null
351    }
352
353    def modules = gradle.ext.expoAutolinkingManager.resolve(true).modules
354    return modules.toString()
355  }
356
357  ext.ensureDependeciesWereEvaluated = { Project project ->
358    if (!gradle.ext.has('expoAutolinkingManager')) {
359      return
360    }
361
362    def modules = gradle.ext.expoAutolinkingManager.getModules(true)
363    for (module in modules) {
364      for (moduleProject in module.projects) {
365        def dependency = project.findProject(":${moduleProject.name}")
366        if (dependency == null) {
367          logger.warn("Coudn't find project ${moduleProject.name}. Please, make sure that `useExpoModules` was called in `settings.gradle`.")
368          continue
369        }
370
371        // Prevent circular dependencies
372        if (moduleProject.name == project.name) {
373          continue
374        }
375
376        project.evaluationDependsOn(":${moduleProject.name}")
377      }
378    }
379  }
380}
381