expo-modules-autolinking 3.1.0-canary-20251210-1f163e3 → 3.1.0-canary-20251211-7da85ea

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (15) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/android/expo-gradle-plugin/expo-autolinking-settings-plugin/src/main/kotlin/expo/modules/plugin/ExpoAutolinkingSettingsPlugin.kt +28 -1
  3. package/android/expo-gradle-plugin/expo-max-sdk-override-plugin/build.gradle.kts +44 -0
  4. package/android/expo-gradle-plugin/expo-max-sdk-override-plugin/src/main/kotlin/expo/modules/plugin/AnalyzeManifestReport.kt +69 -0
  5. package/android/expo-gradle-plugin/expo-max-sdk-override-plugin/src/main/kotlin/expo/modules/plugin/ExpoMaxSdkOverridePlugin.kt +44 -0
  6. package/android/expo-gradle-plugin/expo-max-sdk-override-plugin/src/main/kotlin/expo/modules/plugin/ExpoMaxSdkOverrideTask.kt +129 -0
  7. package/android/expo-gradle-plugin/expo-max-sdk-override-plugin/src/main/kotlin/expo/modules/plugin/FindPermissionsToOverride.kt +62 -0
  8. package/android/expo-gradle-plugin/expo-max-sdk-override-plugin/src/main/kotlin/expo/modules/plugin/PermissionInfo.kt +6 -0
  9. package/android/expo-gradle-plugin/expo-max-sdk-override-plugin/src/main/kotlin/expo/modules/plugin/utils/ExtractPathFromLine.kt +16 -0
  10. package/android/expo-gradle-plugin/expo-max-sdk-override-plugin/src/test/java/expo/modules/plugin/AnalyzeManifestReportTest.kt +102 -0
  11. package/android/expo-gradle-plugin/expo-max-sdk-override-plugin/src/test/java/expo/modules/plugin/ExtractPathFromLineTest.kt +35 -0
  12. package/android/expo-gradle-plugin/expo-max-sdk-override-plugin/src/test/java/expo/modules/plugin/FindPermissionsToOverrideTest.kt +91 -0
  13. package/android/expo-gradle-plugin/expo-max-sdk-override-plugin/src/test/java/expo/modules/plugin/FixManifestMaxSdkTaskTest.kt +107 -0
  14. package/android/expo-gradle-plugin/settings.gradle.kts +2 -1
  15. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -43,6 +43,7 @@
43
43
 
