@callstack/react-native-brownfield 5.1.0 → 5.2.0
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.
- package/CHANGELOG.md +35 -0
- package/android/src/main/java/com/callstack/reactnativebrownfield/ReactNativeBrownfield.kt +22 -4
- package/android/src/main/java/com/callstack/reactnativebrownfield/ReactNativeFragment.kt +3 -1
- package/gradle-plugin/brownfield/build.gradle.kts +74 -2
- package/gradle-plugin/brownfield/gradle/libs.versions.toml +7 -2
- package/gradle-plugin/brownfield/gradle/wrapper/gradle-wrapper.properties +1 -1
- package/gradle-plugin/brownfield/gradle.properties +1 -1
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscoverer.kt +96 -0
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt +10 -214
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/BrownfieldPublishingInfo.kt +7 -0
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt +123 -1
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNSourceSets.kt +6 -1
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/Constants.kt +12 -1
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/{expo/utils/BrownfieldPrimitives.kt → shared/DependencyInfo.kt} +3 -8
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/DependencyPublishability.kt +22 -0
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/GradleDependencyCollector.kt +44 -0
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/MergeClassesTask.kt +2 -0
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/PublishingMetadataInjector.kt +181 -0
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/TransitiveDependencyFiltering.kt +17 -0
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/{expo/utils → shared}/VersionMediatingDependencySet.kt +1 -1
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/Extension.kt +11 -0
- package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscovererTest.kt +171 -0
- package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelperGradleFallbackTest.kt +47 -0
- package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/AgpCompatibilityMatrixTest.kt +155 -0
- package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/LegacyConsumerConfigurationTest.kt +110 -0
- package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/DependencyPublishabilityTest.kt +67 -0
- package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/PublishingMetadataInjectorTest.kt +222 -0
- package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/TransitiveDependencyFilteringTest.kt +37 -0
- package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/VersionMediatingDependencySetTest.kt +42 -0
- package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/utils/ExtensionTest.kt +11 -0
- package/ios/BrownfieldSurfaceRegistry.swift +93 -0
- package/ios/Views/ReactNativeView.swift +34 -5
- package/ios/Views/ReactNativeViewController.swift +120 -1
- package/lib/commonjs/expo-config-plugin/android/utils/constants.js +1 -1
- package/lib/commonjs/expo-config-plugin/android/withAndroidModuleFiles.js +1 -1
- package/lib/commonjs/expo-config-plugin/android/withAndroidModuleFiles.js.map +1 -1
- package/lib/commonjs/expo-config-plugin/template/android/build.gradle.kts +1 -2
- package/lib/commonjs/expo-config-plugin/template/android/proguard-rules.pro +1 -1
- package/lib/module/expo-config-plugin/android/utils/constants.js +1 -1
- package/lib/module/expo-config-plugin/android/withAndroidModuleFiles.js +1 -1
- package/lib/module/expo-config-plugin/android/withAndroidModuleFiles.js.map +1 -1
- package/lib/module/expo-config-plugin/template/android/build.gradle.kts +1 -2
- package/lib/module/expo-config-plugin/template/android/proguard-rules.pro +1 -1
- package/lib/typescript/commonjs/src/expo-config-plugin/android/utils/constants.d.ts +2 -2
- package/lib/typescript/commonjs/src/expo-config-plugin/android/withAndroidModuleFiles.d.ts +11 -1
- package/lib/typescript/commonjs/src/expo-config-plugin/android/withAndroidModuleFiles.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/expo-config-plugin/types/android/BrownfieldPluginAndroidConfig.d.ts +3 -2
- package/lib/typescript/commonjs/src/expo-config-plugin/types/android/BrownfieldPluginAndroidConfig.d.ts.map +1 -1
- package/lib/typescript/module/src/expo-config-plugin/android/utils/constants.d.ts +2 -2
- package/lib/typescript/module/src/expo-config-plugin/android/withAndroidModuleFiles.d.ts +11 -1
- package/lib/typescript/module/src/expo-config-plugin/android/withAndroidModuleFiles.d.ts.map +1 -1
- package/lib/typescript/module/src/expo-config-plugin/types/android/BrownfieldPluginAndroidConfig.d.ts +3 -2
- package/lib/typescript/module/src/expo-config-plugin/types/android/BrownfieldPluginAndroidConfig.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/expo-config-plugin/android/utils/constants.ts +1 -1
- package/src/expo-config-plugin/android/withAndroidModuleFiles.ts +31 -2
- package/src/expo-config-plugin/template/android/build.gradle.kts +1 -2
- package/src/expo-config-plugin/template/android/proguard-rules.pro +1 -1
- package/src/expo-config-plugin/types/android/BrownfieldPluginAndroidConfig.ts +3 -2
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
package com.callstack.react.brownfield.shared
|
|
2
|
+
|
|
3
|
+
import groovy.util.NodeList
|
|
4
|
+
import groovy.xml.XmlParser
|
|
5
|
+
import org.gradle.testfixtures.ProjectBuilder
|
|
6
|
+
import org.junit.jupiter.api.Test
|
|
7
|
+
import kotlin.test.assertEquals
|
|
8
|
+
import kotlin.test.assertNotNull
|
|
9
|
+
import kotlin.test.assertNull
|
|
10
|
+
import kotlin.test.assertTrue
|
|
11
|
+
|
|
12
|
+
class PublishingMetadataInjectorTaskRegistrationTest {
|
|
13
|
+
@Test
|
|
14
|
+
fun `does not claim the task name the setup docs tell consumers to register`() {
|
|
15
|
+
val project = ProjectBuilder.builder().build()
|
|
16
|
+
|
|
17
|
+
PublishingMetadataInjector(project)
|
|
18
|
+
|
|
19
|
+
assertNull(
|
|
20
|
+
project.tasks.findByName(Constants.LEGACY_CONSUMER_MODULE_METADATA_TASK_NAME),
|
|
21
|
+
"the plugin must leave this name free: the published setup docs tell every bare-RN " +
|
|
22
|
+
"consumer to register it themselves, and the plugin applies before their script body",
|
|
23
|
+
)
|
|
24
|
+
assertNotNull(
|
|
25
|
+
project.tasks.findByName(Constants.MODULE_METADATA_POST_PROCESS_TASK_NAME),
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@Test
|
|
30
|
+
fun `a not-yet-migrated consumer can still register the legacy task after the plugin has run`() {
|
|
31
|
+
// The upgrade path at the task-container level: plugin registers during apply(), then the
|
|
32
|
+
// consumer's script body registers the task the docs told them to. Must not throw.
|
|
33
|
+
val project = ProjectBuilder.builder().build()
|
|
34
|
+
|
|
35
|
+
PublishingMetadataInjector(project)
|
|
36
|
+
project.tasks.register(Constants.LEGACY_CONSUMER_MODULE_METADATA_TASK_NAME)
|
|
37
|
+
|
|
38
|
+
assertNotNull(project.tasks.findByName(Constants.LEGACY_CONSUMER_MODULE_METADATA_TASK_NAME))
|
|
39
|
+
assertNotNull(project.tasks.findByName(Constants.MODULE_METADATA_POST_PROCESS_TASK_NAME))
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class MutatePomDependenciesNodeTest {
|
|
44
|
+
private fun dependenciesNodeFrom(xml: String): groovy.util.Node {
|
|
45
|
+
val root = XmlParser().parseText(xml)
|
|
46
|
+
return (root.get("dependencies") as NodeList).first() as groovy.util.Node
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private fun versionsFor(
|
|
50
|
+
dependenciesNode: groovy.util.Node,
|
|
51
|
+
groupId: String,
|
|
52
|
+
artifactId: String,
|
|
53
|
+
): List<String> {
|
|
54
|
+
return dependenciesNode.children()
|
|
55
|
+
.filterIsInstance<groovy.util.Node>()
|
|
56
|
+
.filter {
|
|
57
|
+
(it["groupId"] as NodeList).text() == groupId && (it["artifactId"] as NodeList).text() == artifactId
|
|
58
|
+
}
|
|
59
|
+
.map { (it["version"] as NodeList).text() }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@Test
|
|
63
|
+
fun `appends new dependencies with no pre-existing entries`() {
|
|
64
|
+
val dependenciesNode = dependenciesNodeFrom("<project><dependencies/></project>")
|
|
65
|
+
val toInject = VersionMediatingDependencySet()
|
|
66
|
+
toInject.add(DependencyInfo("androidx.core", "core-ktx", "1.17.0", "compile", false))
|
|
67
|
+
|
|
68
|
+
mutatePomDependenciesNode(dependenciesNode, toInject) { _, _ -> false }
|
|
69
|
+
|
|
70
|
+
assertEquals(listOf("1.17.0"), versionsFor(dependenciesNode, "androidx.core", "core-ktx"))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@Test
|
|
74
|
+
fun `injects into a POM that has no dependencies element at all`() {
|
|
75
|
+
// Must call resolveOrCreateDependenciesNode — inlining the fallback here would make this
|
|
76
|
+
// pass even with the production path reverted to first().
|
|
77
|
+
val root = XmlParser().parseText("<project/>")
|
|
78
|
+
val toInject = VersionMediatingDependencySet()
|
|
79
|
+
toInject.add(DependencyInfo("androidx.core", "core-ktx", "1.17.0", "compile", false))
|
|
80
|
+
|
|
81
|
+
val dependenciesNode = resolveOrCreateDependenciesNode(root)
|
|
82
|
+
mutatePomDependenciesNode(dependenciesNode, toInject) { _, _ -> false }
|
|
83
|
+
|
|
84
|
+
assertEquals(listOf("1.17.0"), versionsFor(dependenciesNode, "androidx.core", "core-ktx"))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@Test
|
|
88
|
+
fun `reuses the existing dependencies element instead of appending a second one`() {
|
|
89
|
+
val root =
|
|
90
|
+
XmlParser().parseText(
|
|
91
|
+
"""
|
|
92
|
+
<project>
|
|
93
|
+
<dependencies>
|
|
94
|
+
<dependency>
|
|
95
|
+
<groupId>androidx.core</groupId>
|
|
96
|
+
<artifactId>core-ktx</artifactId>
|
|
97
|
+
<version>1.17.0</version>
|
|
98
|
+
</dependency>
|
|
99
|
+
</dependencies>
|
|
100
|
+
</project>
|
|
101
|
+
""".trimIndent(),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
val dependenciesNode = resolveOrCreateDependenciesNode(root)
|
|
105
|
+
|
|
106
|
+
assertEquals(
|
|
107
|
+
1,
|
|
108
|
+
(root.get("dependencies") as NodeList).size,
|
|
109
|
+
"a POM that already has <dependencies> must not gain a second one",
|
|
110
|
+
)
|
|
111
|
+
assertEquals(listOf("1.17.0"), versionsFor(dependenciesNode, "androidx.core", "core-ktx"))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
@Test
|
|
115
|
+
fun `removes an unrelated excluded entry that is not being re-injected`() {
|
|
116
|
+
val dependenciesNode =
|
|
117
|
+
dependenciesNodeFrom(
|
|
118
|
+
"""
|
|
119
|
+
<project>
|
|
120
|
+
<dependencies>
|
|
121
|
+
<dependency>
|
|
122
|
+
<groupId>com.rnapp</groupId>
|
|
123
|
+
<artifactId>brownfieldlib</artifactId>
|
|
124
|
+
<version>unspecified</version>
|
|
125
|
+
</dependency>
|
|
126
|
+
</dependencies>
|
|
127
|
+
</project>
|
|
128
|
+
""".trimIndent(),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
mutatePomDependenciesNode(dependenciesNode, VersionMediatingDependencySet()) { groupId, _ ->
|
|
132
|
+
groupId == "com.rnapp"
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
assertTrue(versionsFor(dependenciesNode, "com.rnapp", "brownfieldlib").isEmpty())
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
@Test
|
|
139
|
+
fun `a superseded stale entry is replaced by the mediated version, not left duplicated or missing`() {
|
|
140
|
+
// Consumer hand-declares 1.6.0, embedded module needs 1.7.1, so the coordinate is
|
|
141
|
+
// superseded and shouldExclude matches it. The mediated 1.7.1 must survive exactly once.
|
|
142
|
+
val dependenciesNode =
|
|
143
|
+
dependenciesNodeFrom(
|
|
144
|
+
"""
|
|
145
|
+
<project>
|
|
146
|
+
<dependencies>
|
|
147
|
+
<dependency>
|
|
148
|
+
<groupId>androidx.appcompat</groupId>
|
|
149
|
+
<artifactId>appcompat</artifactId>
|
|
150
|
+
<version>1.6.0</version>
|
|
151
|
+
<scope>runtime</scope>
|
|
152
|
+
</dependency>
|
|
153
|
+
</dependencies>
|
|
154
|
+
</project>
|
|
155
|
+
""".trimIndent(),
|
|
156
|
+
)
|
|
157
|
+
val mediated = VersionMediatingDependencySet()
|
|
158
|
+
mediated.add(DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false))
|
|
159
|
+
|
|
160
|
+
mutatePomDependenciesNode(dependenciesNode, mediated) { groupId, artifactId ->
|
|
161
|
+
groupId == "androidx.appcompat" && artifactId == "appcompat"
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
assertEquals(listOf("1.7.1"), versionsFor(dependenciesNode, "androidx.appcompat", "appcompat"))
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
class MutateModuleJsonVariantsTest {
|
|
169
|
+
private fun jsonWithVariant(dependencies: MutableList<MutableMap<String, Any>>): MutableMap<String, Any> {
|
|
170
|
+
return mutableMapOf(
|
|
171
|
+
"variants" to
|
|
172
|
+
mutableListOf(
|
|
173
|
+
mutableMapOf<String, Any>(
|
|
174
|
+
"name" to "runtimeElements",
|
|
175
|
+
"dependencies" to dependencies,
|
|
176
|
+
),
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
@Suppress("UNCHECKED_CAST")
|
|
182
|
+
private fun dependenciesOf(json: Map<*, *>): List<Map<String, Any>> {
|
|
183
|
+
val variants = json["variants"] as List<Map<String, Any>>
|
|
184
|
+
return variants.first()["dependencies"] as List<Map<String, Any>>
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
@Test
|
|
188
|
+
fun `appends new dependencies with no pre-existing entries`() {
|
|
189
|
+
val json = jsonWithVariant(mutableListOf())
|
|
190
|
+
val toInject = VersionMediatingDependencySet()
|
|
191
|
+
toInject.add(DependencyInfo("androidx.core", "core-ktx", "1.17.0", "compile", false))
|
|
192
|
+
|
|
193
|
+
mutateModuleJsonVariants(json, toInject) { _, _ -> false }
|
|
194
|
+
|
|
195
|
+
val deps = dependenciesOf(json)
|
|
196
|
+
assertEquals(1, deps.size)
|
|
197
|
+
assertEquals("androidx.core", deps.first()["group"])
|
|
198
|
+
assertEquals("core-ktx", deps.first()["module"])
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
@Test
|
|
202
|
+
fun `a superseded stale entry is replaced by the mediated version, not left duplicated or missing`() {
|
|
203
|
+
val json =
|
|
204
|
+
jsonWithVariant(
|
|
205
|
+
mutableListOf(
|
|
206
|
+
mutableMapOf("group" to "androidx.appcompat", "module" to "appcompat"),
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
val mediated = VersionMediatingDependencySet()
|
|
210
|
+
mediated.add(DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false))
|
|
211
|
+
|
|
212
|
+
mutateModuleJsonVariants(json, mediated) { group, module ->
|
|
213
|
+
group == "androidx.appcompat" && module == "appcompat"
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
val deps = dependenciesOf(json)
|
|
217
|
+
assertEquals(1, deps.size, "expected exactly one entry, not zero (deleted) or two (duplicated)")
|
|
218
|
+
@Suppress("UNCHECKED_CAST")
|
|
219
|
+
val version = (deps.first()["version"] as? Map<String, Any>)?.get("requires")
|
|
220
|
+
assertEquals("1.7.1", version)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
package com.callstack.react.brownfield.shared
|
|
2
|
+
|
|
3
|
+
import org.junit.jupiter.api.Test
|
|
4
|
+
import kotlin.test.assertEquals
|
|
5
|
+
import kotlin.test.assertFalse
|
|
6
|
+
import kotlin.test.assertTrue
|
|
7
|
+
|
|
8
|
+
class TransitiveDependencyFilteringTest {
|
|
9
|
+
@Test
|
|
10
|
+
fun `drops a hard-excluded coordinate the RNC discoverer found, even though it has no Expo awareness`() {
|
|
11
|
+
// The RNC discoverer has no Expo blacklist, so an embedded module depending on an Expo
|
|
12
|
+
// coordinate would otherwise be published as a Maven dependency that resolves nowhere.
|
|
13
|
+
val transitiveDeps = VersionMediatingDependencySet()
|
|
14
|
+
transitiveDeps.add(DependencyInfo("androidx.core", "core-ktx", "1.17.0", "compile", false))
|
|
15
|
+
transitiveDeps.add(DependencyInfo("host.exp.exponent", "expo.modules.device", "56.0.4", "compile", false))
|
|
16
|
+
|
|
17
|
+
val hardExcludePredicate: (String, String) -> Boolean = { groupId, _ -> groupId == "host.exp.exponent" }
|
|
18
|
+
dropHardExcludedDependencies(transitiveDeps, hardExcludePredicate)
|
|
19
|
+
|
|
20
|
+
assertTrue(transitiveDeps.contains(DependencyInfo("androidx.core", "core-ktx", "1.17.0", "compile", false)))
|
|
21
|
+
assertFalse(transitiveDeps.any { it.groupId == "host.exp.exponent" })
|
|
22
|
+
assertEquals(1, transitiveDeps.size)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@Test
|
|
26
|
+
fun `does not drop a superseded coordinate that is meant to still be injected`() {
|
|
27
|
+
// hardExcludePredicate must never include the supersede check, or a mediated
|
|
28
|
+
// replacement for a consumer's stale hand-declared dependency would be dropped too.
|
|
29
|
+
val transitiveDeps = VersionMediatingDependencySet()
|
|
30
|
+
transitiveDeps.add(DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false))
|
|
31
|
+
|
|
32
|
+
dropHardExcludedDependencies(transitiveDeps) { _, _ -> false }
|
|
33
|
+
|
|
34
|
+
assertTrue(transitiveDeps.contains(DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false)))
|
|
35
|
+
assertEquals(1, transitiveDeps.size)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
package com.callstack.react.brownfield.shared
|
|
2
|
+
|
|
3
|
+
import org.junit.jupiter.api.Test
|
|
4
|
+
import kotlin.test.assertEquals
|
|
5
|
+
import kotlin.test.assertTrue
|
|
6
|
+
|
|
7
|
+
class VersionMediatingDependencySetTest {
|
|
8
|
+
@Test
|
|
9
|
+
fun `keeps the higher version when the same coordinate is added twice`() {
|
|
10
|
+
val set = VersionMediatingDependencySet()
|
|
11
|
+
|
|
12
|
+
set.add(DependencyInfo("androidx.appcompat", "appcompat", "1.6.0", "compile", false))
|
|
13
|
+
set.add(DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false))
|
|
14
|
+
|
|
15
|
+
assertEquals(1, set.size)
|
|
16
|
+
assertTrue(set.contains(DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false)))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@Test
|
|
20
|
+
fun `does not downgrade when a lower version is added second`() {
|
|
21
|
+
val set = VersionMediatingDependencySet()
|
|
22
|
+
|
|
23
|
+
set.add(DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false))
|
|
24
|
+
set.add(DependencyInfo("androidx.appcompat", "appcompat", "1.6.0", "compile", false))
|
|
25
|
+
|
|
26
|
+
val kept = set.first { it.groupId == "androidx.appcompat" }
|
|
27
|
+
assertEquals("1.7.1", kept.version)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@Test
|
|
31
|
+
fun `removeAll removes matching entries and returns them`() {
|
|
32
|
+
val set = VersionMediatingDependencySet()
|
|
33
|
+
set.add(DependencyInfo("host.exp.exponent", "expo", "1.0.0", "compile", false))
|
|
34
|
+
set.add(DependencyInfo("androidx.core", "core-ktx", "1.17.0", "compile", false))
|
|
35
|
+
|
|
36
|
+
val removed = set.removeAll { it.groupId == "host.exp.exponent" }
|
|
37
|
+
|
|
38
|
+
assertEquals(1, removed.size)
|
|
39
|
+
assertEquals(1, set.size)
|
|
40
|
+
assertTrue(set.contains(DependencyInfo("androidx.core", "core-ktx", "1.17.0", "compile", false)))
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
package com.callstack.react.brownfield.utils
|
|
2
|
+
|
|
3
|
+
import org.junit.jupiter.api.Test
|
|
4
|
+
import kotlin.test.assertTrue
|
|
5
|
+
|
|
6
|
+
class ExtensionTest {
|
|
7
|
+
@Test
|
|
8
|
+
fun `experimentalIncludeTransitiveDependencies defaults to true`() {
|
|
9
|
+
assertTrue(Extension().experimentalIncludeTransitiveDependencies)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Identifies one live React Native surface. Vended by `BrownfieldSurfaceRegistry`.
|
|
4
|
+
struct BrownfieldSurfaceID: Hashable, CustomStringConvertible {
|
|
5
|
+
fileprivate let rawValue: UInt64
|
|
6
|
+
|
|
7
|
+
var description: String { "surface#\(rawValue)" }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/// Decides which React Native surface may act on a `popToNative()` request.
|
|
11
|
+
///
|
|
12
|
+
/// `popToNative()` is delivered as an app-wide notification, so every live React
|
|
13
|
+
/// Native surface receives it — including ones buried under other screens. Acting
|
|
14
|
+
/// on all of them tears down the whole navigation stack
|
|
15
|
+
/// (https://github.com/callstack/react-native-brownfield/issues/354).
|
|
16
|
+
///
|
|
17
|
+
/// The registry resolves that to a single surface: whichever on-screen surface
|
|
18
|
+
/// was shown most recently.
|
|
19
|
+
///
|
|
20
|
+
/// Visibility is asked for at query time instead of being cached from lifecycle
|
|
21
|
+
/// callbacks. Hosts recreate view controllers — SwiftUI does it freely — and a
|
|
22
|
+
/// cached flag is stale exactly when the notification arrives.
|
|
23
|
+
///
|
|
24
|
+
/// Deliberately free of UIKit so it can be unit tested on macOS via `swift test`.
|
|
25
|
+
/// Internal, like the sibling `BrownfieldBundleSupport` target: it is an
|
|
26
|
+
/// implementation detail of the pod, not API for host apps.
|
|
27
|
+
final class BrownfieldSurfaceRegistry {
|
|
28
|
+
static let shared = BrownfieldSurfaceRegistry()
|
|
29
|
+
|
|
30
|
+
private struct Surface {
|
|
31
|
+
let isOnScreen: () -> Bool
|
|
32
|
+
var shownAt: UInt64
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private let lock = NSLock()
|
|
36
|
+
private var nextSurfaceValue: UInt64 = 0
|
|
37
|
+
private var nextShownValue: UInt64 = 0
|
|
38
|
+
private var surfaces: [BrownfieldSurfaceID: Surface] = [:]
|
|
39
|
+
|
|
40
|
+
init() {}
|
|
41
|
+
|
|
42
|
+
/// - Parameter isOnScreen: queried on every `popTarget()`. Capture the host
|
|
43
|
+
/// weakly so a released surface stops winning the election.
|
|
44
|
+
func register(isOnScreen: @escaping () -> Bool) -> BrownfieldSurfaceID {
|
|
45
|
+
lock.lock()
|
|
46
|
+
defer { lock.unlock() }
|
|
47
|
+
|
|
48
|
+
nextSurfaceValue += 1
|
|
49
|
+
let id = BrownfieldSurfaceID(rawValue: nextSurfaceValue)
|
|
50
|
+
surfaces[id] = Surface(isOnScreen: isOnScreen, shownAt: 0)
|
|
51
|
+
return id
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func unregister(_ surface: BrownfieldSurfaceID) {
|
|
55
|
+
lock.lock()
|
|
56
|
+
defer { lock.unlock() }
|
|
57
|
+
|
|
58
|
+
surfaces.removeValue(forKey: surface)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Records that a surface was just shown, ordering it ahead of other on-screen
|
|
62
|
+
/// surfaces. Only a tiebreaker — a surface never marked shown can still win
|
|
63
|
+
/// when it is the one on screen.
|
|
64
|
+
func markShown(_ surface: BrownfieldSurfaceID) {
|
|
65
|
+
lock.lock()
|
|
66
|
+
defer { lock.unlock() }
|
|
67
|
+
|
|
68
|
+
guard var entry = surfaces[surface] else { return }
|
|
69
|
+
|
|
70
|
+
nextShownValue += 1
|
|
71
|
+
entry.shownAt = nextShownValue
|
|
72
|
+
surfaces[surface] = entry
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// The single surface allowed to handle `popToNative()`, or `nil` when no
|
|
76
|
+
/// React Native surface is on screen.
|
|
77
|
+
func popTarget() -> BrownfieldSurfaceID? {
|
|
78
|
+
lock.lock()
|
|
79
|
+
let candidates = surfaces
|
|
80
|
+
lock.unlock()
|
|
81
|
+
|
|
82
|
+
return candidates
|
|
83
|
+
.filter { $0.value.isOnScreen() }
|
|
84
|
+
.max { lhs, rhs in
|
|
85
|
+
(lhs.value.shownAt, lhs.key.rawValue) < (rhs.value.shownAt, rhs.key.rawValue)
|
|
86
|
+
}?
|
|
87
|
+
.key
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
func isPopTarget(_ surface: BrownfieldSurfaceID) -> Bool {
|
|
91
|
+
popTarget() == surface
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -1,20 +1,38 @@
|
|
|
1
1
|
import SwiftUI
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
Links a SwiftUI `ReactNativeView` to the view controller it hosts, so the
|
|
5
|
+
SwiftUI layer can ask whether this surface may act on `popToNative()`.
|
|
6
|
+
*/
|
|
7
|
+
final class BrownfieldSurfaceHost {
|
|
8
|
+
weak var controller: ReactNativeViewController?
|
|
9
|
+
|
|
10
|
+
var isPopTarget: Bool { controller?.isPopTarget ?? false }
|
|
11
|
+
}
|
|
12
|
+
|
|
3
13
|
/**
|
|
4
14
|
A UIViewControllerRepresentable that bridges ReactNativeViewController to SwiftUI.
|
|
5
15
|
*/
|
|
6
16
|
struct ReactNativeViewRepresentable: UIViewControllerRepresentable {
|
|
7
17
|
var moduleName: String
|
|
8
18
|
var initialProperties: [String: Any] = [:]
|
|
19
|
+
var host: BrownfieldSurfaceHost
|
|
9
20
|
|
|
10
21
|
func makeUIViewController(context: Context) -> UIViewController {
|
|
11
|
-
|
|
22
|
+
let controller = ReactNativeViewController(
|
|
12
23
|
moduleName: moduleName,
|
|
13
24
|
initialProperties: initialProperties
|
|
14
25
|
)
|
|
26
|
+
controller.isHostedBySwiftUI = true
|
|
27
|
+
host.controller = controller
|
|
28
|
+
return controller
|
|
15
29
|
}
|
|
16
30
|
|
|
17
|
-
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
|
|
31
|
+
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
|
|
32
|
+
if let controller = uiViewController as? ReactNativeViewController {
|
|
33
|
+
host.controller = controller
|
|
34
|
+
}
|
|
35
|
+
}
|
|
18
36
|
}
|
|
19
37
|
|
|
20
38
|
/**
|
|
@@ -24,6 +42,7 @@ struct ReactNativeViewRepresentable: UIViewControllerRepresentable {
|
|
|
24
42
|
@available(iOS 15.0, *)
|
|
25
43
|
public struct ReactNativeView: View {
|
|
26
44
|
@Environment(\.dismiss) var dismiss
|
|
45
|
+
@State private var host = BrownfieldSurfaceHost()
|
|
27
46
|
var moduleName: String
|
|
28
47
|
var initialProperties: [String: Any] = [:]
|
|
29
48
|
|
|
@@ -35,11 +54,21 @@ public struct ReactNativeView: View {
|
|
|
35
54
|
public var body: some View {
|
|
36
55
|
ReactNativeViewRepresentable(
|
|
37
56
|
moduleName: moduleName,
|
|
38
|
-
initialProperties: initialProperties
|
|
57
|
+
initialProperties: initialProperties,
|
|
58
|
+
host: host
|
|
39
59
|
)
|
|
40
60
|
.ignoresSafeArea(.all)
|
|
41
|
-
|
|
42
|
-
|
|
61
|
+
// `.popToNative` is public API and may be posted from any thread, while
|
|
62
|
+
// `host.isPopTarget` reads UIKit state — so deliver on main.
|
|
63
|
+
.onReceive(
|
|
64
|
+
NotificationCenter.default
|
|
65
|
+
.publisher(for: NSNotification.Name.popToNative)
|
|
66
|
+
.receive(on: DispatchQueue.main)
|
|
67
|
+
) { _ in
|
|
68
|
+
// Every live surface receives this app-wide notification; only the
|
|
69
|
+
// topmost on-screen one may dismiss.
|
|
70
|
+
// https://github.com/callstack/react-native-brownfield/issues/354
|
|
71
|
+
guard host.isPopTarget else { return }
|
|
43
72
|
dismiss()
|
|
44
73
|
}
|
|
45
74
|
}
|
|
@@ -7,6 +7,25 @@ internal import EXUpdates
|
|
|
7
7
|
@objc public class ReactNativeViewController: UIViewController {
|
|
8
8
|
private var moduleName: String
|
|
9
9
|
private var initialProperties: [String: Any]?
|
|
10
|
+
/// Identifies this surface in `BrownfieldSurfaceRegistry`, which decides
|
|
11
|
+
/// which single surface may act on `popToNative()`.
|
|
12
|
+
private var surfaceID: BrownfieldSurfaceID?
|
|
13
|
+
|
|
14
|
+
/// Set by `ReactNativeView`. SwiftUI owns its navigation state, so an
|
|
15
|
+
/// imperative `popViewController` is reverted; the SwiftUI host dismisses
|
|
16
|
+
/// instead and this controller stands down.
|
|
17
|
+
internal var isHostedBySwiftUI = false
|
|
18
|
+
|
|
19
|
+
/// True from the start of a disappearance transition until the surface next
|
|
20
|
+
/// appears. `view.window` stays non-nil for the whole animation, so this is
|
|
21
|
+
/// what distinguishes "the host already dismissed us" from "nothing happened".
|
|
22
|
+
private var isDisappearing = false
|
|
23
|
+
|
|
24
|
+
/// Whether this surface is the one allowed to act on `popToNative()`.
|
|
25
|
+
internal var isPopTarget: Bool {
|
|
26
|
+
guard let surfaceID else { return false }
|
|
27
|
+
return BrownfieldSurfaceRegistry.shared.isPopTarget(surfaceID)
|
|
28
|
+
}
|
|
10
29
|
|
|
11
30
|
#if canImport(EXUpdates)
|
|
12
31
|
private let expoUpdatesDelegate = ReactNativeExpoUpdatesDelegate()
|
|
@@ -39,6 +58,12 @@ internal import EXUpdates
|
|
|
39
58
|
#if !canImport(EXUpdates)
|
|
40
59
|
renderReactNativeView()
|
|
41
60
|
#endif
|
|
61
|
+
|
|
62
|
+
// Visibility is resolved live: SwiftUI recreates hosted controllers, so a
|
|
63
|
+
// cached lifecycle flag is stale when the notification arrives.
|
|
64
|
+
surfaceID = BrownfieldSurfaceRegistry.shared.register { [weak self] in
|
|
65
|
+
self?.viewIfLoaded?.window != nil
|
|
66
|
+
}
|
|
42
67
|
|
|
43
68
|
NotificationCenter.default.addObserver(
|
|
44
69
|
self,
|
|
@@ -56,8 +81,31 @@ internal import EXUpdates
|
|
|
56
81
|
}
|
|
57
82
|
}
|
|
58
83
|
|
|
84
|
+
public override func viewWillAppear(_ animated: Bool) {
|
|
85
|
+
super.viewWillAppear(animated)
|
|
86
|
+
|
|
87
|
+
isDisappearing = false
|
|
88
|
+
|
|
89
|
+
// Ordered here rather than in `viewDidAppear`: during a modal presentation
|
|
90
|
+
// the covered surface is still in a window, so waiting for `viewDidAppear`
|
|
91
|
+
// would let it out-rank the surface being presented over it.
|
|
92
|
+
if let surfaceID {
|
|
93
|
+
BrownfieldSurfaceRegistry.shared.markShown(surfaceID)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
public override func viewWillDisappear(_ animated: Bool) {
|
|
98
|
+
super.viewWillDisappear(animated)
|
|
99
|
+
|
|
100
|
+
isDisappearing = true
|
|
101
|
+
}
|
|
102
|
+
|
|
59
103
|
deinit {
|
|
60
104
|
NotificationCenter.default.removeObserver(self)
|
|
105
|
+
|
|
106
|
+
if let surfaceID {
|
|
107
|
+
BrownfieldSurfaceRegistry.shared.unregister(surfaceID)
|
|
108
|
+
}
|
|
61
109
|
}
|
|
62
110
|
|
|
63
111
|
@objc private func togglePopGestureRecognizer(_ notification: Notification) {
|
|
@@ -73,11 +121,82 @@ internal import EXUpdates
|
|
|
73
121
|
guard let userInfo = notification.userInfo,
|
|
74
122
|
let animated = userInfo["animated"] as? Bool else { return }
|
|
75
123
|
|
|
124
|
+
// `.popToNative` is public API, so a host may post it from any thread.
|
|
125
|
+
// Everything below reads UIKit state — including the registry's visibility
|
|
126
|
+
// check — so hop to main first.
|
|
76
127
|
DispatchQueue.main.async { [weak self] in
|
|
77
|
-
self
|
|
128
|
+
guard let self else { return }
|
|
129
|
+
|
|
130
|
+
// The notification is app-wide, so every live surface receives it. Only
|
|
131
|
+
// the topmost visible one may act, otherwise surfaces buried under other
|
|
132
|
+
// screens tear down the whole navigation stack.
|
|
133
|
+
// https://github.com/callstack/react-native-brownfield/issues/354
|
|
134
|
+
let target = BrownfieldSurfaceRegistry.shared.popTarget()
|
|
135
|
+
|
|
136
|
+
guard let surfaceID = self.surfaceID, target == surfaceID else {
|
|
137
|
+
if target == nil {
|
|
138
|
+
NSLog("[ReactBrownfield] popToNative(): no React Native surface is on screen; ignoring.")
|
|
139
|
+
}
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
guard self.isHostedBySwiftUI else {
|
|
144
|
+
self.popFromNativeNavigationStack(animated: animated)
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// SwiftUI owns its navigation state and reverts an imperative pop, so the
|
|
149
|
+
// SwiftUI host dismisses instead. It cannot always succeed though — a
|
|
150
|
+
// `ReactNativeView` inside a `UIHostingController` pushed onto a UIKit
|
|
151
|
+
// navigation stack has nothing for `dismiss()` to act on. Give the host a
|
|
152
|
+
// moment, then fall back, so `popToNative()` is never silently inert.
|
|
153
|
+
//
|
|
154
|
+
// `isDisappearing` — not the delay — is what makes this safe: a dismiss
|
|
155
|
+
// that is merely slow has already begun its transition by the time this
|
|
156
|
+
// runs, so a late timer stands down instead of popping a second entry.
|
|
157
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
158
|
+
guard let self,
|
|
159
|
+
!self.isDisappearing,
|
|
160
|
+
self.viewIfLoaded?.window != nil,
|
|
161
|
+
// Re-established: the entitlement was resolved before the wait.
|
|
162
|
+
self.isPopTarget else { return }
|
|
163
|
+
|
|
164
|
+
self.popFromNativeNavigationStack(animated: animated)
|
|
165
|
+
}
|
|
78
166
|
}
|
|
79
167
|
}
|
|
80
168
|
|
|
169
|
+
/// Pops this surface off the host's `UINavigationController`.
|
|
170
|
+
///
|
|
171
|
+
/// Deliberately does not fall back to `dismiss()` when there is no navigation
|
|
172
|
+
/// stack: `presentingViewController` is inherited, so an inline surface hosted
|
|
173
|
+
/// inside a presented native screen would tear down that whole screen — the
|
|
174
|
+
/// same over-dismissal this fix exists to prevent.
|
|
175
|
+
private func popFromNativeNavigationStack(animated: Bool) {
|
|
176
|
+
guard let navigationController else { return }
|
|
177
|
+
|
|
178
|
+
// This controller is usually a child of a host controller (SwiftUI wraps it
|
|
179
|
+
// in one), so find the stack entry containing it rather than assuming it is
|
|
180
|
+
// itself on the stack — and pop to the entry below that, so a screen pushed
|
|
181
|
+
// while we were waiting is not the one that gets popped.
|
|
182
|
+
let stack = navigationController.viewControllers
|
|
183
|
+
guard let index = stack.firstIndex(where: { $0 === self || self.isDescendant(of: $0) }),
|
|
184
|
+
index > 0 else { return }
|
|
185
|
+
|
|
186
|
+
navigationController.popToViewController(stack[index - 1], animated: animated)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private func isDescendant(of controller: UIViewController) -> Bool {
|
|
190
|
+
var ancestor = parent
|
|
191
|
+
|
|
192
|
+
while let current = ancestor {
|
|
193
|
+
if current === controller { return true }
|
|
194
|
+
ancestor = current.parent
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return false
|
|
198
|
+
}
|
|
199
|
+
|
|
81
200
|
private func renderReactNativeView() {
|
|
82
201
|
guard !moduleName.isEmpty else { return }
|
|
83
202
|
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
Object.defineProperty(exports,"__esModule",{value:true});exports.brownfieldGradlePluginDependency=exports.BROWNFIELD_PLUGIN_VERSION=void 0;var BROWNFIELD_PLUGIN_VERSION=exports.BROWNFIELD_PLUGIN_VERSION='2.0.0-
|
|
1
|
+
Object.defineProperty(exports,"__esModule",{value:true});exports.brownfieldGradlePluginDependency=exports.BROWNFIELD_PLUGIN_VERSION=void 0;var BROWNFIELD_PLUGIN_VERSION=exports.BROWNFIELD_PLUGIN_VERSION='2.0.0-alpha10';var brownfieldGradlePluginDependency=exports.brownfieldGradlePluginDependency=`classpath("com.callstack.react:brownfield-gradle-plugin:${BROWNFIELD_PLUGIN_VERSION}")`;
|
|
2
2
|
//# sourceMappingURL=constants.js.map
|