1import groovy.json.JsonSlurper
2import java.nio.file.Paths
3
4// Object representing a module.
5class ExpoModule {
6  // Name of the JavaScript package
7  String name
8
9  // Version of the package, loaded from `package.json`
10  String version
11
12  // Name of the Android project
13  String projectName
14
15  // Path to the folder with Android project
16  String sourceDir
17
18  ExpoModule(Object data) {
19    this.name = data.packageName
20    this.version = data.packageVersion
21    this.projectName = data.projectName
22    this.sourceDir = data.sourceDir
23  }
24}
25
26class ExpoAutolinkingManager {
27  private File projectDir
28  private Map options
29  private Object cachedResolvingResults
30
31  static String generatedPackageListNamespace = 'expo.modules.linker'
32  static String generatedPackageListFilename = 'ExpoModulesPackageList.java'
33  static String generatedFilesSrcDir = 'generated/expo/src/main/java'
34  static String modulesLinkerName = 'expo-modules-linker'
35
36  ExpoAutolinkingManager(File projectDir, Map options = [:]) {
37    this.projectDir = projectDir
38    this.options = options
39  }
40
41  Object resolve() {
42    if (cachedResolvingResults) {
43      return cachedResolvingResults
44    }
45    String[] args = convertOptionsToCommandArgs('resolve', this.options)
46    args += ['--json']
47
48    String output = exec(args)
49    Object json = new JsonSlurper().parseText(output)
50
51    cachedResolvingResults = json
52    return json
53  }
54
55  boolean shouldUseAAR() {
56    return options?.useAAR == true
57  }
58
59  ExpoModule[] getModules() {
60    Object json = resolve()
61    return json.modules.collect { new ExpoModule(it) }
62  }
63
64  static void generatePackageList(Project project, Map options) {
65    String[] args = convertOptionsToCommandArgs('generate-package-list', options)
66
67    // Construct absolute path to generated package list.
68    def generatedFilePath = Paths.get(
69      project.buildDir.toString(),
70      generatedFilesSrcDir,
71      generatedPackageListNamespace.replace('.', '/'),
72      generatedPackageListFilename
73    )
74
75    args += [
76      '--namespace',
77      generatedPackageListNamespace,
78      '--target',
79      generatedFilePath.toString()
80    ]
81
82    if (options == null) {
83      // Options are provided only when settings.gradle was configured.
84      // If not, the generated list should be empty.
85      args += '--empty'
86    }
87
88    exec(args)
89  }
90
91  static String exec(String[] commandArgs) {
92    Process proc = commandArgs.execute()
93    StringBuffer outputStream = new StringBuffer()
94    proc.waitForProcessOutput(outputStream, System.err)
95    return outputStream.toString()
96  }
97
98  static private String[] convertOptionsToCommandArgs(String command, Map options) {
99    String[] args = [
100      'node',
101      '--eval',
102      'require(\'expo-modules-autolinking\')(process.argv.slice(1))',
103      '--',
104      command,
105      '--platform',
106      'android'
107    ]
108
109    if (options?.searchPaths) {
110      args += options.searchPaths
111    }
112
113    if (options?.ignorePaths) {
114      args += '--ignore-paths'
115      args += options.ignorePaths
116    }
117
118    if (options?.exclude) {
119      args += '--exclude'
120      args += options.exclude
121    }
122
123    return args
124  }
125}
126
127class Colors {
128  static final String GREEN = "\u001B[32m"
129  static final String RESET = "\u001B[0m"
130}
131
132// Here we split the implementation, depending on Gradle context.
133// `rootProject` is a `ProjectDescriptor` if this file is imported in `settings.gradle` context,
134// otherwise we can assume it is imported in `build.gradle`.
135if (rootProject instanceof ProjectDescriptor) {
136  // Method to be used in `settings.gradle`. Options passed here will have an effect in `build.gradle` context as well,
137  // i.e. adding the dependencies and generating the package list.
138  ext.useExpoModules = { Map options = [:] ->
139    ExpoAutolinkingManager manager = new ExpoAutolinkingManager(rootProject.projectDir, options)
140    ExpoModule[] modules = manager.getModules()
141
142    for (module in modules) {
143      include(":${module.projectName}")
144      project(":${module.projectName}").projectDir = new File(module.sourceDir)
145
146      if (module.projectName == "expo-modules-core") {
147        String modulesLinkerPath = Paths.get(
148          module.sourceDir,
149          "..",
150          "android-linker"
151        ).toString()
152
153        include(":${ExpoAutolinkingManager.modulesLinkerName}")
154        project(":${ExpoAutolinkingManager.modulesLinkerName}").projectDir = new File(modulesLinkerPath)
155      }
156    }
157
158    // Save the manager in the shared context, so that we can later use it in `build.gradle`.
159    gradle.ext.expoAutolinkingManager = manager
160  }
161} else {
162  def addDependencies = { DependencyHandler handler, Closure<Project> projectNameResolver ->
163    // Return early if `useExpoModules` was not called in `settings.gradle`
164    if (!gradle.ext.has('expoAutolinkingManager')) {
165      // TODO(@tsapeta): Temporarily muted this error — uncomment it once we start migrating from autolinking v1 to v2
166      // logger.error('Autolinking is not set up in `settings.gradle`: expo modules won\'t be autolinked.')
167      return
168    }
169    ExpoAutolinkingManager manager = gradle.ext.expoAutolinkingManager
170    def modules = manager.getModules()
171
172    if (!modules.length) {
173      return
174    }
175
176    println 'Using expo modules'
177
178    for (module in modules) {
179      Project dependency = projectNameResolver(module)
180
181      // Don't link itself
182      if (module.name == "expo-modules-core") {
183        continue
184      }
185
186      if (manager.shouldUseAAR()) {
187        handler.add('api', "${dependency.group}:${module.projectName}:${dependency.version}")
188      } else {
189        handler.add('api', dependency)
190      }
191
192      // Can remove this once we move all the interfaces into the core.
193      if (dependency.name.endsWith('-interface')) {
194        continue
195      }
196
197      println "— ${Colors.GREEN}${module.name}${Colors.RESET} (${module.version})"
198    }
199  }
200
201  def findAppProject = { Project project ->
202    return project.rootProject.subprojects.find {
203      it.plugins.find { it.class.name == "com.android.build.gradle.AppPlugin" }
204    }
205  }
206
207  ext.addLinkerPackage = { Project project ->
208    Map options = gradle.ext.has('expoAutolinkingManager') ? gradle.ext.expoAutolinkingManager.options : null
209    if (options == null) {
210      return
211    }
212
213    if (options.autolink == false) {
214      return
215    }
216
217    Project appProject = findAppProject(project)
218    if (appProject == null) {
219      logger.warn("Can't add `expo-modules-linker` automatically. The app android project wasn't found.")
220      return
221    }
222
223    Project linkerProject = rootProject.findProject(":${ExpoAutolinkingManager.modulesLinkerName}")
224    if (linkerProject == null) {
225      logger.warn("Can't find `expo-modules-linker` project. Please, make sure that `useExpoModules` was called in `settings.gradle`.")
226      return
227    }
228
229    if (appProject.configurations.findByName("implementation")) {
230      appProject.dependencies.add('implementation', linkerProject)
231    } else {
232      logger.warn("Can't add `expo-modules-linker` automatically. Couldn't find configuration.")
233    }
234  }
235
236  // Adding dependencies
237  ext.addExpoModulesDependencies = { DependencyHandler handler, Project project ->
238    // `addExpoModulesDependencies` can't be called in `expo-modules-core`, because it would lead to the circular dependency.
239    // For some reason Gradle sometimes tries to call this function in `expo-modules-core` - maybe because `expo-modules-core` adds `expo-modules-linker`
240    // and Gradle tries to parse linker project with the wrong context. So for now, we're just ignoring that and waiting for the correct invocation.
241    if (project.path == ":expo-modules-core" || project.path.startsWith("host.exp.exponent:expo-modules-core")) {
242      logger.warn("`addExpoModulesDependencies` was called with the `expo-modules-core` project.")
243      return
244    }
245    addDependencies(handler) { module -> rootProject.project(":${module.projectName}") }
246  }
247
248  // Generating the package list
249  ext.generatedFilesSrcDir = ExpoAutolinkingManager.generatedFilesSrcDir
250
251  ext.generateExpoModulesPackageList = {
252    // Get options used in `settings.gradle` or null if it wasn't set up.
253    Map options = gradle.ext.has('expoAutolinkingManager') ? gradle.ext.expoAutolinkingManager.options : null
254
255    if (options == null) {
256      // TODO(@tsapeta): Temporarily muted this error — uncomment it once we start migrating from autolinking v1 to v2
257      // logger.error('Autolinking is not set up in `settings.gradle`: generated package list with expo modules will be empty.')
258    }
259    ExpoAutolinkingManager.generatePackageList(project, options)
260  }
261
262  ext.ensureDependeciesWereEvaluated = { Project project ->
263    if (!gradle.ext.has('expoAutolinkingManager')) {
264      return
265    }
266
267    def modules = gradle.ext.expoAutolinkingManager.getModules()
268    for (module in modules) {
269      def dependency = project.findProject(":${module.projectName}")
270      if (dependency == null) {
271        logger.warn("Coudn't find project ${module.projectName}. Please, make sure that `useExpoModules` was called in `settings.gradle`.")
272        continue
273      }
274
275      if (module.projectName == "expo-modules-core") {
276        continue
277      }
278
279      project.evaluationDependsOn(":${module.projectName}")
280    }
281  }
282}
283