first commit

This commit is contained in:
2026-03-10 16:18:05 +00:00
commit 11f9c069b5
31635 changed files with 3187747 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
# expo-modules-plugin
This project contains two Gradle plugins that are used to inject the necessary dependencies and configurations into an Android project that uses Expo modules. It also provides a shared project that contains common code for both plugins.
### `expo-autolinking-settings-plugin`
The settings plugin is an entry point for our setup. It should be applied to the root `settings.gradle` file of the application.
Responsibilities:
- Add all modules into the project hierarchy; modules won't be added to the dependency graph. The `expo` package will depend on them rather than adding them directly to the app project.
- Add extra Maven repositories.
- Link and apply custom plugins.
- Expose autolinking configuration.
### `expo-autolinking-plugin`
This plugin shouldn't be applied directly by the end user. It'll be applied by the `expo` package.
Responsibilities:
- Ensure the dependencies are evaluated before the `expo` package.
- Add previously linked modules to the dependency graph.
- Create a task that will generate the package list file.
### `shared`
This project contains common code for both plugins.

View File

@@ -0,0 +1,4 @@
plugins {
kotlin("jvm") version "2.1.20" apply false
id("java-gradle-plugin")
}

View File

@@ -0,0 +1,41 @@
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
kotlin("jvm")
kotlin("plugin.serialization") version "1.9.24"
}
repositories {
mavenCentral()
}
dependencies {
api("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
testImplementation("junit:junit:4.13.2")
testImplementation("com.google.truth:truth:1.1.2")
}
java {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
tasks.withType<KotlinCompile> {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
}
}
group = "expo.modules"
version = "1.0"
tasks.withType<Test>().configureEach {
testLogging {
exceptionFormat = TestExceptionFormat.FULL
showExceptions = true
showCauses = true
showStackTraces = true
}
}

View File

@@ -0,0 +1,81 @@
package expo.modules.plugin
/**
* Builder for creating command to run using `expo-modules-autolinking`.
*/
class AutolinkingCommandBuilder {
/**
* Command for finding and running `expo-modules-autolinking`.
*/
private val baseCommand = listOf(
"node",
"--no-warnings",
"--eval",
"require('expo/bin/autolinking')",
"expo-modules-autolinking"
)
private val platform = listOf(
"--platform",
"android"
)
private var autolinkingCommand = emptyList<String>()
private var useJson = emptyList<String>()
private val optionsMap = mutableSetOf<Pair<String, String>>()
private var searchPaths = emptyList<String>()
/**
* Set the autolinking command to run.
*/
fun command(command: String) = apply {
autolinkingCommand = listOf(command)
}
/**
* Add an option to the command.
*/
fun option(key: String, value: String) = apply {
optionsMap.add(key to value)
}
/**
* Add a list of values as an option to the command.
*/
fun option(key: String, value: List<String>) = apply {
value.forEach { optionsMap.add(key to it) }
}
/**
* Whether it should output json.
*/
fun useJson() = apply {
useJson = listOf("--json")
}
/**
* Set the search paths for the autolinking script.
*/
fun searchPaths(paths: List<String>) = apply {
searchPaths = paths
}
fun useAutolinkingOptions(autolinkingOptions: AutolinkingOptions) = apply {
autolinkingOptions.exclude?.let { option(EXCLUDE_KEY, it) }
autolinkingOptions.searchPaths?.let { searchPaths(it) }
}
fun build(): List<String> {
val command = baseCommand +
autolinkingCommand +
platform +
useJson +
optionsMap.map { (key, value) -> listOf("--$key", value) }.flatMap { it } +
searchPaths
return Os.windowsAwareCommandLine(command)
}
companion object {
const val EXCLUDE_KEY = "exclude"
}
}

View File

@@ -0,0 +1,42 @@
package expo.modules.plugin
import expo.modules.plugin.configuration.ExpoAutolinkingConfig
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.security.MessageDigest
@Serializable
data class AutolinkingOptions(
val searchPaths: List<String>? = null,
val exclude: List<String>? = null
) {
fun toJson(): String {
return Json.encodeToString(AutolinkingOptions.serializer(), this)
}
companion object {
fun fromJson(jsonString: String): AutolinkingOptions {
return Json.decodeFromString(jsonString)
}
}
}
/**
* Extension that will be added to Gradle, making it possible to access the configuration in all projects.
*/
open class ExpoGradleExtension(
val config: ExpoAutolinkingConfig,
val options: AutolinkingOptions = AutolinkingOptions(),
val projectRoot: java.io.File,
) {
/**
* MD5 hash of the configuration.
* It can be used to determine if the configuration has changed.
*/
val hash: String
get() {
val stringifyConfig = config.toString()
val md = MessageDigest.getInstance("MD5")
return md.digest(stringifyConfig.toByteArray()).contentToString()
}
}

View File

@@ -0,0 +1,13 @@
package expo.modules.plugin
object Os {
fun isWindows(): Boolean =
System.getProperty("os.name")?.lowercase()?.contains("windows") == true
fun windowsAwareCommandLine(args: List<String>): List<String> =
if (isWindows()) {
listOf("cmd", "/c") + args
} else {
args
}
}

View File

@@ -0,0 +1,151 @@
package expo.modules.plugin.configuration
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
@Serializable
data class ExpoAutolinkingConfig(
val modules: List<ExpoModule> = emptyList(),
val extraDependencies: List<MavenRepo> = emptyList(),
val coreFeatures: List<String> = emptyList(),
val configuration: Configuration = Configuration()
) {
/**
* Returns all gradle projects from all modules.
*/
val allProjects: List<GradleProject>
get() = modules.flatMap { it.projects }
/**
* Returns all plugins from all modules.
*/
val allPlugins: List<GradlePlugin>
get() = modules.flatMap { it.plugins }
/**
* Returns all AAR projects from all modules.
*/
val allAarProjects: List<GradleAarProject>
get() = modules.flatMap { it.projects }.flatMap { it.aarProjects }
fun toJson(): String {
return Json.encodeToString(serializer(), this)
}
companion object {
private val jsonDecoder by lazy {
val module = SerializersModule {
polymorphicDefaultDeserializer(MavenCredentials::class) { MavenCredentialsSerializer }
}
Json {
// We don't want to fail on a unknown key
ignoreUnknownKeys = true
serializersModule = module
}
}
/**
* Decodes the `ExpoAutolinkingConfig` from given string.
*/
fun decodeFromString(input: String): ExpoAutolinkingConfig {
return jsonDecoder.decodeFromString(input)
}
}
}
@Serializable
data class Configuration(
val buildFromSource: List<String> = emptyList<String>()
) {
val buildFromSourceRegex by lazy {
buildFromSource.map { it.toRegex() }
}
}
/**
* Object representing a maven repository
*/
@Serializable
data class MavenRepo(
val url: String,
val credentials: MavenCredentials? = null,
val authentication: String? = null
)
/**
* Object representing a module.
*/
@Serializable
data class ExpoModule(
val packageName: String,
val packageVersion: String,
val projects: List<GradleProject> = emptyList(),
val plugins: List<GradlePlugin> = emptyList(),
)
@Serializable
data class Publication(
val groupId: String,
val artifactId: String,
val version: String,
val repository: String
)
/**
* Object representing a gradle project.
*/
@Serializable
data class GradleProject(
val name: String,
val sourceDir: String,
val publication: Publication? = null,
val aarProjects: List<GradleAarProject> = emptyList(),
val modules: List<ModuleInfo> = emptyList(),
val services: List<String> = emptyList(),
val packages: List<String> = emptyList(),
val shouldUsePublicationScriptPath: String? = null,
@Transient val configuration: GradleProjectConfiguration = GradleProjectConfiguration()
) {
/**
* Returns whether the publication was defined and should be used.
*/
val usePublication: Boolean
get() = publication != null && configuration.shouldUsePublication
}
data class GradleProjectConfiguration(
var shouldUsePublication: Boolean = false
)
/**
* Object representing a module with name and classifier
*/
@Serializable
data class ModuleInfo(
val classifier: String,
val name: String?,
)
/**
* Object representing a gradle plugin
*/
@Serializable
data class GradlePlugin(
val id: String,
val group: String,
val sourceDir: String,
val applyToRootProject: Boolean = true
)
/**
* Object representing an gradle project containing AAR file
*/
@Serializable
data class GradleAarProject(
val name: String,
val aarFilePath: String,
val projectDir: String
)

View File

@@ -0,0 +1,44 @@
package expo.modules.plugin.configuration
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonContentPolymorphicSerializer
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.jsonObject
/**
* Based type of maven credentials object.
*/
sealed interface MavenCredentials
@Serializable
data class BasicMavenCredentials(
val username: String,
val password: String
) : MavenCredentials
@Serializable
data class HttpHeaderMavenCredentials(
val name: String,
val value: String
) : MavenCredentials
@Serializable
data class AWSMavenCredentials(
val accessKey: String,
val secretKey: String,
val sessionToken: String? = null
) : MavenCredentials
/**
* Custom deserializer for [MavenCredentials].
* We need to use polymorphic deserialization because we have multiple types of credentials.
* It'll decide based on present fields which type of credentials it is.
*/
object MavenCredentialsSerializer : JsonContentPolymorphicSerializer<MavenCredentials>(MavenCredentials::class) {
override fun selectDeserializer(element: JsonElement) = when {
"username" in element.jsonObject && "password" in element.jsonObject -> BasicMavenCredentials.serializer()
"name" in element.jsonObject && "value" in element.jsonObject -> HttpHeaderMavenCredentials.serializer()
"accessKey" in element.jsonObject && "secretKey" in element.jsonObject -> AWSMavenCredentials.serializer()
else -> throw IllegalStateException("Unknown MavenCredentials type for $element")
}
}

View File

@@ -0,0 +1,11 @@
package expo.modules.plugin.text
object Colors {
const val GREEN = "\u001B[32m"
const val YELLOW = "\u001B[33m"
const val RESET = "\u001B[0m"
}
fun Any?.withColor(color: String): String {
return "$color$this${Colors.RESET}"
}

View File

@@ -0,0 +1,6 @@
package expo.modules.plugin.text
object Emojis {
const val INFORMATION = "\u2139\uFE0F"
const val GEAR = "\u2699"
}

View File

@@ -0,0 +1,147 @@
package com.modules.plugin.connfiguration
import com.google.common.truth.Truth
import expo.modules.plugin.configuration.AWSMavenCredentials
import expo.modules.plugin.configuration.BasicMavenCredentials
import expo.modules.plugin.configuration.ExpoAutolinkingConfig
import expo.modules.plugin.configuration.HttpHeaderMavenCredentials
import org.junit.Test
class ExpoAutolinkingConfigTest {
@Test
fun `can deserialize config`() {
// language=JSON
val mockedConfig = """
{
"extraDependencies": [],
"modules": [
{
"packageName": "expo",
"packageVersion": "52.0.11",
"projects": [
{
"name": "expo",
"sourceDir": "/Users/lukasz/work/expo/packages/expo/android"
}
],
"modules": [
"expo.modules.fetch.ExpoFetchModule"
]
},
{
"packageName": "expo-network-addons",
"packageVersion": "0.7.0",
"projects": [
{
"name": "expo-network-addons",
"sourceDir": "/Users/lukasz/work/expo/packages/expo-network-addons/android"
}
],
"plugins": [
{
"id": "expo-network-addons-gradle-plugin",
"group": "expo.modules",
"sourceDir": "/Users/lukasz/work/expo/packages/expo-network-addons/expo-network-addons-gradle-plugin",
"applyToRootProject": true
}
],
"modules": []
}
]
}
""".trimIndent()
val config = ExpoAutolinkingConfig.decodeFromString(mockedConfig)
Truth.assertThat(config.allProjects.map { it.name })
.containsExactly("expo", "expo-network-addons")
val expoModule = config.modules.find { it.packageName == "expo" }
val expoNetworkAddonsModule = config.modules.find { it.packageName == "expo-network-addons" }
Truth.assertThat(expoModule).isNotNull()
Truth.assertThat(expoNetworkAddonsModule).isNotNull()
expoModule!!
expoNetworkAddonsModule!!
Truth.assertThat(expoModule.projects.firstOrNull()?.sourceDir)
.isEqualTo("/Users/lukasz/work/expo/packages/expo/android")
Truth.assertThat(expoModule.modules.firstOrNull())
.isEqualTo("expo.modules.fetch.ExpoFetchModule")
Truth.assertThat(expoNetworkAddonsModule.projects.firstOrNull()?.sourceDir)
.isEqualTo("/Users/lukasz/work/expo/packages/expo-network-addons/android")
Truth.assertThat(expoNetworkAddonsModule.plugins.firstOrNull()?.id)
.isEqualTo("expo-network-addons-gradle-plugin")
}
@Test
fun `can deserialize extra dependencies`() {
// language=JSON
val mockedConfig = """
{
"modules": [],
"extraDependencies": [
{
"url": "repo1",
"credentials": {
"username": "user",
"password": "password"
},
"authentication": "basic"
},
{
"url": "repo2",
"credentials": {
"name": "name",
"value": "value"
},
"authentication": "header"
},
{
"url": "repo3",
"credentials": {
"accessKey": "accessKey",
"secretKey": "secretKey"
},
"authentication": "digest"
}
]
}
""".trimIndent()
val config = ExpoAutolinkingConfig.decodeFromString(mockedConfig)
val repo1 = config.extraDependencies.find { it.url == "repo1" }
val repo2 = config.extraDependencies.find { it.url == "repo2" }
val repo3 = config.extraDependencies.find { it.url == "repo3" }
Truth.assertThat(repo1).isNotNull()
Truth.assertThat(repo2).isNotNull()
Truth.assertThat(repo3).isNotNull()
repo1!!
repo2!!
repo3!!
Truth.assertThat(repo1.authentication).isEqualTo("basic")
Truth.assertThat(repo1.credentials).isInstanceOf(BasicMavenCredentials::class.java)
val basicCredentials = repo1.credentials as BasicMavenCredentials
Truth.assertThat(basicCredentials.username).isEqualTo("user")
Truth.assertThat(basicCredentials.password).isEqualTo("password")
Truth.assertThat(repo2.authentication).isEqualTo("header")
Truth.assertThat(repo2.credentials).isInstanceOf(HttpHeaderMavenCredentials::class.java)
val headerCredentials = repo2.credentials as HttpHeaderMavenCredentials
Truth.assertThat(headerCredentials.name).isEqualTo("name")
Truth.assertThat(headerCredentials.value).isEqualTo("value")
Truth.assertThat(repo3.authentication).isEqualTo("digest")
Truth.assertThat(repo3.credentials).isInstanceOf(AWSMavenCredentials::class.java)
val awsCredentials = repo3.credentials as AWSMavenCredentials
Truth.assertThat(awsCredentials.accessKey).isEqualTo("accessKey")
Truth.assertThat(awsCredentials.secretKey).isEqualTo("secretKey")
}
}

View File

@@ -0,0 +1,44 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
kotlin("jvm")
id("java-gradle-plugin")
}
repositories {
google()
mavenCentral()
}
dependencies {
implementation(project(":expo-autolinking-plugin-shared"))
implementation(gradleApi())
compileOnly("com.android.tools.build:gradle:8.5.0")
}
java {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
tasks.withType<KotlinCompile> {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
group = "expo.modules"
gradlePlugin {
plugins {
create("expoAutolinkingPlugin") {
id = "expo-autolinking"
implementationClass = "expo.modules.plugin.ExpoAutolinkingPlugin"
}
create("expoRootProjectPlugin") {
id = "expo-root-project"
implementationClass = "expo.modules.plugin.ExpoRootProjectPlugin"
}
}
}

View File

@@ -0,0 +1,165 @@
package expo.modules.plugin
import com.android.build.api.variant.AndroidComponentsExtension
import com.android.build.gradle.BaseExtension
import com.android.build.gradle.internal.tasks.factory.dependsOn
import expo.modules.plugin.configuration.ExpoModule
import expo.modules.plugin.text.Colors
import expo.modules.plugin.text.withColor
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.Task
import org.gradle.api.file.Directory
import org.gradle.api.file.RegularFile
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.TaskProvider
import java.nio.file.Paths
const val generatedPackageListNamespace = "expo.modules"
const val generatedPackageListFilename = "ExpoModulesPackageList.kt"
const val generatedFilesSrcDir = "generated/expo/src/main/java"
open class ExpoAutolinkingPlugin : Plugin<Project> {
override fun apply(project: Project) {
val gradleExtension = project.gradle.extensions.findByType(ExpoGradleExtension::class.java)
?: throw IllegalStateException("`ExpoGradleExtension` not found. Please, make sure that `useExpoModules` was called in `settings.gradle`.")
val config = gradleExtension.config
project.logger.quiet("")
project.logger.quiet("Using expo modules")
val appProject = findAppProject(project.rootProject)
appProject?.let { copyAppDimensionsAndFlavorsToProject(project, it) }
val (prebuiltProjects, projects) = config.allProjects.partition { project ->
project.usePublication
}
project.withSubprojects(projects) { subproject ->
// Ensures that dependencies are resolved before the project is evaluated.
project.evaluationDependsOn(subproject.path)
// Adds the subproject as a dependency to the current project (expo package).
project.dependencies.add("api", subproject)
project.logger.quiet(" - ${subproject.name.withColor(Colors.GREEN)} (${subproject.version})")
}
prebuiltProjects.forEach { prebuiltProject ->
val publication = requireNotNull(prebuiltProject.publication)
project.dependencies.add("api", "${publication.groupId}:${publication.artifactId}:${publication.version}")
project.logger.quiet(" - ${"[\uD83D\uDCE6]".withColor(Colors.YELLOW)} ${prebuiltProject.name.withColor(Colors.GREEN)} (${publication.version})")
}
project.logger.quiet("")
// Creates a task that generates a list of expo modules.
val generatePackagesList = createGeneratePackagesListTask(project, gradleExtension.config.modules, gradleExtension.hash)
// Ensures that the task is executed before the build.
project.tasks
.named("preBuild", Task::class.java)
.dependsOn(generatePackagesList)
// Adds the generated file to the source set.
project.extensions.getByType(AndroidComponentsExtension::class.java).finalizeDsl { ext ->
ext
.sourceSets
.getByName("main")
.java
.srcDir(getPackageListDir(project))
}
}
fun getPackageListDir(project: Project): Provider<Directory> {
return project.layout.buildDirectory.dir(generatedFilesSrcDir)
}
fun getPackageListFile(project: Project): Provider<RegularFile> {
val packageListRelativePath = Paths.get(
generatedFilesSrcDir,
generatedPackageListNamespace.replace('.', '/'),
generatedPackageListFilename
).toString()
return project.layout.buildDirectory.file(packageListRelativePath)
}
fun createGeneratePackagesListTask(project: Project, modules: List<ExpoModule>, hash: String): TaskProvider<GeneratePackagesListTask> {
return project.tasks.register("generatePackagesList", GeneratePackagesListTask::class.java) {
it.hash.set(hash)
it.namespace.set(generatedPackageListNamespace)
it.outputFile.set(getPackageListFile(project))
it.modules = modules
}
}
private fun findAppProject(root: Project): Project? {
return root.allprojects.firstOrNull { it.plugins.hasPlugin("com.android.application") }
}
private fun copyAppDimensionsAndFlavorsToProject(
project: Project,
appProject: Project
) {
val appAndroid = appProject.extensions.findByName("android") as? BaseExtension ?: run {
return
}
val consumerAndroid = project.extensions.findByName("android") as? BaseExtension ?: run {
return
}
val appDimensions = syncFlavorDimensions(project, consumerAndroid, appAndroid)
copyMissingProductFlavors(project, consumerAndroid, appAndroid, appDimensions)
}
private fun syncFlavorDimensions(
project: Project,
consumerAndroid: BaseExtension,
appAndroid: BaseExtension
): List<String> {
val appDimensions = appAndroid
.flavorDimensionList
.takeIf { it.isNotEmpty() }
?: return emptyList()
val consumerDimensions = (consumerAndroid.flavorDimensionList).toMutableList()
val dimensionsAdded = appDimensions.any { dimension ->
if (dimension !in consumerDimensions) {
consumerDimensions.add(dimension)
true
} else {
false
}
}
if (dimensionsAdded) {
consumerAndroid.flavorDimensions(*consumerDimensions.toTypedArray())
project.logger.quiet(" -> Copied/merged flavorDimensions: ${consumerDimensions.joinToString()}")
}
return appDimensions
}
private fun copyMissingProductFlavors(
project: Project,
consumerAndroid: BaseExtension,
appAndroid: BaseExtension,
appDimensions: List<String>
) {
val appFlavors = appAndroid.productFlavors
val consumerFlavors = consumerAndroid.productFlavors
val existingFlavorNames = consumerFlavors.map { it.name }.toSet()
appFlavors.forEach { appFlavor ->
if (appFlavor.name !in existingFlavorNames) {
val dimension = appFlavor.dimension ?: appDimensions.singleOrNull()
consumerFlavors.create(appFlavor.name).apply {
this.dimension = dimension
}
project.logger.quiet(" -> Created flavor '${appFlavor.name}' (dimension='$dimension') in :${project.path}")
}
}
}
}

View File

@@ -0,0 +1,74 @@
package expo.modules.plugin
import expo.modules.plugin.text.Colors
import expo.modules.plugin.text.withColor
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalog
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.api.plugins.ExtraPropertiesExtension
import org.gradle.internal.extensions.core.extra
import java.util.Optional
import kotlin.jvm.optionals.getOrNull
class ExpoRootProjectPlugin : Plugin<Project> {
override fun apply(rootProject: Project) {
val versionCatalogs = rootProject.extensions.getByType(VersionCatalogsExtension::class.java)
val libs = versionCatalogs.find("expoLibs")
with(rootProject) {
defineDefaultProperties(libs)
}
}
}
fun Project.defineDefaultProperties(versionCatalogs: Optional<VersionCatalog>) {
// Android related
val buildTools = extra.setIfNotExist("buildToolsVersion") { versionCatalogs.getVersionOrDefault("buildTools", "35.0.0") }
val minSdk = extra.setIfNotExist("minSdkVersion") { Integer.parseInt(versionCatalogs.getVersionOrDefault("minSdk", "24")) }
val compileSdk = extra.setIfNotExist("compileSdkVersion") { Integer.parseInt(versionCatalogs.getVersionOrDefault("compileSdk", "35")) }
val targetSdk = extra.setIfNotExist("targetSdkVersion") { Integer.parseInt(versionCatalogs.getVersionOrDefault("targetSdk", "35")) }
val ndk = extra.setIfNotExist("ndkVersion") { versionCatalogs.getVersionOrDefault("ndkVersion", "27.1.12297006") }
// Kotlin related
val kotlin = extra.setIfNotExist("kotlinVersion") { versionCatalogs.getVersionOrDefault("kotlin", "2.0.21") }
val ksp = extra.setIfNotExist("kspVersion") {
versionCatalogs.getVersionOrDefault("ksp") {
try {
return@getVersionOrDefault KSPLookup.getValue(extra.get("kotlinVersion") as String)
} catch (e: Throwable) {
throw IllegalStateException(
"Can't find KSP version for Kotlin version '${extra.get("kotlinVersion")}'. You're probably using an unsupported version of Kotlin. Supported versions are: '${KSPLookup.keys.joinToString(", ")}'",
e
)
}
}
}
project.logger.quiet("""
${"[ExpoRootProject]".withColor(Colors.GREEN)} Using the following versions:
- buildTools: ${buildTools.withColor(Colors.GREEN)}
- minSdk: ${minSdk.withColor(Colors.GREEN)}
- compileSdk: ${compileSdk.withColor(Colors.GREEN)}
- targetSdk: ${targetSdk.withColor(Colors.GREEN)}
- ndk: ${ndk.withColor(Colors.GREEN)}
- kotlin: ${kotlin.withColor(Colors.GREEN)}
- ksp: ${ksp.withColor(Colors.GREEN)}
""".trimIndent())
}
inline fun ExtraPropertiesExtension.setIfNotExist(name: String, value: () -> Any): Any? {
if (!has(name)) {
set(name, value())
}
return get(name)
}
fun Optional<VersionCatalog>.getVersionOrDefault(name: String, default: String): String {
return getOrNull()?.findVersion(name)?.getOrNull()?.requiredVersion ?: default
}
fun Optional<VersionCatalog>.getVersionOrDefault(name: String, default: () -> String): String {
return getOrNull()?.findVersion(name)?.getOrNull()?.requiredVersion ?: default.invoke()
}

View File

@@ -0,0 +1,117 @@
package expo.modules.plugin
import expo.modules.plugin.configuration.ExpoModule
import org.gradle.api.DefaultTask
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction
/**
* Task that generates a list of packages that should be included in your app's runtime.
*/
abstract class GeneratePackagesListTask : DefaultTask() {
init {
group = "expo"
}
/**
* Hash of the current configuration.
* Used to invalidate the task when the configuration changes.
*/
@get:Input
abstract val hash: Property<String>
/**
* Java package name under which the package list should be placed.
*/
@get:Input
abstract val namespace: Property<String>
/**
* List of modules.
*/
@get:Internal
lateinit var modules: List<ExpoModule>
/**
* The output file where the package list should be written.
*/
@get:OutputFile
abstract val outputFile: RegularFileProperty
@TaskAction
fun generatePackagesList() {
val target = outputFile.get().asFile
val content = generatePackageListFileContent()
target.writeText(content)
}
private fun generatePackageListFileContent(): String {
return """package ${namespace.get()};
import expo.modules.core.interfaces.Package;
import expo.modules.kotlin.modules.Module;
import expo.modules.kotlin.ModulesProvider;
class ExpoModulesPackageList : ModulesProvider {
companion object {
val packagesList: List<Package> = listOf(
${
modules
.filterNot { it.packageName == "expo" }
.flatMap { module ->
module.projects.flatMap { project ->
project.packages.map { " ${it}()" }
}
}
.joinToString(",\n")
}
)
val modulesMap: Map<Class<out Module>, String?> = mapOf(
${
modules
.flatMap { module ->
module.projects.flatMap { project ->
project.modules.map { (classifier, name) ->
" ${classifier}::class.java to ${name?.let { "\"${it}\"" }}"
}
}
}
.joinToString(",\n")
}
)
@JvmStatic
fun getPackageList(): List<Package> {
return packagesList
}
}
override fun getModulesMap(): Map<Class<out Module>, String?> {
return modulesMap
}
override fun getServices(): List<Class<out expo.modules.kotlin.services.Service>> {
return listOf<Class<out expo.modules.kotlin.services.Service>>(
${
modules
.flatMap { module ->
module.projects.flatMap { project ->
project.services.map { " ${it}::class.java" }
}
}
.joinToString(",\n")
}
)
}
}
""".trimIndent()
}
}

View File

@@ -0,0 +1,20 @@
// Copyright 2015-present 650 Industries. All rights reserved.
// Generated using './scripts/generateKSPLookUp.js'
package expo.modules.plugin
val KSPLookup = mapOf(
"2.2.21" to "2.2.21-2.0.5",
"2.3.1" to "2.3.1",
"2.3.0" to "2.3.0",
"2.2.20" to "2.2.20-2.0.4",
"2.2.10" to "2.2.10-2.0.2",
"2.2.0" to "2.2.0-2.0.2",
"2.1.21" to "2.1.21-2.0.2",
"2.1.20" to "2.1.20-2.0.1",
"2.1.10" to "2.1.10-1.0.31",
"2.1.0" to "2.1.0-1.0.29",
"2.0.21" to "2.0.21-1.0.28",
"2.0.20" to "2.0.20-1.0.25",
"2.0.10" to "2.0.10-1.0.24",
"2.0.0" to "2.0.0-1.0.24"
)

View File

@@ -0,0 +1,26 @@
package expo.modules.plugin
import expo.modules.plugin.configuration.GradleProject
import org.gradle.api.Project
internal fun Project.withSubproject(subprojectConfig: GradleProject, action: (subproject: Project) -> Unit) {
val subprojectPath = ":${subprojectConfig.name}"
val subproject = findProject(subprojectPath)
if (subproject == null) {
logger.warn("Couldn't find project ${subprojectConfig.name}. Please, make sure that `expo-autolinking-settings` plugin was applied in `settings.gradle`.")
return
}
// Prevent circular dependencies
if (subproject == this) {
return
}
action(subproject)
}
internal fun Project.withSubprojects(subprojectsConfig: List<GradleProject>, action: (subproject: Project) -> Unit) {
subprojectsConfig.forEach { subprojectConfig ->
withSubproject(subprojectConfig, action)
}
}

View File

@@ -0,0 +1,54 @@
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
kotlin("jvm")
id("java-gradle-plugin")
}
repositories {
google()
mavenCentral()
}
dependencies {
implementation(project(":expo-autolinking-plugin-shared"))
implementation(gradleApi())
compileOnly("com.android.tools.build:gradle:8.5.0")
testImplementation("junit:junit:4.13.2")
testImplementation("com.google.truth:truth:1.1.2")
testImplementation("io.mockk:mockk:1.14.2")
}
java {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
tasks.withType<KotlinCompile> {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
group = "expo.modules"
gradlePlugin {
plugins {
create("expoAutolinkingSettingsPlugin") {
id = "expo-autolinking-settings"
implementationClass = "expo.modules.plugin.ExpoAutolinkingSettingsPlugin"
}
}
}
tasks.withType<Test>().configureEach {
testLogging {
exceptionFormat = TestExceptionFormat.FULL
showExceptions = true
showCauses = true
showStackTraces = true
}
}

View File

@@ -0,0 +1,11 @@
package expo.modules.plugin
import expo.modules.plugin.configuration.ExpoAutolinkingConfig
import expo.modules.plugin.configuration.GradleProject
import org.gradle.api.Project
fun ExpoAutolinkingConfig.getConfigForProject(gradleProject: Project): GradleProject? {
return allProjects.firstOrNull {
it.name == gradleProject.name
}
}

View File

@@ -0,0 +1,139 @@
package expo.modules.plugin
import org.gradle.api.Action
import org.gradle.api.initialization.Settings
import org.gradle.api.initialization.dsl.VersionCatalogBuilder
import org.gradle.api.model.ObjectFactory
import java.io.File
import javax.inject.Inject
open class ExpoAutolinkingSettingsExtension(
val settings: Settings,
@Inject val objects: ObjectFactory
) {
/**
* The root directory of the react native project.
* Should be used by projects that don't follow the /android folder structure.
*
* Defaults to `settings.rootDir`.
*/
var projectRoot: File = settings.rootDir
/**
* Command that should be provided to `react-native` to resolve the configuration.
*/
val rnConfigCommand by lazy {
val commandBuilder = AutolinkingCommandBuilder()
.command("react-native-config")
.useJson()
if (projectRoot != settings.rootDir) {
commandBuilder.option("project-root", projectRoot.absolutePath)
commandBuilder.option("source-dir", settings.rootDir.absolutePath)
}
commandBuilder.build()
}
/**
* A list of paths relative to the app's root directory where
* the autolinking script should search for Expo modules.
*/
var searchPaths: List<String>? = null
/**
* Package names to exclude when looking up for modules.
*/
var exclude: List<String>? = null
/**
* The file pointing to the React Native Gradle plugin.
*/
val reactNativeGradlePlugin: File by lazy {
File(
settings.providers.exec { env ->
env.workingDir(projectRoot)
env.commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
}.standardOutput.asText.get().trim(),
).parentFile
}
/**
* The file pointing to the React Native root directory.
*/
val reactNative: File by lazy {
File(
settings.providers.exec { env ->
env.workingDir(projectRoot)
env.commandLine("node", "--print", "require.resolve('react-native/package.json')")
}.standardOutput.asText.get().trim(),
).parentFile
}
/**
* Uses Expo modules autolinking.
*/
fun useExpoModules() {
SettingsManager(
settings,
projectRoot,
searchPaths,
exclude
).useExpoModules()
}
fun useExpoVersionCatalog() {
useExpoVersionCatalog(
reactNativeVersionCatalog = null,
override = null
)
}
fun useExpoVersionCatalog(
override: Action<in VersionCatalogBuilder>
) {
useExpoVersionCatalog(
reactNativeVersionCatalog = null,
override = override
)
}
fun useExpoVersionCatalog(
reactNativeVersionCatalog: String?,
override: Action<in VersionCatalogBuilder>?
) {
val baseFile = if (reactNativeVersionCatalog != null) {
File(reactNativeVersionCatalog)
} else {
File(
reactNative,
"gradle/libs.versions.toml"
)
}
val catalogFile = objects.fileCollection().from(baseFile)
val properties = listOf(
"android.buildToolsVersion" to "buildTools",
"android.minSdkVersion" to "minSdk",
"android.compileSdkVersion" to "compileSdk",
"android.targetSdkVersion" to "targetSdk",
"android.kotlinVersion" to "kotlin"
)
settings.dependencyResolutionManagement {
it.versionCatalogs { spec ->
spec.create("expoLibs") { catalog ->
catalog.from(catalogFile)
properties.forEach { (propertyName, name) ->
val property = settings.providers.gradleProperty(propertyName)
if (property.isPresent) {
catalog.version(name, property.get())
}
}
override?.execute(catalog)
}
}
}
}
}

View File

@@ -0,0 +1,82 @@
package expo.modules.plugin
import expo.modules.plugin.gradle.addBuildCache
import expo.modules.plugin.gradle.beforeRootProject
import expo.modules.plugin.gradle.loadLocalProperties
import expo.modules.plugin.text.Colors
import expo.modules.plugin.text.withColor
import expo.modules.plugin.utils.getPropertiesPrefixedBy
import org.gradle.api.Plugin
import org.gradle.api.UnknownProjectException
import org.gradle.api.initialization.Settings
import org.gradle.internal.cc.base.logger
import java.io.File
import java.util.Properties
open class ExpoAutolinkingSettingsPlugin : Plugin<Settings> {
override fun apply(settings: Settings) {
// Adds a property to the settings that indicates that the `expo-autolinking-plugin` is available.
settings.gradle.extensions.extraProperties.set("expoAutolinkingSettingsPlugin", true)
settings.addBuildCache()
// Creates an extension that allows users to link expo modules and add additional configuration.
settings.extensions.create("expoAutolinking", ExpoAutolinkingSettingsExtension::class.java, settings)
val expoGradlePluginsFile = getExpoGradlePluginsFile(settings)
// If the `expo-gradle-plugin` is available, it will be included in the settings.
// It won't be available in our test project or when we decide to prebuild plugin.
if (expoGradlePluginsFile.exists()) {
settings.gradle.beforeRootProject { rootProject ->
// Adds the `expo-autolinking-plugin` to the root project, so it will be available for all subprojects.
rootProject
.buildscript
.dependencies
.apply {
add("classpath", "expo.modules:expo-autolinking-plugin")
add("classpath", "expo.modules:expo-max-sdk-override-plugin")
}
}
// Includes the `expo-gradle-plugin` subproject.
settings.includeBuild(
expoGradlePluginsFile.absolutePath
)
}
configureMaxSdkOverridePlugin(settings)
}
private fun getExpoGradlePluginsFile(settings: Settings): File {
val expoModulesAutolinkingPath =
settings.providers.exec { env ->
env.workingDir(settings.rootDir)
env.commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
}.standardOutput.asText.get().trim()
val expoAutolinkingDir = File(expoModulesAutolinkingPath).parentFile
return File(
expoAutolinkingDir,
"android/expo-gradle-plugin"
)
}
private fun configureMaxSdkOverridePlugin(settings: Settings) {
settings.gradle.beforeRootProject { rootProject ->
try {
rootProject.project(":app") { appProject ->
appProject.pluginManager.withPlugin("com.android.application") {
appProject.pluginManager.apply("expo-max-sdk-override-plugin")
}
}
} catch (e: UnknownProjectException) {
logger.error(
" Failed to apply gradle plugin ".withColor(Colors.RESET)
+ "'expo-max-sdk-override-plugin'".withColor(Colors.GREEN)
+ ". Plugin has failed to find the ':app' project. It will not be applied.".withColor(Colors.RESET)
)
}
}
}
}

View File

@@ -0,0 +1,172 @@
package expo.modules.plugin
import expo.modules.plugin.configuration.ExpoAutolinkingConfig
import expo.modules.plugin.configuration.GradleProject
import expo.modules.plugin.gradle.afterAndroidApplicationProject
import expo.modules.plugin.gradle.applyAarProject
import expo.modules.plugin.gradle.applyPlugin
import expo.modules.plugin.gradle.beforeProject
import expo.modules.plugin.gradle.beforeRootProject
import expo.modules.plugin.gradle.linkAarProject
import expo.modules.plugin.gradle.linkBuildDependence
import expo.modules.plugin.gradle.linkLocalMavenRepository
import expo.modules.plugin.gradle.linkMavenRepository
import expo.modules.plugin.gradle.linkPlugin
import expo.modules.plugin.gradle.linkProject
import expo.modules.plugin.text.Colors
import expo.modules.plugin.text.Emojis
import expo.modules.plugin.text.withColor
import groovy.lang.Binding
import groovy.lang.GroovyShell
import org.gradle.api.Project
import org.gradle.api.initialization.Settings
import org.gradle.api.logging.Logging
import org.gradle.internal.extensions.core.extra
import java.io.File
class SettingsManager(
val settings: Settings,
val projectRoot: File,
searchPaths: List<String>? = null,
exclude: List<String>? = null
) {
private val autolinkingOptions = AutolinkingOptions(
searchPaths,
exclude
)
private val groovyShell by lazy {
val binding = Binding()
binding.setVariable("providers", settings.providers)
GroovyShell(javaClass.classLoader, binding)
}
private val logger by lazy {
Logging.getLogger(Settings::class.java)
}
/**
* Resolved configuration from `expo-modules-autolinking`.
*/
private val config by lazy {
val command = AutolinkingCommandBuilder()
.command("resolve")
.useJson()
.useAutolinkingOptions(autolinkingOptions)
.build()
val result = settings.providers.exec { env ->
env.workingDir(projectRoot.absolutePath)
env.commandLine(command)
}.standardOutput.asText.get()
val decodedConfig = ExpoAutolinkingConfig.decodeFromString(result)
configurePublication(decodedConfig)
return@lazy decodedConfig
}
private fun configurePublication(config: ExpoAutolinkingConfig) {
config.allProjects.forEach { project ->
if (project.publication != null) {
val forceBuildFromSource = config.configuration.buildFromSourceRegex.any {
it.matches(project.name)
}
project.configuration.shouldUsePublication = !forceBuildFromSource && evaluateShouldUsePublicationScript(project)
}
}
}
private fun evaluateShouldUsePublicationScript(project: GradleProject): Boolean {
// If the path to the script is not defined, we assume that the publication should be used.
val scriptPath = project.shouldUsePublicationScriptPath
?: return true
val scriptFile = File(scriptPath)
// If the path is invalid, we assume that the publication should be used.
if (!scriptFile.exists()) {
logger.warn("[ExpoAutolinkingPlugin] The script file does not exist: $scriptPath")
return false
}
val result = groovyShell.run(scriptFile, emptyArray<String>())
return result as? Boolean == true
}
fun useExpoModules() {
link()
settings.gradle.beforeProject { project ->
// Adds precompiled artifacts
config.allAarProjects.filter { it.name == project.name }
.forEach(
project::applyAarProject
)
}
// Defines the required features for the core module
settings.gradle.beforeProject("expo-modules-core") { project ->
project.extra.set("coreFeatures", config.coreFeatures)
}
settings.gradle.beforeRootProject { rootProject: Project ->
val extraDependency = config.extraDependencies
extraDependency.forEach { mavenConfig ->
rootProject.logger.quiet("Adding extra maven repository: ${mavenConfig.url}")
}
rootProject.allprojects { project ->
extraDependency.forEach { mavenConfig ->
project.linkMavenRepository(mavenConfig)
}
}
config.allPlugins.forEach(rootProject::linkBuildDependence)
// Adds maven repositories for all projects that are using the publication.
// It most likely means that we will add "https://maven.pkg.github.com/expo/expo" to the repositories.
val localRepositories = config
.allProjects
.filter { it.usePublication && it.publication?.repository != "mavenLocal" }
.mapNotNull {
val publication = it.publication
?: return@mapNotNull null
"${it.sourceDir}/../${publication.repository}" to publication
}
.groupBy({ it.first }, { it.second })
rootProject.allprojects { project ->
localRepositories.forEach { (path, publications) ->
project.linkLocalMavenRepository(path, publications)
}
}
}
settings.gradle.afterAndroidApplicationProject { androidApplication ->
config
.allPlugins
.filter { it.applyToRootProject }
.forEach { plugin ->
androidApplication.logger.quiet(" ${Emojis.INFORMATION} ${"Applying gradle plugin".withColor(Colors.YELLOW)} '${plugin.id.withColor(Colors.GREEN)}'")
androidApplication.applyPlugin(plugin)
}
}
settings.gradle.extensions.create("expoGradle", ExpoGradleExtension::class.java, config, autolinkingOptions, projectRoot)
}
/**
* Links all projects, plugins and aar projects.
*/
private fun link() = with(config) {
allProjects.forEach { project ->
if (!project.usePublication) {
settings.linkProject(project)
}
}
allPlugins.forEach(settings::linkPlugin)
allAarProjects.forEach(settings::linkAarProject)
}
}

View File

@@ -0,0 +1,38 @@
package expo.modules.plugin.gradle
import org.gradle.api.Project
import org.gradle.api.invocation.Gradle
/**
* Adds an action to be called immediately before a root project is evaluate.
*/
internal inline fun Gradle.beforeRootProject(crossinline action: (rootProject: Project) -> Unit) {
beforeProject { project ->
if (project !== project.rootProject) {
return@beforeProject
}
action(project)
}
}
/**
* Adds an action to be called before the given project is evaluated.
*/
internal inline fun Gradle.beforeProject(projectName: String, crossinline action: (project: Project) -> Unit) {
beforeProject { project ->
if (project.name == projectName) {
action(project)
}
}
}
/**
* Adds an action to be called immediately after an Android application project is evaluated.
*/
internal inline fun Gradle.afterAndroidApplicationProject(crossinline action: (androidApplication: Project) -> Unit) {
afterProject { project ->
if (project.plugins.hasPlugin("com.android.application")) {
action(project)
}
}
}

View File

@@ -0,0 +1,75 @@
package expo.modules.plugin.gradle
import expo.modules.plugin.configuration.AWSMavenCredentials
import expo.modules.plugin.configuration.BasicMavenCredentials
import expo.modules.plugin.configuration.HttpHeaderMavenCredentials
import expo.modules.plugin.configuration.MavenCredentials
import expo.modules.plugin.utils.Env
import org.gradle.api.artifacts.repositories.MavenArtifactRepository
import org.gradle.api.credentials.AwsCredentials
import org.gradle.api.credentials.HttpHeaderCredentials
import org.gradle.internal.authentication.DefaultBasicAuthentication
import org.gradle.internal.authentication.DefaultDigestAuthentication
import org.gradle.internal.authentication.DefaultHttpHeaderAuthentication
internal fun MavenArtifactRepository.applyAuthentication(authenticationType: String?) {
if (authenticationType == null) {
return
}
authentication.add(
when (authenticationType) {
"basic" -> DefaultBasicAuthentication("basic")
"digest" -> DefaultDigestAuthentication("digest")
"header" -> DefaultHttpHeaderAuthentication("header")
else -> throw IllegalArgumentException("Unknown authentication type: $authenticationType")
}
)
}
internal fun MavenArtifactRepository.applyCredentials(mavenCredentials: MavenCredentials?) {
if (mavenCredentials == null) {
return
}
when (mavenCredentials) {
is BasicMavenCredentials -> {
val (username, password) = mavenCredentials
credentials { credentials ->
credentials.username = resolveEnvVar(username)
credentials.password = resolveEnvVar(password)
}
}
is HttpHeaderMavenCredentials -> {
val (name, value) = mavenCredentials
credentials(HttpHeaderCredentials::class.java) { credentials ->
credentials.name = resolveEnvVar(name)
credentials.value = resolveEnvVar(value)
}
}
is AWSMavenCredentials -> {
val (accessKey, secretKey, sessionToken) = mavenCredentials
credentials(AwsCredentials::class.java) { credentials ->
credentials.accessKey = resolveEnvVar(accessKey)
credentials.secretKey = resolveEnvVar(secretKey)
credentials.sessionToken = sessionToken?.let { resolveEnvVar(it) }
}
}
}
}
private val ENV_REGEX = """System\.getenv\(['"]([A-Za-z0-9_]+)['"]\)""".toRegex()
/**
* Utility function to substitute environment variables in strings.
* Supports patterns like System.getEnv('VAR_NAME') or System.getEnv("VAR_NAME")
*/
private fun resolveEnvVar(input: String): String {
return ENV_REGEX.replace(input) { match ->
val name = match.groupValues[1]
// Return original if env var not found
Env.getProcessEnv(name) ?: match.value
}
}

View File

@@ -0,0 +1,43 @@
package expo.modules.plugin.gradle
import expo.modules.plugin.configuration.GradleAarProject
import expo.modules.plugin.configuration.GradlePlugin
import expo.modules.plugin.configuration.MavenRepo
import expo.modules.plugin.configuration.Publication
import org.gradle.api.Project
import java.io.File
internal fun Project.applyPlugin(plugin: GradlePlugin) {
plugins.apply(plugin.id)
}
internal fun Project.applyAarProject(aarProject: GradleAarProject) {
configurations.maybeCreate("default")
artifacts.add("default", File(aarProject.aarFilePath))
}
internal fun Project.linkBuildDependence(plugin: GradlePlugin) {
buildscript.dependencies.add("classpath", "${plugin.group}:${plugin.id}")
}
internal fun Project.linkLocalMavenRepository(path: String, publications: List<Publication>) {
repositories.mavenLocal { maven ->
maven.url = file(path).toURI()
maven.content { content ->
publications.forEach { publication ->
content.includeVersion(publication.groupId, publication.artifactId, publication.version)
}
}
}
}
internal fun Project.linkMavenRepository(mavenRepo: MavenRepo) {
val (url, credentials, authentication) = mavenRepo
repositories.maven { maven ->
maven.setUrl(url)
maven.applyCredentials(credentials)
maven.applyAuthentication(authentication)
}
}

View File

@@ -0,0 +1,81 @@
package expo.modules.plugin.gradle
import expo.modules.plugin.configuration.GradleAarProject
import expo.modules.plugin.configuration.GradlePlugin
import expo.modules.plugin.configuration.GradleProject
import expo.modules.plugin.text.Colors
import expo.modules.plugin.text.Emojis
import expo.modules.plugin.text.withColor
import expo.modules.plugin.utils.getPropertiesPrefixedBy
import org.gradle.api.initialization.Settings
import org.gradle.api.logging.Logging
import org.gradle.caching.http.HttpBuildCache
import java.io.File
import java.net.URI
import java.util.Properties
internal fun Settings.linkProject(project: GradleProject) {
include(":${project.name}")
project(":${project.name}").projectDir = File(project.sourceDir)
}
internal fun Settings.linkPlugin(plugin: GradlePlugin) {
includeBuild(File(plugin.sourceDir))
}
internal fun Settings.linkAarProject(aarProject: GradleAarProject) {
include(":${aarProject.name}")
val projectDir = File(aarProject.projectDir)
if (!projectDir.exists()) {
projectDir.mkdirs()
}
project(":${aarProject.name}").projectDir = projectDir
}
internal fun Settings.loadLocalProperties(): Properties {
return Properties().apply {
val localPropertiesFile = File(settings.rootDir, "local.properties")
if (localPropertiesFile.exists()) {
localPropertiesFile.reader().use(::load)
}
}
}
internal fun Settings.addBuildCache() {
val localProperties = settings
.loadLocalProperties()
val remoteCacheConfigPrefix = "expo.cache.remote."
val remoteCacheConfig = settings.getPropertiesPrefixedBy(remoteCacheConfigPrefix) +
localProperties.getPropertiesPrefixedBy(remoteCacheConfigPrefix)
val url = remoteCacheConfig["url"] ?: return
val username = remoteCacheConfig["username"]
val password = remoteCacheConfig["password"]
val readonly = remoteCacheConfig["read-only"]?.toBoolean() ?: false
val isUnsafe = url.startsWith("http://")
val logger = Logging.getLogger(Settings::class.java)
logger.quiet("${Emojis.GEAR} Configuring remote build cache: ${url.withColor(Colors.GREEN)} (username: ${username.withColor(Colors.GREEN)}, read-only: ${readonly.toString().withColor(Colors.GREEN)})")
settings.buildCache { configuration ->
configuration.remote(HttpBuildCache::class.java) { cache ->
cache.url = URI.create(url)
if (username != null && password != null) {
cache.credentials {
it.username = username
it.password = password
}
}
cache.isPush = !readonly
if (isUnsafe) {
cache.isAllowInsecureProtocol = true
cache.isAllowUntrustedServer = true
}
}
}
}

View File

@@ -0,0 +1,8 @@
package expo.modules.plugin.utils
internal object Env {
/**
* A wrapper around [System.getenv] that we can setup mock for testing.
*/
fun getProcessEnv(name: String): String? = System.getenv(name)
}

View File

@@ -0,0 +1,25 @@
package expo.modules.plugin.utils
import org.gradle.api.initialization.Settings
import java.util.Properties
internal fun Properties.getPropertiesPrefixedBy(prefix: String): Map<String, String> {
return entries
.mapNotNull { (key, value) ->
if (key !is String || value !is String) {
return@mapNotNull null
}
if (key.startsWith(prefix)) {
key.removePrefix(prefix) to value
} else {
null
}
}
.toMap()
}
internal fun Settings.getPropertiesPrefixedBy(prefix: String): Map<String, String> {
val prefixedProperty = providers.gradlePropertiesPrefixedBy(prefix).get()
return prefixedProperty.mapKeys { (key, _) -> key.removePrefix(prefix) }
}

View File

@@ -0,0 +1,190 @@
package expo.modules.plugin
import com.google.common.truth.Truth
import expo.modules.plugin.configuration.ExpoAutolinkingConfig
import org.gradle.testkit.runner.BuildResult
import org.gradle.testkit.runner.GradleRunner
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
class ExpoAutolinkingSettingsPluginTest {
@JvmField
@Rule
var testProjectDir: TemporaryFolder = TemporaryFolder()
@Before
fun setUp() {
testProjectDir.root.removeRecursively()
testProjectDir.root.createProject()
}
@Test
fun `applies settings plugin`() {
val result = executeGradleRun()
Truth.assertThat(result.output).contains("BUILD SUCCESSFUL")
}
@Test
fun `injects expo gradle extension`() {
val result = executeGradleRun(":app:gradleExpoExtension")
val expoConfig = findPrefix("expoGradle=", result.output)
Truth.assertThat(expoConfig).isNotNull()
}
@Test
fun `returns correct config`() {
val result = executeGradleRun(":app:expoConfig")
val configStringFromPlugin = findPrefix("expoConfig=", result.output)
val configFromPlugin = ExpoAutolinkingConfig.decodeFromString(configStringFromPlugin!!)
val configStringFromAutolinking = testProjectDir.root.runCommand(
*AutolinkingCommandBuilder()
.command("resolve")
.useJson()
.build()
.toTypedArray()
)
val configFromAutolinking = ExpoAutolinkingConfig.decodeFromString(configStringFromAutolinking)
Truth.assertThat(configFromPlugin).isEqualTo(configFromAutolinking)
}
private fun executeGradleRun(task: String? = null): BuildResult =
GradleRunner
.create()
.withProjectDir(testProjectDir.root)
.apply {
if (task != null) {
withArguments(task)
}
}
.withPluginClasspath()
.build()
}
fun findPrefix(prefix: String, input: String): String? {
return input.lineSequence()
.map { it.trim() }
.find { it.startsWith(prefix) }
?.substringAfter(prefix)
?.takeIf { it.isNotBlank() }
}
/**
* Creates a new project with the following structure:
* <file>
* ├── app
* │ └── build.gradle
* ├── build.gradle
* ├── settings.gradle
* └── package.json
*/
private fun File.createProject() {
File(this, "app").mkdir()
val app = File(this, "app")
File(app, "build.gradle").writeText(
"""
task("gradleExpoExtension") {
doLast {
println("expoGradle=" + gradle.expoGradle)
}
}
task("expoConfig") {
doLast {
println("expoConfig=" + gradle.expoGradle.config.toJson())
}
}
""".trimIndent()
)
File(this, "build.gradle").writeText(
"""
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.6.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.24")
}
}
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }
}
}
""".trimIndent()
)
File(this, "settings.gradle").writeText(
"""
plugins {
id("expo-autolinking-settings")
}
expoAutolinking.useExpoModules()
include(":app")
""".trimIndent()
)
File(this, "package.json").writeText(
"""
{
"name": "test-project",
"dependencies": {
"expo": "52.0.5",
"expo-image-picker": "16.0.3"
}
}
""".trimIndent()
)
runCommand("npm", "install")
// Mocks the expo and expo-modules-core build.gradle files
// to avoid errors during the sync process.
File(this, "node_modules/expo/android/build.gradle").writeText(
"""
""".trimIndent()
)
File(this, "node_modules/expo-modules-core/android/build.gradle").writeText(
"""
""".trimIndent()
)
}
/**
* Runs a command in the current directory and returns the output as a string.
*/
private fun File.runCommand(vararg command: String): String {
val process = ProcessBuilder(*command)
.directory(this)
.start()
val inputStream = process.inputStream
process.waitFor()
return inputStream.use {
it.readAllBytes().toString(Charsets.UTF_8)
}
}
/**
* Removes the file and all its children
*/
private fun File.removeRecursively() =
this
.walkBottomUp()
.filter { it != this }
.forEach { it.deleteRecursively() }

View File

@@ -0,0 +1,112 @@
package expo.modules.plugin
import com.google.common.truth.Truth
import expo.modules.plugin.configuration.AWSMavenCredentials
import expo.modules.plugin.configuration.BasicMavenCredentials
import expo.modules.plugin.configuration.HttpHeaderMavenCredentials
import expo.modules.plugin.gradle.applyCredentials
import expo.modules.plugin.utils.Env
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.unmockkObject
import org.gradle.api.credentials.AwsCredentials
import org.gradle.api.credentials.HttpHeaderCredentials
import org.gradle.testfixtures.ProjectBuilder
import org.junit.After
import org.junit.Before
import org.junit.Test
import java.net.URI
class MavenArtifactRepositoryExtensionTest {
@Before
fun setUp() {
mockkObject(Env)
}
@After
fun tearDown() {
unmockkObject(Env)
}
@Test
fun `should apply credentials from string values`() {
val project = ProjectBuilder.builder().build()
val mavenRepo = project.repositories.maven {
it.url = URI("auth.maven.test")
it.applyCredentials(BasicMavenCredentials("username", "password"))
}
Truth.assertThat(mavenRepo.credentials.username).isEqualTo("username")
Truth.assertThat(mavenRepo.credentials.password).isEqualTo("password")
}
@Test
fun `should apply credentials from environment variables`() {
val project = ProjectBuilder.builder().build()
every { Env.getProcessEnv("envUsername") } returns "basic1"
every { Env.getProcessEnv("envPassword") } returns "basic2"
val mavenRepoBasic = project.repositories.maven {
it.url = URI("auth.maven.test")
it.applyCredentials(BasicMavenCredentials("System.getenv('envUsername')", "System.getenv(\"envPassword\")"))
}
Truth.assertThat(mavenRepoBasic.credentials.username).isEqualTo("basic1")
Truth.assertThat(mavenRepoBasic.credentials.password).isEqualTo("basic2")
every { Env.getProcessEnv("envName") } returns "httpHeader1"
every { Env.getProcessEnv("envValue") } returns "httpHeader2"
val mavenRepoHttpHeader = project.repositories.maven {
it.url = URI("auth.maven.test")
it.applyCredentials(HttpHeaderMavenCredentials("System.getenv('envName')", "System.getenv(\"envValue\")"))
}
val httpHeaderCredentials = mavenRepoHttpHeader.getCredentials(HttpHeaderCredentials::class.java)
Truth.assertThat(httpHeaderCredentials.name).isEqualTo("httpHeader1")
Truth.assertThat(httpHeaderCredentials.value).isEqualTo("httpHeader2")
every { Env.getProcessEnv("envAccessKey") } returns "aws1"
every { Env.getProcessEnv("envSecretKey") } returns "aws2"
every { Env.getProcessEnv("envSessionToken") } returns "aws3"
val mavenRepoAws = project.repositories.maven {
it.url = URI("auth.maven.test")
it.applyCredentials(AWSMavenCredentials(
"System.getenv('envAccessKey')",
"System.getenv(\"envSecretKey\")",
"System.getenv('envSessionToken')"))
}
val awsCredentials = mavenRepoAws.getCredentials(AwsCredentials::class.java)
Truth.assertThat(awsCredentials.accessKey).isEqualTo("aws1")
Truth.assertThat(awsCredentials.secretKey).isEqualTo("aws2")
Truth.assertThat(awsCredentials.sessionToken).isEqualTo("aws3")
}
@Test
fun `should fallback to original inputs if environment variables not found`() {
val project = ProjectBuilder.builder().build()
val mavenRepoBasic = project.repositories.maven {
it.url = URI("auth.maven.test")
it.applyCredentials(BasicMavenCredentials("System.getenv('envUsername')", "System.getenv(\"envPassword\")"))
}
Truth.assertThat(mavenRepoBasic.credentials.username).isEqualTo("System.getenv('envUsername')")
Truth.assertThat(mavenRepoBasic.credentials.password).isEqualTo("System.getenv(\"envPassword\")")
val mavenRepoHttpHeader = project.repositories.maven {
it.url = URI("auth.maven.test")
it.applyCredentials(HttpHeaderMavenCredentials("System.getenv('envName')", "System.getenv(\"envValue\")"))
}
val httpHeaderCredentials = mavenRepoHttpHeader.getCredentials(HttpHeaderCredentials::class.java)
Truth.assertThat(httpHeaderCredentials.name).isEqualTo("System.getenv('envName')")
Truth.assertThat(httpHeaderCredentials.value).isEqualTo("System.getenv(\"envValue\")")
val mavenRepoAws = project.repositories.maven {
it.url = URI("auth.maven.test")
it.applyCredentials(AWSMavenCredentials(
"System.getenv('envAccessKey')",
"System.getenv(\"envSecretKey\")",
"System.getenv('envSessionToken')"))
}
val awsCredentials = mavenRepoAws.getCredentials(AwsCredentials::class.java)
Truth.assertThat(awsCredentials.accessKey).isEqualTo("System.getenv('envAccessKey')")
Truth.assertThat(awsCredentials.secretKey).isEqualTo("System.getenv(\"envSecretKey\")")
Truth.assertThat(awsCredentials.sessionToken).isEqualTo("System.getenv('envSessionToken')")
}
}

View File

@@ -0,0 +1,44 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
kotlin("jvm")
id("java-gradle-plugin")
}
repositories {
google()
mavenCentral()
}
dependencies {
implementation(project(":expo-autolinking-plugin-shared"))
implementation(gradleApi())
compileOnly("com.android.tools.build:gradle:8.5.0")
testImplementation("junit:junit:4.13.2")
testImplementation("com.google.truth:truth:1.1.2")
testImplementation(gradleTestKit())
}
java {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
tasks.withType<KotlinCompile> {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
group = "expo.modules"
gradlePlugin {
plugins {
create("expoMaxSdkOverridePlugin") {
id = "expo-max-sdk-override-plugin"
implementationClass = "expo.modules.plugin.ExpoMaxSdkOverridePlugin"
}
}
}

View File

@@ -0,0 +1,69 @@
package expo.modules.plugin
import expo.modules.plugin.utils.extractPathFromLine
/**
* Analyzes the manifest merge report content to find permissions that may be defined with android:maxSdkVersion,
* where one AndroidManifest.xml defines the permission with the aforementioned annotation and another one without.
*
* This method should be used to reduce the search scope for `findPermissionsToOverride` method.
*
* @param reportContent The content of the manifest merge report to analyze.
* @return A map of suspicious permission names to their corresponding PermissionInfo objects.
*/
internal fun analyzeManifestReport(reportContent: String): Map<String, PermissionInfo> {
val allPermissionInfo = mutableMapOf<String, PermissionInfo>()
var currentPermission: String? = null
var lastAttributeWasMaxSdk = false
for (line in reportContent.lines()) {
val trimmedLine = line.trimStart()
// This line starts a new permission definition
if (line.startsWith("uses-permission#")) {
currentPermission = line.substringAfter("uses-permission#").trim()
allPermissionInfo.getOrPut(currentPermission) {
PermissionInfo()
}
lastAttributeWasMaxSdk = false
} else if (currentPermission != null) {
when {
trimmedLine.startsWith("android:maxSdkVersion") -> {
lastAttributeWasMaxSdk = true
}
// Source of maxSdkVersion annotation
line.startsWith("\t") && (trimmedLine.startsWith("ADDED from") || trimmedLine.startsWith("MERGED from")) -> {
if (lastAttributeWasMaxSdk) {
extractPathFromLine(line)?.let { source ->
allPermissionInfo[currentPermission]?.maxSdkSources?.add(source)
}
}
lastAttributeWasMaxSdk = false
}
// Source of the permission definition
!line.startsWith("\t") && (trimmedLine.startsWith("ADDED from") || trimmedLine.startsWith("MERGED from")) -> {
extractPathFromLine(line)?.let { path ->
allPermissionInfo[currentPermission]?.manifestPaths?.add(path)
}
lastAttributeWasMaxSdk = false
}
else -> {
lastAttributeWasMaxSdk = false
}
}
}
}
// Permissions which may have maxSdkConflicts, happen when there is more than one
// source fora a permission and the permission is annotated with maxSdkVersion
val problematicPermissions = allPermissionInfo.filter { (permission, info) ->
val multipleDefinitions = info.manifestPaths.size > 1
val maxSdkDefined = info.maxSdkSources.isNotEmpty()
multipleDefinitions && maxSdkDefined
}
return problematicPermissions.toMap()
}

View File

@@ -0,0 +1,44 @@
package expo.modules.plugin
import com.android.build.api.artifact.SingleArtifact
import org.gradle.api.Plugin
import org.gradle.api.Project
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import expo.modules.plugin.text.Colors
import expo.modules.plugin.text.Emojis
import expo.modules.plugin.text.withColor
import org.gradle.internal.cc.base.logger
/**
* Plugin, which registers ExpoMaxSdkOverrideTask and schedules it to run with `app:processDebugManifest`.
*
* The task finds all permissions declared with `android:maxSdkVersion`. If the permission was declared in more than one place, and one of the places
* defines the task without `android:maxSdkVersion` the task will remove the `android:maxSdkVersion` from the final merged manifest
*/
class ExpoMaxSdkOverridePlugin : Plugin<Project> {
override fun apply(project: Project) {
val androidComponents = project.extensions.getByType(ApplicationAndroidComponentsExtension::class.java)
logger.quiet(" ${Emojis.INFORMATION} ${"Applying gradle plugin".withColor(Colors.YELLOW)} '${"expo-max-sdk-override-plugin".withColor(Colors.GREEN)}'")
logger.quiet(" [expo-max-sdk-override-plugin] This plugin will find all permissions declared with `android:maxSdkVersion`. If there exists a declaration with the `android:maxSdkVersion` annotation and another one without, the plugin will remove the annotation from the final merged manifest. In order to see a log with the changes run a clean build of the app.")
androidComponents.onVariants(androidComponents.selector().all()) { variant ->
val taskName = "expo${variant.name.replaceFirstChar { it.uppercase() }}OverrideMaxSdkConflicts"
val blameReportPath = "outputs/logs/manifest-merger-${listOfNotNull(variant.flavorName?.takeIf { it.isNotEmpty() }, variant.buildType).joinToString("-")}-report.txt"
val reportFile = project.layout.buildDirectory.file(blameReportPath)
val fixTaskProvider = project.tasks.register(
taskName,
FixManifestMaxSdkTask::class.java
) { task ->
task.blameReportFile.set(reportFile)
}
variant.artifacts
.use(fixTaskProvider)
.wiredWithFiles(
FixManifestMaxSdkTask::mergedManifestIn,
FixManifestMaxSdkTask::modifiedManifestOut
)
.toTransform(SingleArtifact.MERGED_MANIFEST)
}
}
}

View File

@@ -0,0 +1,129 @@
package expo.modules.plugin
import expo.modules.plugin.text.Colors
import expo.modules.plugin.text.withColor
import org.gradle.api.DefaultTask
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.transform.OutputKeys
import javax.xml.transform.TransformerFactory
import javax.xml.transform.dom.DOMSource
import javax.xml.transform.stream.StreamResult
/**
* This task reads the manifest merge report, finds conflicting permissions, and removes 'android:maxSdkVersion' from them in the final merged manifest.
*/
abstract class FixManifestMaxSdkTask : DefaultTask() {
@get:InputFile
abstract val blameReportFile: RegularFileProperty
@get:InputFile
abstract val mergedManifestIn: RegularFileProperty
@get:OutputFile
abstract val modifiedManifestOut: RegularFileProperty
@TaskAction
fun taskAction() {
val blameFile = blameReportFile.get().asFile
val inManifest = mergedManifestIn.get().asFile
val outManifest = modifiedManifestOut.get().asFile
logger.quiet("---------- Expo Max Sdk Override Plugin ----------".withColor(Colors.YELLOW))
if (!blameFile.exists()) {
logger.warn("Manifest blame report not found: ${blameFile.absolutePath}. Skipping `android:maxSdkVersion` permission conflict check.")
inManifest.copyTo(outManifest, overwrite = true)
logNoChanges()
return
}
val reportContents = blameFile.readText()
val potentialProblems = analyzeManifestReport(reportContents)
if (potentialProblems.isEmpty()) {
inManifest.copyTo(outManifest, overwrite = true)
logNoChanges()
return
}
val brokenPermissions = findPermissionsToOverride(potentialProblems)
if (brokenPermissions.isEmpty()) {
inManifest.copyTo(outManifest, overwrite = true)
logNoChanges()
return
}
logger.quiet(">>> WARNING: Found ${brokenPermissions.size} permission(s) with conflicting 'android:maxSdkVersion' declarations.".withColor(Colors.YELLOW))
brokenPermissions.forEach { (permission, info) ->
val sourcesWithoutMaxSdk = info.manifestPaths.subtract(info.maxSdkSources)
logger.quiet(" - $permission".withColor(Colors.YELLOW))
logger.quiet(" > Defined WITH `android:maxSdkVersion` in: ${info.maxSdkSources.joinToString()}".withColor(Colors.YELLOW))
logger.quiet(" > Defined WITHOUT `android:maxSdkVersion` in: ${sourcesWithoutMaxSdk.joinToString()}".withColor(Colors.YELLOW))
}
logger.quiet(">>> Removing 'android:maxSdkVersion' from these permissions in the final manifest to prevent runtime issues.".withColor(Colors.YELLOW))
tryFixManifest(inManifest, outManifest, brokenPermissions)
logger.quiet("--------------------------------------------------".withColor(Colors.YELLOW))
}
private fun logNoChanges() {
logger.quiet(">>> No 'android:maxSdkVersion' conflicts found".withColor(Colors.GREEN))
logger.quiet("--------------------------------------------------".withColor(Colors.YELLOW))
}
private fun tryFixManifest(inManifest: File, outManifest: File, brokenPermissions: Map<String, PermissionInfo>) {
try {
val factory = DocumentBuilderFactory.newInstance().apply {
isNamespaceAware = true
}
val builder = factory.newDocumentBuilder()
val doc = builder.parse(inManifest)
val permissionNodes = doc.getElementsByTagName(ManifestConstants.USES_PERMISSION_TAG)
var modificationsMade = 0
val nodesToProcess = (0 until permissionNodes.length)
.map { permissionNodes.item(it) }
.filterIsInstance<org.w3c.dom.Element>()
for (element in nodesToProcess) {
val permissionName = element.getAttribute(ManifestConstants.ANDROID_NAME_ATTRIBUTE)
if (brokenPermissions.containsKey(permissionName) && element.hasAttribute(ManifestConstants.ANDROID_MAX_SDK_VERSION_ATTRIBUTE)) {
element.removeAttribute(ManifestConstants.ANDROID_MAX_SDK_VERSION_ATTRIBUTE)
modificationsMade++
}
}
if (modificationsMade > 0) {
logger.quiet(">>> Removed 'android:maxSdkVersion' from $modificationsMade instance(s) in the final manifest.".withColor(Colors.YELLOW))
}
TransformerFactory.newInstance().newTransformer().apply {
setOutputProperty(OutputKeys.INDENT, "yes")
setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4")
setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no")
setOutputProperty(OutputKeys.ENCODING, "UTF-8")
transform(DOMSource(doc), StreamResult(outManifest))
}
} catch (e: Exception) {
logger.error("Failed to parse and fix merged manifest: ${e.message}".withColor(Colors.RESET), e)
logger.quiet(">>> Restored the original merged manifest.".withColor(Colors.YELLOW))
inManifest.copyTo(outManifest, overwrite = true)
}
}
}
private object ManifestConstants {
const val USES_PERMISSION_TAG = "uses-permission"
const val ANDROID_NAME_ATTRIBUTE = "android:name"
const val ANDROID_MAX_SDK_VERSION_ATTRIBUTE = "android:maxSdkVersion"
}

View File

@@ -0,0 +1,62 @@
package expo.modules.plugin
import org.gradle.internal.cc.base.logger
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
/**
* Based on a map of `String` and `PermissionInfo` read and parse manifest files, finds cases where
* a permission is defined in one place with `android:maxSdkVersion` and in another without that annotation.
*
* @param problematicPermissions A Map of `String` and `PermissionInfo` obtained with analyzeManifestReport
*/
internal fun findPermissionsToOverride(problematicPermissions: Map<String, PermissionInfo>): Map<String, PermissionInfo> {
val factory = DocumentBuilderFactory.newInstance()
factory.isNamespaceAware = true
// Basic security
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) // Disallow parsing <!DOCTYPE> files
factory.setFeature("http://xml.org/sax/features/external-general-entities", false) // Prevent external general entities
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false) // Prevent external paramater entities
val builder = factory.newDocumentBuilder()
val brokenPermissions = mutableMapOf<String, PermissionInfo>()
problematicPermissions.forEach { permission, info ->
// Not actually a problematic permission
if (info.maxSdkSources.size == 0) {
return@forEach
}
info.manifestPaths.forEach { manifestPath ->
try {
val file = File(manifestPath)
if (!file.exists() || !file.canRead()) {
logger.error("Failed to open manifest file at: $manifestPath")
return@forEach
}
val doc = builder.parse(file)
val permissionNodes = doc.getElementsByTagName("uses-permission")
for (i in 0 until permissionNodes.length) {
val permissionNode = permissionNodes.item(i)
if (permissionNode.nodeType == org.w3c.dom.Node.ELEMENT_NODE) {
val element = permissionNode as org.w3c.dom.Element
val permissionName = element.getAttribute("android:name")
if (permissionName == permission && !element.hasAttribute("android:maxSdkVersion")) {
brokenPermissions[permission] = info
return@forEach
}
}
}
} catch (e: Exception) {
logger.error("Failed to parse manifest at ${manifestPath}", e)
}
}
}
return brokenPermissions
}

View File

@@ -0,0 +1,6 @@
package expo.modules.plugin
internal data class PermissionInfo(
val maxSdkSources: MutableSet<String> = mutableSetOf(),
val manifestPaths: MutableSet<String> = mutableSetOf()
)

View File

@@ -0,0 +1,16 @@
package expo.modules.plugin.utils
/**
* Extracts a single file path from one line of an AndroidManifest merge log.
*
* @param line The raw single-line string from the build log.
* @return A String containing the absolute file path to the manifest, or null if no path is found.
*/
fun extractPathFromLine(line: String): String? {
// Regex to find a path starting with '/' and ending just before
// the line/column numbers (e.g., :11:3)
val regex = Regex("(/.*?):\\d+:\\d+.*")
val match = regex.find(line)
return match?.groups?.get(1)?.value
}

View File

@@ -0,0 +1,102 @@
package expo.modules.plugin
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class AnalyzeManifestReportTest {
@Test
fun `finds permission with conflict`() {
val reportContent = """
uses-permission#android.permission.READ_CONTACTS
MERGED from /Users/user/project/app/src/main/AndroidManifest.xml:11:3-33
MERGED from /Users/user/project/library/src/main/AndroidManifest.xml:15:3-83
android:maxSdkVersion
ADDED from /Users/user/project/library/src/main/AndroidManifest.xml:16:7-34
""".trimIndent()
val problems = analyzeManifestReport(reportContent)
assertThat(problems).hasSize(1)
assertThat(problems).containsKey("android.permission.READ_CONTACTS")
val info = problems["android.permission.READ_CONTACTS"]
assertThat(info).isNotNull()
info?.let {
assertThat(info.manifestPaths).containsExactly(
"/Users/user/project/app/src/main/AndroidManifest.xml",
"/Users/user/project/library/src/main/AndroidManifest.xml"
)
assertThat(info.maxSdkSources).containsExactly(
"/Users/user/project/library/src/main/AndroidManifest.xml"
)
}
}
@Test
fun `ignores permission with no conflict`() {
val reportContent = """
uses-permission#android.permission.READ_CONTACTS
MERGED from /Users/user/project/app/src/main/AndroidManifest.xml:11:3-33
MERGED from /Users/user/project/library/src/main/AndroidManifest.xml:15:3-83
""".trimIndent()
val problems = analyzeManifestReport(reportContent)
assertThat(problems).isEmpty()
}
@Test
fun `ignores permission with only one source`() {
val reportContent = """
uses-permission#android.permission.READ_CONTACTS
MERGED from /Users/user/project/app/src/main/AndroidManifest.xml:11:3-33
android:maxSdkVersion
ADDED from /Users/user/project/app/src/main/AndroidManifest.xml:12:7-34
""".trimIndent()
val problems = analyzeManifestReport(reportContent)
assertThat(problems).isEmpty()
}
@Test
fun `handles multiple permissions`() {
val reportContent = """
uses-permission#android.permission.READ_CONTACTS
MERGED from /Users/user/project/app/src/main/AndroidManifest.xml:11:3-33
MERGED from /Users/user/project/library/src/main/AndroidManifest.xml:15:3-83
android:maxSdkVersion
ADDED from /Users/user/project/library/src/main/AndroidManifest.xml:16:7-34
uses-permission#android.permission.WRITE_EXTERNAL_STORAGE
MERGED from /Users/user/project/app/src/main/AndroidManifest.xml:13:3-33
uses-permission#android.permission.READ_EXTERNAL_STORAGE
MERGED from /Users/user/project/app/src/main/AndroidManifest.xml:14:3-33
MERGED from /Users/user/project/otherlib/src/main/AndroidManifest.xml:9:3-83
android:maxSdkVersion
ADDED from /Users/user/project/app/src/main/AndroidManifest.xml:15:7-34
""".trimIndent()
val problems = analyzeManifestReport(reportContent)
assertThat(problems).hasSize(2)
assertThat(problems).containsKey("android.permission.READ_CONTACTS")
assertThat(problems).containsKey("android.permission.READ_EXTERNAL_STORAGE")
val readContactsInfo = problems["android.permission.READ_CONTACTS"]!!
assertThat(readContactsInfo.maxSdkSources).containsExactly(
"/Users/user/project/library/src/main/AndroidManifest.xml"
)
val readStorageInfo = problems["android.permission.READ_EXTERNAL_STORAGE"]!!
assertThat(readStorageInfo.manifestPaths).containsExactly(
"/Users/user/project/app/src/main/AndroidManifest.xml",
"/Users/user/project/otherlib/src/main/AndroidManifest.xml"
)
assertThat(readStorageInfo.maxSdkSources).containsExactly(
"/Users/user/project/app/src/main/AndroidManifest.xml"
)
}
}

View File

@@ -0,0 +1,35 @@
package expo.modules.plugin.utils
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class ExtractPathFromLineTest {
@Test
fun `extracts path from a standard merge log line`() {
val line = "\tMERGED from /Users/user/project/app/src/main/AndroidManifest.xml:11:3-33"
val path = extractPathFromLine(line)
assertThat(path).isEqualTo("/Users/user/project/app/src/main/AndroidManifest.xml")
}
@Test
fun `extracts path from an ADDED line`() {
val line = " ADDED from /Users/user/project/library/build/intermediates/merged_manifest/debug/AndroidManifest.xml:23:7-77"
val path = extractPathFromLine(line)
assertThat(path).isEqualTo("/Users/user/project/library/build/intermediates/merged_manifest/debug/AndroidManifest.xml")
}
@Test
fun `extracts path from an ADDED line with space`() {
val line = " ADDED from /Users/happy user/project/library/build/intermediates/merged_manifest/debug/AndroidManifest.xml:23:7-77"
val path = extractPathFromLine(line)
assertThat(path).isEqualTo("/Users/happy user/project/library/build/intermediates/merged_manifest/debug/AndroidManifest.xml")
}
@Test
fun `returns null if no path is found`() {
val line = "\tandroid:maxSdkVersion"
val path = extractPathFromLine(line)
assertThat(path).isNull()
}
}

View File

@@ -0,0 +1,91 @@
package expo.modules.plugin
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
class FindPermissionsToOverrideTest {
@get:Rule
val tempFolder = TemporaryFolder()
private lateinit var manifestWithMaxSdk: File
private lateinit var manifestWithoutMaxSdk: File
@Before
fun setup() {
manifestWithMaxSdk = File(tempFolder.root, "max_sdk_manifest.xml")
manifestWithMaxSdk.writeText("""
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission
android:name="android.permission.READ_CONTACTS"
android:maxSdkVersion="28" />
</manifest>
""".trimIndent())
manifestWithoutMaxSdk = File(tempFolder.root, "no_max_sdk_manifest.xml")
manifestWithoutMaxSdk.writeText("""
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
</manifest>
""".trimIndent())
}
@Test
fun `finds permission that needs to be overridden`() {
val permissionInfo = PermissionInfo(
maxSdkSources = mutableSetOf(manifestWithMaxSdk.absolutePath),
manifestPaths = mutableSetOf(
manifestWithMaxSdk.absolutePath,
manifestWithoutMaxSdk.absolutePath
)
)
val problems = mapOf("android.permission.READ_CONTACTS" to permissionInfo)
val overrides = findPermissionsToOverride(problems)
assertThat(overrides).hasSize(1)
assertThat(overrides).containsKey("android.permission.READ_CONTACTS")
}
@Test
fun `does not find override if no conflict exists`() {
val manifestWithMaxSdk2 = File(tempFolder.root, "max_sdk_manifest_2.xml")
manifestWithMaxSdk2.writeText("""
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.READ_CONTACTS" android:maxSdkVersion="28" />
</manifest>
""".trimIndent())
val permissionInfo = PermissionInfo(
maxSdkSources = mutableSetOf(manifestWithMaxSdk.absolutePath, manifestWithMaxSdk2.absolutePath),
manifestPaths = mutableSetOf(
manifestWithMaxSdk.absolutePath,
manifestWithMaxSdk2.absolutePath
)
)
val problems = mapOf("android.permission.READ_CONTACTS" to permissionInfo)
val overrides = findPermissionsToOverride(problems)
assertThat(overrides).isEmpty()
}
@Test
fun `ignores permission if file does not exist`() {
val nonExistentPath = "/path/to/nothing.xml"
val permissionInfo = PermissionInfo(
maxSdkSources = mutableSetOf(manifestWithMaxSdk.absolutePath),
manifestPaths = mutableSetOf(
manifestWithMaxSdk.absolutePath,
nonExistentPath
)
)
val problems = mapOf("android.permission.READ_CONTACTS" to permissionInfo)
val overrides = findPermissionsToOverride(problems)
assertThat(overrides).isEmpty()
}
}

View File

@@ -0,0 +1,107 @@
package expo.modules.plugin
import com.google.common.truth.Truth.assertThat
import org.gradle.testfixtures.ProjectBuilder
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
class FixManifestMaxSdkTaskTest {
@get:Rule
val tempFolder = TemporaryFolder()
private lateinit var blameReportFile: File
private lateinit var mergedManifestIn: File
private lateinit var modifiedManifestOut: File
private lateinit var manifest1: File
private lateinit var manifest2: File
@Before
fun setup() {
val projectDir = tempFolder.root
blameReportFile = File(projectDir, "blame-report.txt")
mergedManifestIn = File(projectDir, "merged-manifest-in.xml")
modifiedManifestOut = File(projectDir, "modified-manifest-out.xml")
val manifestDir1 = File(projectDir, "lib1/src/main").apply { mkdirs() }
val manifestDir2 = File(projectDir, "app/src/main").apply { mkdirs() }
manifest1 = File(manifestDir1, "AndroidManifest.xml")
manifest1.writeText("""
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.READ_CONTACTS" android:maxSdkVersion="28" />
</manifest>
""".trimIndent())
manifest2 = File(manifestDir2, "AndroidManifest.xml")
manifest2.writeText("""
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.READ_CONTACTS" />
</manifest>
""".trimIndent())
blameReportFile.writeText("""
uses-permission#android.permission.READ_CONTACTS
MERGED from ${manifest2.absolutePath}:5:3-33
MERGED from ${manifest1.absolutePath}:3:3-83
android:maxSdkVersion
ADDED from ${manifest1.absolutePath}:4:7-34
""".trimIndent())
mergedManifestIn.writeText("""
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.app">
<uses-permission android:name="android.permission.READ_CONTACTS" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
""".trimIndent())
}
@Test
fun `task removes maxSdkVersion from conflicting permission`() {
val project = ProjectBuilder.builder().withProjectDir(tempFolder.root).build()
val task = project.tasks.register("testFixTask", FixManifestMaxSdkTask::class.java).get()
task.blameReportFile.set(blameReportFile)
task.mergedManifestIn.set(mergedManifestIn)
task.modifiedManifestOut.set(modifiedManifestOut)
task.taskAction()
val outputContent = modifiedManifestOut.readText()
assertThat(outputContent).contains("<uses-permission android:name=\"android.permission.READ_CONTACTS\"/>")
assertThat(outputContent).doesNotContain("maxSdkVersion")
assertThat(outputContent).contains("<uses-permission android:name=\"android.permission.INTERNET\"/>")
}
@Test
fun `task copies file directly if no conflicts are found`() {
val project = ProjectBuilder.builder().withProjectDir(tempFolder.root).build()
val task = project.tasks.register("testFixTask", FixManifestMaxSdkTask::class.java).get()
blameReportFile.writeText("""
uses-permission#android.permission.READ_CONTACTS
MERGED from /app/src/main/AndroidManifest.xml:5:3-33
""".trimIndent())
val originalContent = mergedManifestIn.readText()
task.blameReportFile.set(blameReportFile)
task.mergedManifestIn.set(mergedManifestIn)
task.modifiedManifestOut.set(modifiedManifestOut)
task.taskAction()
val outputContent = modifiedManifestOut.readText()
assertThat(outputContent).isEqualTo(originalContent)
assertThat(outputContent).contains("maxSdkVersion=\"28\"")
}
}

View File

@@ -0,0 +1,16 @@
pluginManagement {
repositories {
mavenCentral()
google()
gradlePluginPortal()
}
}
include(
":expo-autolinking-plugin-shared",
":expo-autolinking-settings-plugin",
":expo-autolinking-plugin",
":expo-max-sdk-override-plugin"
)
rootProject.name = "expo-gradle-plugin"