44
44
  - Refactor test suite and file discovery implementation to drop `glob` ([#40601](https://github.com/expo/expo/pull/40601) by [@kitten](https://github.com/kitten))
45
45
  - Improve recursive dependency resolution performance ([#40651](https://github.com/expo/expo/pull/40651) by [@kitten](https://github.com/kitten))
46
+ - Add `expo-max-sdk-override-plugin` gradle plugin to change the default manifest merger behaviour for `android:maxSdkVersion` conflicts. ([#40973](https://github.com/expo/expo/pull/40973) by [@behenate](https://github.com/behenate))
46
47
 
47
48
  ## 3.0.19 - 2025-10-23
48
49
 
@@ -3,9 +3,13 @@ package expo.modules.plugin
3
3
  import expo.modules.plugin.gradle.addBuildCache
4
4
  import expo.modules.plugin.gradle.beforeRootProject
5
5
  import expo.modules.plugin.gradle.loadLocalProperties
6
+ import expo.modules.plugin.text.Colors
7
+ import expo.modules.plugin.text.withColor
6
8
  import expo.modules.plugin.utils.getPropertiesPrefixedBy
7
9
  import org.gradle.api.Plugin
10
+ import org.gradle.api.UnknownProjectException
8
11
  import org.gradle.api.initialization.Settings
12
+ import org.gradle.internal.cc.base.logger
9
13
  import java.io.File
10
14
  import java.util.Properties
11
15
 
@@ -28,7 +32,10 @@ open class ExpoAutolinkingSettingsPlugin : Plugin<Settings> {
28
32
  rootProject
29
33
  .buildscript
30
34
  .dependencies
31
- .add("classpath", "expo.modules:expo-autolinking-plugin")
35
+ .apply {
36
+ add("classpath", "expo.modules:expo-autolinking-plugin")
37
+ add("classpath", "expo.modules:expo-max-sdk-override-plugin")
38
+ }
32
39
  }
33
40
 
34
41
  // Includes the `expo-gradle-plugin` subproject.
@@ -36,6 +43,8 @@ open class ExpoAutolinkingSettingsPlugin : Plugin<Settings> {
36
43
  expoGradlePluginsFile.absolutePath
37
44
  )
38
45
  }
46
+
47
+ configureMaxSdkOverridePlugin(settings)
39
48
  }
40
49
 
41
50
  private fun getExpoGradlePluginsFile(settings: Settings): File {
@@ -52,4 +61,22 @@ open class ExpoAutolinkingSettingsPlugin : Plugin<Settings> {
52
61
  "android/expo-gradle-plugin"
53
62
  )
54
63
  }
64
+
65
+ private fun configureMaxSdkOverridePlugin(settings: Settings) {
66
+ settings.gradle.beforeRootProject { rootProject ->
67
+ try {
68
+ rootProject.project(":app") { appProject ->
69
+ appProject.pluginManager.withPlugin("com.android.application") {
70
+ appProject.pluginManager.apply("expo-max-sdk-override-plugin")
71
+ }
72
+ }
73
+ } catch (e: UnknownProjectException) {
74
+ logger.error(
75
+ " ℹ️ Failed to apply gradle plugin ".withColor(Colors.RESET)
76
+ + "'expo-max-sdk-override-plugin'".withColor(Colors.GREEN)
77
+ + ". Plugin has failed to find the ':app' project. It will not be applied.".withColor(Colors.RESET)
78
+ )
79
+ }
80
+ }
81
+ }
55
82
  }
@@ -0,0 +1,44 @@
1
+ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
2
+ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
3
+
4
+ plugins {
5
+ kotlin("jvm")
6
+ id("java-gradle-plugin")
7
+ }
8
+
9
+ repositories {
10
+ google()
11
+ mavenCentral()
12
+ }
13
+
14
+ dependencies {
15
+ implementation(project(":expo-autolinking-plugin-shared"))
16
+ implementation(gradleApi())
17
+ compileOnly("com.android.tools.build:gradle:8.5.0")
18
+
19
+ testImplementation("junit:junit:4.13.2")
20
+ testImplementation("com.google.truth:truth:1.1.2")
21
+ testImplementation(gradleTestKit())
22
+ }
23
+
24
+ java {
25
+ sourceCompatibility = JavaVersion.VERSION_11
26
+ targetCompatibility = JavaVersion.VERSION_11
27
+ }
28
+
29
+ tasks.withType<KotlinCompile> {
30
+ compilerOptions {
31
+ jvmTarget.set(JvmTarget.JVM_11)
32
+ }
33
+ }
34
+
35
+ group = "expo.modules"
36
+
37
+ gradlePlugin {
38
+ plugins {
39
+ create("expoMaxSdkOverridePlugin") {
40
+ id = "expo-max-sdk-override-plugin"
41
+ implementationClass = "expo.modules.plugin.ExpoMaxSdkOverridePlugin"
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,69 @@
1
+ package expo.modules.plugin
2
+
3
+ import expo.modules.plugin.utils.extractPathFromLine
4
+
5
+ /**
6
+ * Analyzes the manifest merge report content to find permissions that may be defined with android:maxSdkVersion,
7
+ * where one AndroidManifest.xml defines the permission with the aforementioned annotation and another one without.
8
+ *
9
+ * This method should be used to reduce the search scope for `findPermissionsToOverride` method.
10
+ *
11
+ * @param reportContent The content of the manifest merge report to analyze.
12
+ * @return A map of suspicious permission names to their corresponding PermissionInfo objects.
13
+ */
14
+ internal fun analyzeManifestReport(reportContent: String): Map<String, PermissionInfo> {
15
+ val allPermissionInfo = mutableMapOf<String, PermissionInfo>()
16
+ var currentPermission: String? = null
17
+ var lastAttributeWasMaxSdk = false
18
+
19
+ for (line in reportContent.lines()) {
20
+ val trimmedLine = line.trimStart()
21
+
22
+ // This line starts a new permission definition
23
+ if (line.startsWith("uses-permission#")) {
24
+ currentPermission = line.substringAfter("uses-permission#").trim()
25
+ allPermissionInfo.getOrPut(currentPermission) {
26
+ PermissionInfo()
27
+ }
28
+ lastAttributeWasMaxSdk = false
29
+ } else if (currentPermission != null) {
30
+ when {
31
+ trimmedLine.startsWith("android:maxSdkVersion") -> {
32
+ lastAttributeWasMaxSdk = true
33
+ }
34
+
35
+ // Source of maxSdkVersion annotation
36
+ line.startsWith("\t") && (trimmedLine.startsWith("ADDED from") || trimmedLine.startsWith("MERGED from")) -> {
37
+ if (lastAttributeWasMaxSdk) {
38
+ extractPathFromLine(line)?.let { source ->
39
+ allPermissionInfo[currentPermission]?.maxSdkSources?.add(source)
40
+ }
41
+ }
42
+ lastAttributeWasMaxSdk = false
43
+ }
44
+
45
+ // Source of the permission definition
46
+ !line.startsWith("\t") && (trimmedLine.startsWith("ADDED from") || trimmedLine.startsWith("MERGED from")) -> {
47
+ extractPathFromLine(line)?.let { path ->
48
+ allPermissionInfo[currentPermission]?.manifestPaths?.add(path)
49
+ }
50
+ lastAttributeWasMaxSdk = false
51
+ }
52
+
53
+ else -> {
54
+ lastAttributeWasMaxSdk = false
55
+ }
56
+ }
57
+ }
58
+ }
59
+
60
+ // Permissions which may have maxSdkConflicts, happen when there is more than one
61
+ // source fora a permission and the permission is annotated with maxSdkVersion
62
+ val problematicPermissions = allPermissionInfo.filter { (permission, info) ->
63
+ val multipleDefinitions = info.manifestPaths.size > 1
64
+ val maxSdkDefined = info.maxSdkSources.isNotEmpty()
65
+ multipleDefinitions && maxSdkDefined
66
+ }
67
+
68
+ return problematicPermissions.toMap()
69
+ }
@@ -0,0 +1,44 @@
1
+ package expo.modules.plugin
2
+
3
+ import com.android.build.api.artifact.SingleArtifact
4
+ import org.gradle.api.Plugin
5
+ import org.gradle.api.Project
6
+ import com.android.build.api.variant.ApplicationAndroidComponentsExtension
7
+ import expo.modules.plugin.text.Colors
8
+ import expo.modules.plugin.text.Emojis
9
+ import expo.modules.plugin.text.withColor
10
+ import org.gradle.internal.cc.base.logger
11
+
12
+ /**
13
+ * Plugin, which registers ExpoMaxSdkOverrideTask and schedules it to run with `app:processDebugManifest`.
14
+ *
15
+ * The task finds all permissions declared with `android:maxSdkVersion`. If the permission was declared in more than one place, and one of the places
16
+ * defines the task without `android:maxSdkVersion` the task will remove the `android:maxSdkVersion` from the final merged manifest
17
+ */
18
+ class ExpoMaxSdkOverridePlugin : Plugin<Project> {
19
+ override fun apply(project: Project) {
20
+ val androidComponents = project.extensions.getByType(ApplicationAndroidComponentsExtension::class.java)
21
+ logger.quiet(" ${Emojis.INFORMATION} ${"Applying gradle plugin".withColor(Colors.YELLOW)} '${"expo-max-sdk-override-plugin".withColor(Colors.GREEN)}'")
22
+ 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.")
23
+
24
+ androidComponents.onVariants(androidComponents.selector().all()) { variant ->
25
+ val taskName = "expo${variant.name.replaceFirstChar { it.uppercase() }}OverrideMaxSdkConflicts"
26
+ val blameReportPath = "outputs/logs/manifest-merger-${variant.name}-report.txt"
27
+ val reportFile = project.layout.buildDirectory.file(blameReportPath)
28
+ val fixTaskProvider = project.tasks.register(
29
+ taskName,
30
+ FixManifestMaxSdkTask::class.java
31
+ ) { task ->
32
+ task.blameReportFile.set(reportFile)
33
+ }
34
+
35
+ variant.artifacts
36
+ .use(fixTaskProvider)
37
+ .wiredWithFiles(
38
+ FixManifestMaxSdkTask::mergedManifestIn,
39
+ FixManifestMaxSdkTask::modifiedManifestOut
40
+ )
41
+ .toTransform(SingleArtifact.MERGED_MANIFEST)
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,129 @@
1
+ package expo.modules.plugin
2
+
3
+ import expo.modules.plugin.text.Colors
4
+ import expo.modules.plugin.text.withColor
5
+ import org.gradle.api.DefaultTask
6
+ import org.gradle.api.file.RegularFileProperty
7
+ import org.gradle.api.tasks.InputFile
8
+ import org.gradle.api.tasks.OutputFile
9
+ import org.gradle.api.tasks.TaskAction
10
+ import java.io.File
11
+ import javax.xml.parsers.DocumentBuilderFactory
12
+ import javax.xml.transform.OutputKeys
13
+ import javax.xml.transform.TransformerFactory
14
+ import javax.xml.transform.dom.DOMSource
15
+ import javax.xml.transform.stream.StreamResult
16
+
17
+ /**
18
+ * This task reads the manifest merge report, finds conflicting permissions, and removes 'android:maxSdkVersion' from them in the final merged manifest.
19
+ */
20
+ abstract class FixManifestMaxSdkTask : DefaultTask() {
21
+ @get:InputFile
22
+ abstract val blameReportFile: RegularFileProperty
23
+
24
+ @get:InputFile
25
+ abstract val mergedManifestIn: RegularFileProperty
26
+
27
+ @get:OutputFile
28
+ abstract val modifiedManifestOut: RegularFileProperty
29
+
30
+ @TaskAction
31
+ fun taskAction() {
32
+ val blameFile = blameReportFile.get().asFile
33
+ val inManifest = mergedManifestIn.get().asFile
34
+ val outManifest = modifiedManifestOut.get().asFile
35
+
36
+ logger.quiet("---------- Expo Max Sdk Override Plugin ----------".withColor(Colors.YELLOW))
37
+
38
+ if (!blameFile.exists()) {
39
+ logger.warn("Manifest blame report not found: ${blameFile.absolutePath}. Skipping `android:maxSdkVersion` permission conflict check.")
40
+ inManifest.copyTo(outManifest, overwrite = true)
41
+ logNoChanges()
42
+ return
43
+ }
44
+
45
+ val reportContents = blameFile.readText()
46
+ val potentialProblems = analyzeManifestReport(reportContents)
47
+
48
+ if (potentialProblems.isEmpty()) {
49
+ inManifest.copyTo(outManifest, overwrite = true)
50
+ logNoChanges()
51
+ return
52
+ }
53
+
54
+ val brokenPermissions = findPermissionsToOverride(potentialProblems)
55
+
56
+ if (brokenPermissions.isEmpty()) {
57
+ inManifest.copyTo(outManifest, overwrite = true)
58
+ logNoChanges()
59
+ return
60
+ }
61
+
62
+ logger.quiet(">>> WARNING: Found ${brokenPermissions.size} permission(s) with conflicting 'android:maxSdkVersion' declarations.".withColor(Colors.YELLOW))
63
+ brokenPermissions.forEach { (permission, info) ->
64
+ val sourcesWithoutMaxSdk = info.manifestPaths.subtract(info.maxSdkSources)
65
+ logger.quiet(" - $permission".withColor(Colors.YELLOW))
66
+ logger.quiet(" > Defined WITH `android:maxSdkVersion` in: ${info.maxSdkSources.joinToString()}".withColor(Colors.YELLOW))
67
+ logger.quiet(" > Defined WITHOUT `android:maxSdkVersion` in: ${sourcesWithoutMaxSdk.joinToString()}".withColor(Colors.YELLOW))
68
+ }
69
+ logger.quiet(">>> Removing 'android:maxSdkVersion' from these permissions in the final manifest to prevent runtime issues.".withColor(Colors.YELLOW))
70
+
71
+ tryFixManifest(inManifest, outManifest, brokenPermissions)
72
+
73
+ logger.quiet("--------------------------------------------------".withColor(Colors.YELLOW))
74
+ }
75
+
76
+ private fun logNoChanges() {
77
+ logger.quiet(">>> No 'android:maxSdkVersion' conflicts found".withColor(Colors.GREEN))
78
+ logger.quiet("--------------------------------------------------".withColor(Colors.YELLOW))
79
+ }
80
+
81
+ private fun tryFixManifest(inManifest: File, outManifest: File, brokenPermissions: Map<String, PermissionInfo>) {
82
+ try {
83
+ val factory = DocumentBuilderFactory.newInstance().apply {
84
+ isNamespaceAware = true
85
+ }
86
+ val builder = factory.newDocumentBuilder()
87
+
88
+ val doc = builder.parse(inManifest)
89
+ val permissionNodes = doc.getElementsByTagName(ManifestConstants.USES_PERMISSION_TAG)
90
+ var modificationsMade = 0
91
+
92
+ val nodesToProcess = (0 until permissionNodes.length)
93
+ .map { permissionNodes.item(it) }
94
+ .filterIsInstance<org.w3c.dom.Element>()
95
+
96
+ for (element in nodesToProcess) {
97
+ val permissionName = element.getAttribute(ManifestConstants.ANDROID_NAME_ATTRIBUTE)
98
+
99
+ if (brokenPermissions.containsKey(permissionName) && element.hasAttribute(ManifestConstants.ANDROID_MAX_SDK_VERSION_ATTRIBUTE)) {
100
+ element.removeAttribute(ManifestConstants.ANDROID_MAX_SDK_VERSION_ATTRIBUTE)
101
+ modificationsMade++
102
+ }
103
+ }
104
+
105
+ if (modificationsMade > 0) {
106
+ logger.quiet(">>> Removed 'android:maxSdkVersion' from $modificationsMade instance(s) in the final manifest.".withColor(Colors.YELLOW))
107
+ }
108
+
109
+ TransformerFactory.newInstance().newTransformer().apply {
110
+ setOutputProperty(OutputKeys.INDENT, "yes")
111
+ setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4")
112
+ setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no")
113
+ setOutputProperty(OutputKeys.ENCODING, "UTF-8")
114
+ transform(DOMSource(doc), StreamResult(outManifest))
115
+ }
116
+ } catch (e: Exception) {
117
+ logger.error("Failed to parse and fix merged manifest: ${e.message}".withColor(Colors.RESET), e)
118
+ logger.quiet(">>> Restored the original merged manifest.".withColor(Colors.YELLOW))
119
+
120
+ inManifest.copyTo(outManifest, overwrite = true)
121
+ }
122
+ }
123
+ }
124
+
125
+ private object ManifestConstants {
126
+ const val USES_PERMISSION_TAG = "uses-permission"
127
+ const val ANDROID_NAME_ATTRIBUTE = "android:name"
128
+ const val ANDROID_MAX_SDK_VERSION_ATTRIBUTE = "android:maxSdkVersion"
129
+ }
@@ -0,0 +1,62 @@
1
+ package expo.modules.plugin
2
+
3
+ import org.gradle.internal.cc.base.logger
4
+ import java.io.File
5
+ import javax.xml.parsers.DocumentBuilderFactory
6
+
7
+ /**
8
+ * Based on a map of `String` and `PermissionInfo` read and parse manifest files, finds cases where
9
+ * a permission is defined in one place with `android:maxSdkVersion` and in another without that annotation.
10
+ *
11
+ * @param problematicPermissions A Map of `String` and `PermissionInfo` obtained with analyzeManifestReport
12
+ */
13
+ internal fun findPermissionsToOverride(problematicPermissions: Map<String, PermissionInfo>): Map<String, PermissionInfo> {
14
+ val factory = DocumentBuilderFactory.newInstance()
15
+ factory.isNamespaceAware = true
16
+
17
+ // Basic security
18
+ factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) // Disallow parsing <!DOCTYPE> files
19
+ factory.setFeature("http://xml.org/sax/features/external-general-entities", false) // Prevent external general entities
20
+ factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false) // Prevent external paramater entities
21
+
22
+ val builder = factory.newDocumentBuilder()
23
+ val brokenPermissions = mutableMapOf<String, PermissionInfo>()
24
+
25
+ problematicPermissions.forEach { permission, info ->
26
+ // Not actually a problematic permission
27
+ if (info.maxSdkSources.size == 0) {
28
+ return@forEach
29
+ }
30
+
31
+ info.manifestPaths.forEach { manifestPath ->
32
+ try {
33
+ val file = File(manifestPath)
34
+ if (!file.exists() || !file.canRead()) {
35
+ logger.error("Failed to open manifest file at: $manifestPath")
36
+ return@forEach
37
+ }
38
+
39
+ val doc = builder.parse(file)
40
+ val permissionNodes = doc.getElementsByTagName("uses-permission")
41
+
42
+ for (i in 0 until permissionNodes.length) {
43
+ val permissionNode = permissionNodes.item(i)
44
+
45
+ if (permissionNode.nodeType == org.w3c.dom.Node.ELEMENT_NODE) {
46
+ val element = permissionNode as org.w3c.dom.Element
47
+ val permissionName = element.getAttribute("android:name")
48
+
49
+ if (permissionName == permission && !element.hasAttribute("android:maxSdkVersion")) {
50
+ brokenPermissions[permission] = info
51
+ return@forEach
52
+ }
53
+ }
54
+ }
55
+ } catch (e: Exception) {
56
+ logger.error("Failed to parse manifest at ${manifestPath}", e)
57
+ }
58
+ }
59
+ }
60
+
61
+ return brokenPermissions
62
+ }
@@ -0,0 +1,6 @@
1
+ package expo.modules.plugin
2
+
3
+ internal data class PermissionInfo(
4
+ val maxSdkSources: MutableSet<String> = mutableSetOf(),
5
+ val manifestPaths: MutableSet<String> = mutableSetOf()
6
+ )
@@ -0,0 +1,16 @@
1
+ package expo.modules.plugin.utils
2
+
3
+ /**
4
+ * Extracts a single file path from one line of an AndroidManifest merge log.
5
+ *
6
+ * @param line The raw single-line string from the build log.
7
+ * @return A String containing the absolute file path to the manifest, or null if no path is found.
8
+ */
9
+ fun extractPathFromLine(line: String): String? {
10
+ // Regex to find a path starting with '/' and ending just before
11
+ // the line/column numbers (e.g., :11:3)
12
+ val regex = Regex("(/.*?):\\d+:\\d+.*")
13
+ val match = regex.find(line)
14
+
15
+ return match?.groups?.get(1)?.value
16
+ }
@@ -0,0 +1,102 @@
1
+ package expo.modules.plugin
2
+
3
+ import com.google.common.truth.Truth.assertThat
4
+ import org.junit.Test
5
+
6
+ class AnalyzeManifestReportTest {
7
+
8
+ @Test
9
+ fun `finds permission with conflict`() {
10
+ val reportContent = """
11
+ uses-permission#android.permission.READ_CONTACTS
12
+ MERGED from /Users/user/project/app/src/main/AndroidManifest.xml:11:3-33
13
+ MERGED from /Users/user/project/library/src/main/AndroidManifest.xml:15:3-83
14
+ android:maxSdkVersion
15
+ ADDED from /Users/user/project/library/src/main/AndroidManifest.xml:16:7-34
16
+ """.trimIndent()
17
+
18
+ val problems = analyzeManifestReport(reportContent)
19
+
20
+ assertThat(problems).hasSize(1)
21
+ assertThat(problems).containsKey("android.permission.READ_CONTACTS")
22
+
23
+ val info = problems["android.permission.READ_CONTACTS"]
24
+ assertThat(info).isNotNull()
25
+
26
+ info?.let {
27
+ assertThat(info.manifestPaths).containsExactly(
28
+ "/Users/user/project/app/src/main/AndroidManifest.xml",
29
+ "/Users/user/project/library/src/main/AndroidManifest.xml"
30
+ )
31
+ assertThat(info.maxSdkSources).containsExactly(
32
+ "/Users/user/project/library/src/main/AndroidManifest.xml"
33
+ )
34
+ }
35
+ }
36
+
37
+ @Test
38
+ fun `ignores permission with no conflict`() {
39
+ val reportContent = """
40
+ uses-permission#android.permission.READ_CONTACTS
41
+ MERGED from /Users/user/project/app/src/main/AndroidManifest.xml:11:3-33
42
+ MERGED from /Users/user/project/library/src/main/AndroidManifest.xml:15:3-83
43
+ """.trimIndent()
44
+
45
+ val problems = analyzeManifestReport(reportContent)
46
+ assertThat(problems).isEmpty()
47
+ }
48
+
49
+ @Test
50
+ fun `ignores permission with only one source`() {
51
+ val reportContent = """
52
+ uses-permission#android.permission.READ_CONTACTS
53
+ MERGED from /Users/user/project/app/src/main/AndroidManifest.xml:11:3-33
54
+ android:maxSdkVersion
55
+ ADDED from /Users/user/project/app/src/main/AndroidManifest.xml:12:7-34
56
+ """.trimIndent()
57
+
58
+ val problems = analyzeManifestReport(reportContent)
59
+
60
+ assertThat(problems).isEmpty()
61
+ }
62
+
63
+ @Test
64
+ fun `handles multiple permissions`() {
65
+ val reportContent = """
66
+ uses-permission#android.permission.READ_CONTACTS
67
+ MERGED from /Users/user/project/app/src/main/AndroidManifest.xml:11:3-33
68
+ MERGED from /Users/user/project/library/src/main/AndroidManifest.xml:15:3-83
69
+ android:maxSdkVersion
70
+ ADDED from /Users/user/project/library/src/main/AndroidManifest.xml:16:7-34
71
+
72
+ uses-permission#android.permission.WRITE_EXTERNAL_STORAGE
73
+ MERGED from /Users/user/project/app/src/main/AndroidManifest.xml:13:3-33
74
+
75
+ uses-permission#android.permission.READ_EXTERNAL_STORAGE
76
+ MERGED from /Users/user/project/app/src/main/AndroidManifest.xml:14:3-33
77
+ MERGED from /Users/user/project/otherlib/src/main/AndroidManifest.xml:9:3-83
78
+ android:maxSdkVersion
79
+ ADDED from /Users/user/project/app/src/main/AndroidManifest.xml:15:7-34
80
+ """.trimIndent()
81
+
82
+ val problems = analyzeManifestReport(reportContent)
83
+
84
+ assertThat(problems).hasSize(2)
85
+ assertThat(problems).containsKey("android.permission.READ_CONTACTS")
86
+ assertThat(problems).containsKey("android.permission.READ_EXTERNAL_STORAGE")
87
+
88
+ val readContactsInfo = problems["android.permission.READ_CONTACTS"]!!
89
+ assertThat(readContactsInfo.maxSdkSources).containsExactly(
90
+ "/Users/user/project/library/src/main/AndroidManifest.xml"
91
+ )
92
+
93
+ val readStorageInfo = problems["android.permission.READ_EXTERNAL_STORAGE"]!!
94
+ assertThat(readStorageInfo.manifestPaths).containsExactly(
95
+ "/Users/user/project/app/src/main/AndroidManifest.xml",
96
+ "/Users/user/project/otherlib/src/main/AndroidManifest.xml"
97
+ )
98
+ assertThat(readStorageInfo.maxSdkSources).containsExactly(
99
+ "/Users/user/project/app/src/main/AndroidManifest.xml"
100
+ )
101
+ }
102
+ }
@@ -0,0 +1,35 @@
1
+ package expo.modules.plugin.utils
2
+
3
+ import com.google.common.truth.Truth.assertThat
4
+ import org.junit.Test
5
+
6
+ class ExtractPathFromLineTest {
7
+
8
+ @Test
9
+ fun `extracts path from a standard merge log line`() {
10
+ val line = "\tMERGED from /Users/user/project/app/src/main/AndroidManifest.xml:11:3-33"
11
+ val path = extractPathFromLine(line)
12
+ assertThat(path).isEqualTo("/Users/user/project/app/src/main/AndroidManifest.xml")
13
+ }
14
+
15
+ @Test
16
+ fun `extracts path from an ADDED line`() {
17
+ val line = " ADDED from /Users/user/project/library/build/intermediates/merged_manifest/debug/AndroidManifest.xml:23:7-77"
18
+ val path = extractPathFromLine(line)
19
+ assertThat(path).isEqualTo("/Users/user/project/library/build/intermediates/merged_manifest/debug/AndroidManifest.xml")
20
+ }
21
+
22
+ @Test
23
+ fun `extracts path from an ADDED line with space`() {
24
+ val line = " ADDED from /Users/happy user/project/library/build/intermediates/merged_manifest/debug/AndroidManifest.xml:23:7-77"
25
+ val path = extractPathFromLine(line)
26
+ assertThat(path).isEqualTo("/Users/happy user/project/library/build/intermediates/merged_manifest/debug/AndroidManifest.xml")
27
+ }
28
+
29
+ @Test
30
+ fun `returns null if no path is found`() {
31
+ val line = "\tandroid:maxSdkVersion"
32
+ val path = extractPathFromLine(line)
33
+ assertThat(path).isNull()
34
+ }
35
+ }
@@ -0,0 +1,91 @@
1
+ package expo.modules.plugin
2
+
3
+ import com.google.common.truth.Truth.assertThat
4
+ import org.junit.Before
5
+ import org.junit.Rule
6
+ import org.junit.Test
7
+ import org.junit.rules.TemporaryFolder
8
+ import java.io.File
9
+
10
+ class FindPermissionsToOverrideTest {
11
+
12
+ @get:Rule
13
+ val tempFolder = TemporaryFolder()
14
+
15
+ private lateinit var manifestWithMaxSdk: File
16
+ private lateinit var manifestWithoutMaxSdk: File
17
+
18
+ @Before
19
+ fun setup() {
20
+ manifestWithMaxSdk = File(tempFolder.root, "max_sdk_manifest.xml")
21
+ manifestWithMaxSdk.writeText("""
22
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
23
+ <uses-permission
24
+ android:name="android.permission.READ_CONTACTS"
25
+ android:maxSdkVersion="28" />
26
+ </manifest>
27
+ """.trimIndent())
28
+
29
+ manifestWithoutMaxSdk = File(tempFolder.root, "no_max_sdk_manifest.xml")
30
+ manifestWithoutMaxSdk.writeText("""
31
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
32
+ <uses-permission android:name="android.permission.READ_CONTACTS" />
33
+ <uses-permission android:name="android.permission.WRITE_CALENDAR" />
34
+ </manifest>
35
+ """.trimIndent())
36
+ }
37
+
38
+ @Test
39
+ fun `finds permission that needs to be overridden`() {
40
+ val permissionInfo = PermissionInfo(
41
+ maxSdkSources = mutableSetOf(manifestWithMaxSdk.absolutePath),
42
+ manifestPaths = mutableSetOf(
43
+ manifestWithMaxSdk.absolutePath,
44
+ manifestWithoutMaxSdk.absolutePath
45
+ )
46
+ )
47
+ val problems = mapOf("android.permission.READ_CONTACTS" to permissionInfo)
48
+ val overrides = findPermissionsToOverride(problems)
49
+
50
+ assertThat(overrides).hasSize(1)
51
+ assertThat(overrides).containsKey("android.permission.READ_CONTACTS")
52
+ }
53
+
54
+ @Test
55
+ fun `does not find override if no conflict exists`() {
56
+ val manifestWithMaxSdk2 = File(tempFolder.root, "max_sdk_manifest_2.xml")
57
+ manifestWithMaxSdk2.writeText("""
58
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
59
+ <uses-permission android:name="android.permission.READ_CONTACTS" android:maxSdkVersion="28" />
60
+ </manifest>
61
+ """.trimIndent())
62
+
63
+ val permissionInfo = PermissionInfo(
64
+ maxSdkSources = mutableSetOf(manifestWithMaxSdk.absolutePath, manifestWithMaxSdk2.absolutePath),
65
+ manifestPaths = mutableSetOf(
66
+ manifestWithMaxSdk.absolutePath,
67
+ manifestWithMaxSdk2.absolutePath
68
+ )
69
+ )
70
+ val problems = mapOf("android.permission.READ_CONTACTS" to permissionInfo)
71
+ val overrides = findPermissionsToOverride(problems)
72
+
73
+ assertThat(overrides).isEmpty()
74
+ }
75
+
76
+ @Test
77
+ fun `ignores permission if file does not exist`() {
78
+ val nonExistentPath = "/path/to/nothing.xml"
79
+ val permissionInfo = PermissionInfo(
80
+ maxSdkSources = mutableSetOf(manifestWithMaxSdk.absolutePath),
81
+ manifestPaths = mutableSetOf(
82
+ manifestWithMaxSdk.absolutePath,
83
+ nonExistentPath
84
+ )
85
+ )
86
+ val problems = mapOf("android.permission.READ_CONTACTS" to permissionInfo)
87
+ val overrides = findPermissionsToOverride(problems)
88
+
89
+ assertThat(overrides).isEmpty()
90
+ }
91
+ }
@@ -0,0 +1,107 @@
1
+ package expo.modules.plugin
2
+
3
+ import com.google.common.truth.Truth.assertThat
4
+ import org.gradle.testfixtures.ProjectBuilder
5
+ import org.junit.Before
6
+ import org.junit.Rule
7
+ import org.junit.Test
8
+ import org.junit.rules.TemporaryFolder
9
+ import java.io.File
10
+
11
+ class FixManifestMaxSdkTaskTest {
12
+ @get:Rule
13
+ val tempFolder = TemporaryFolder()
14
+
15
+ private lateinit var blameReportFile: File
16
+ private lateinit var mergedManifestIn: File
17
+ private lateinit var modifiedManifestOut: File
18
+
19
+ private lateinit var manifest1: File
20
+ private lateinit var manifest2: File
21
+
22
+ @Before
23
+ fun setup() {
24
+ val projectDir = tempFolder.root
25
+ blameReportFile = File(projectDir, "blame-report.txt")
26
+ mergedManifestIn = File(projectDir, "merged-manifest-in.xml")
27
+ modifiedManifestOut = File(projectDir, "modified-manifest-out.xml")
28
+
29
+ val manifestDir1 = File(projectDir, "lib1/src/main").apply { mkdirs() }
30
+ val manifestDir2 = File(projectDir, "app/src/main").apply { mkdirs() }
31
+
32
+ manifest1 = File(manifestDir1, "AndroidManifest.xml")
33
+ manifest1.writeText("""
34
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
35
+ <uses-permission android:name="android.permission.READ_CONTACTS" android:maxSdkVersion="28" />
36
+ </manifest>
37
+ """.trimIndent())
38
+
39
+ manifest2 = File(manifestDir2, "AndroidManifest.xml")
40
+ manifest2.writeText("""
41
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
42
+ <uses-permission android:name="android.permission.READ_CONTACTS" />
43
+ </manifest>
44
+ """.trimIndent())
45
+
46
+ blameReportFile.writeText("""
47
+ uses-permission#android.permission.READ_CONTACTS
48
+ MERGED from ${manifest2.absolutePath}:5:3-33
49
+ MERGED from ${manifest1.absolutePath}:3:3-83
50
+ android:maxSdkVersion
51
+ ADDED from ${manifest1.absolutePath}:4:7-34
52
+ """.trimIndent())
53
+
54
+ mergedManifestIn.writeText("""
55
+ <?xml version="1.0" encoding="utf-8"?>
56
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android"
57
+ package="com.example.app">
58
+
59
+ <uses-permission android:name="android.permission.READ_CONTACTS" android:maxSdkVersion="28" />
60
+ <uses-permission android:name="android.permission.INTERNET" />
61
+
62
+ </manifest>
63
+ """.trimIndent())
64
+ }
65
+
66
+ @Test
67
+ fun `task removes maxSdkVersion from conflicting permission`() {
68
+ val project = ProjectBuilder.builder().withProjectDir(tempFolder.root).build()
69
+ val task = project.tasks.register("testFixTask", FixManifestMaxSdkTask::class.java).get()
70
+
71
+ task.blameReportFile.set(blameReportFile)
72
+ task.mergedManifestIn.set(mergedManifestIn)
73
+ task.modifiedManifestOut.set(modifiedManifestOut)
74
+
75
+ task.taskAction()
76
+
77
+ val outputContent = modifiedManifestOut.readText()
78
+
79
+ assertThat(outputContent).contains("<uses-permission android:name=\"android.permission.READ_CONTACTS\"/>")
80
+ assertThat(outputContent).doesNotContain("maxSdkVersion")
81
+ assertThat(outputContent).contains("<uses-permission android:name=\"android.permission.INTERNET\"/>")
82
+ }
83
+
84
+ @Test
85
+ fun `task copies file directly if no conflicts are found`() {
86
+ val project = ProjectBuilder.builder().withProjectDir(tempFolder.root).build()
87
+ val task = project.tasks.register("testFixTask", FixManifestMaxSdkTask::class.java).get()
88
+
89
+ blameReportFile.writeText("""
90
+ uses-permission#android.permission.READ_CONTACTS
91
+ MERGED from /app/src/main/AndroidManifest.xml:5:3-33
92
+ """.trimIndent())
93
+
94
+ val originalContent = mergedManifestIn.readText()
95
+
96
+ task.blameReportFile.set(blameReportFile)
97
+ task.mergedManifestIn.set(mergedManifestIn)
98
+ task.modifiedManifestOut.set(modifiedManifestOut)
99
+
100
+ task.taskAction()
101
+
102
+ val outputContent = modifiedManifestOut.readText()
103
+
104
+ assertThat(outputContent).isEqualTo(originalContent)
105
+ assertThat(outputContent).contains("maxSdkVersion=\"28\"")
106
+ }
107
+ }
@@ -9,7 +9,8 @@ pluginManagement {
9
9
  include(
10
10
  ":expo-autolinking-plugin-shared",
11
11
  ":expo-autolinking-settings-plugin",
12
- ":expo-autolinking-plugin"
12
+ ":expo-autolinking-plugin",
13
+ ":expo-max-sdk-override-plugin"
13
14
  )
14
15
 
15
16
  rootProject.name = "expo-gradle-plugin"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-modules-autolinking",
3
- "version": "3.1.0-canary-20251210-1f163e3",
3
+ "version": "3.1.0-canary-20251211-7da85ea",
4
4
  "description": "Scripts that autolink Expo modules.",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -34,7 +34,7 @@
34
34
  "license": "MIT",
35
35
  "homepage": "https://github.com/expo/expo/tree/main/packages/expo-modules-autolinking#readme",
36
36
  "devDependencies": {
37
- "expo-module-scripts": "5.1.0-canary-20251210-1f163e3",
37
+ "expo-module-scripts": "5.1.0-canary-20251211-7da85ea",
38
38
  "memfs": "^3.2.0"
39
39
  },
40
40
  "dependencies": {