@tileflow/react-native 0.0.0-bootstrap.0 → 0.1.0-alpha.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.
Files changed (86) hide show
  1. package/README.md +216 -4
  2. package/TileflowNativeAdmission.podspec +34 -0
  3. package/android/build.gradle +43 -0
  4. package/android/src/main/AndroidManifest.xml +3 -0
  5. package/android/src/main/java/dev/tileflow/reactnative/AdmissionBootstrap.kt +144 -0
  6. package/android/src/main/java/dev/tileflow/reactnative/AdmissionBootstrapOkHttpNetwork.kt +89 -0
  7. package/android/src/main/java/dev/tileflow/reactnative/AdmissionCatalog.kt +137 -0
  8. package/android/src/main/java/dev/tileflow/reactnative/AdmissionDocumentScope.kt +22 -0
  9. package/android/src/main/java/dev/tileflow/reactnative/AdmissionEngine.kt +335 -0
  10. package/android/src/main/java/dev/tileflow/reactnative/AdmissionModuleProvider.kt +68 -0
  11. package/android/src/main/java/dev/tileflow/reactnative/AdmissionOkHttpNetwork.kt +86 -0
  12. package/android/src/main/java/dev/tileflow/reactnative/AdmissionProviderLease.kt +56 -0
  13. package/android/src/main/java/dev/tileflow/reactnative/AdmissionTypes.kt +85 -0
  14. package/android/src/main/java/dev/tileflow/reactnative/AdmissionUrl.kt +100 -0
  15. package/android/src/main/java/dev/tileflow/reactnative/HandlerAdmissionScheduler.kt +16 -0
  16. package/android/src/main/java/dev/tileflow/reactnative/MobileConfiguration.kt +95 -0
  17. package/android/src/main/java/dev/tileflow/reactnative/MobileConfigurationResources.kt +43 -0
  18. package/android/src/main/java/dev/tileflow/reactnative/NativeDocumentOkHttpNetwork.kt +101 -0
  19. package/android/src/main/java/dev/tileflow/reactnative/NativeDocumentRegistry.kt +136 -0
  20. package/android/src/main/java/dev/tileflow/reactnative/NativeSurfaceState.kt +115 -0
  21. package/android/src/main/java/dev/tileflow/reactnative/TileflowNativeAdmissionModule.kt +234 -0
  22. package/android/src/main/java/dev/tileflow/reactnative/TileflowNativeAdmissionPackage.kt +16 -0
  23. package/android/src/main/java/dev/tileflow/reactnative/TileflowNativeConfigurationModule.kt +43 -0
  24. package/android/src/main/java/dev/tileflow/reactnative/TileflowNativeDocumentsModule.kt +80 -0
  25. package/android/src/main/java/dev/tileflow/reactnative/TileflowNativeSurfaceModule.kt +277 -0
  26. package/android/src/main/res/raw/tileflow_configuration_keep.xml +3 -0
  27. package/dist/contract-DWs-lDPo.d.ts +144 -0
  28. package/dist/index.d.ts +13 -0
  29. package/dist/index.js +6989 -0
  30. package/dist/internal/camera.d.ts +65 -0
  31. package/dist/internal/camera.js +325 -0
  32. package/dist/internal/hosted-binding.d.ts +18 -0
  33. package/dist/internal/hosted-binding.js +277 -0
  34. package/dist/internal/initial-view.d.ts +11 -0
  35. package/dist/internal/initial-view.js +8 -0
  36. package/dist/internal/interactions.d.ts +76 -0
  37. package/dist/internal/interactions.js +794 -0
  38. package/dist/internal/native-admission-bridge.d.ts +29 -0
  39. package/dist/internal/native-admission-bridge.js +351 -0
  40. package/dist/internal/native-admission.d.ts +78 -0
  41. package/dist/internal/native-admission.js +1614 -0
  42. package/dist/internal/native-appearance.d.ts +21 -0
  43. package/dist/internal/native-appearance.js +110 -0
  44. package/dist/internal/native-configuration-bridge.d.ts +9 -0
  45. package/dist/internal/native-configuration-bridge.js +343 -0
  46. package/dist/internal/session.d.ts +125 -0
  47. package/dist/internal/session.js +887 -0
  48. package/dist/internal/source-state.d.ts +11 -0
  49. package/dist/internal/source-state.js +26 -0
  50. package/dist/native-admission-contract-k7LKE1vW.d.ts +89 -0
  51. package/docs/native-admission.md +108 -0
  52. package/docs/native-configuration.md +210 -0
  53. package/docs/native-interactions.md +161 -0
  54. package/docs/native-location.md +148 -0
  55. package/ios/TFAdmissionBootstrap.h +17 -0
  56. package/ios/TFAdmissionBootstrap.mm +168 -0
  57. package/ios/TFAdmissionCatalog.h +13 -0
  58. package/ios/TFAdmissionCatalog.mm +193 -0
  59. package/ios/TFAdmissionDocumentScope.h +7 -0
  60. package/ios/TFAdmissionDocumentScope.mm +45 -0
  61. package/ios/TFAdmissionEngine.h +57 -0
  62. package/ios/TFAdmissionEngine.mm +371 -0
  63. package/ios/TFAdmissionInstallation.h +27 -0
  64. package/ios/TFAdmissionInstallation.mm +117 -0
  65. package/ios/TFAdmissionNetwork.h +16 -0
  66. package/ios/TFAdmissionNetwork.mm +268 -0
  67. package/ios/TFAdmissionURL.mm +116 -0
  68. package/ios/TFDocumentBounds.h +9 -0
  69. package/ios/TFDocumentBounds.mm +19 -0
  70. package/ios/TFMobileConfiguration.h +23 -0
  71. package/ios/TFMobileConfiguration.mm +139 -0
  72. package/ios/TFNativeAdmissionURLProtocol.mm +69 -0
  73. package/ios/TFNativeDocumentRegistry.h +28 -0
  74. package/ios/TFNativeDocumentRegistry.mm +193 -0
  75. package/ios/TFNativeDocumentURL.h +6 -0
  76. package/ios/TFNativeSurfaceState.h +28 -0
  77. package/ios/TFNativeSurfaceState.mm +128 -0
  78. package/ios/TileflowNativeAdmission.h +9 -0
  79. package/ios/TileflowNativeAdmission.mm +184 -0
  80. package/ios/TileflowNativeConfiguration.mm +41 -0
  81. package/ios/TileflowNativeDocuments.h +12 -0
  82. package/ios/TileflowNativeDocuments.mm +105 -0
  83. package/ios/TileflowNativeSurface.mm +403 -0
  84. package/ios/tileflow_native_admission.rb +45 -0
  85. package/package.json +51 -7
  86. package/react-native.config.cjs +13 -0
@@ -0,0 +1,137 @@
1
+ package dev.tileflow.reactnative
2
+
3
+ import java.io.ByteArrayOutputStream
4
+ import java.nio.ByteBuffer
5
+ import java.nio.charset.CodingErrorAction
6
+
7
+ /** A finite set of exact documents and narrowly typed URL expansions. */
8
+ internal class AdmissionCatalog(resources: List<AdmissionResource>) {
9
+ private class Rule(val resource: AdmissionResource, val pattern: Regex?, val names: List<String>)
10
+ private val rules: List<Rule>
11
+
12
+ init {
13
+ if (resources.size > AdmissionLimits.RESOURCES) invalid()
14
+ val seen = mutableSetOf<String>()
15
+ rules = resources.map {
16
+ val resource = validate(it)
17
+ if (!seen.add(resource.url)) invalid()
18
+ if (resource.template == null) Rule(resource, null, emptyList())
19
+ else {
20
+ val names = mutableListOf<String>()
21
+ var cursor = 0
22
+ val expression = StringBuilder("^")
23
+ for (match in placeholder.findAll(resource.url)) {
24
+ val name = match.groupValues[1]
25
+ if (names.size >= 16 || (names.isNotEmpty() && match.range.first == cursor)) invalid()
26
+ expression.append(Regex.escape(resource.url.substring(cursor, match.range.first)))
27
+ expression.append('(').append(slots[name] ?: invalid()).append(')')
28
+ names.add(name)
29
+ cursor = match.range.last + 1
30
+ }
31
+ if (names.isEmpty()) invalid()
32
+ expression.append(Regex.escape(resource.url.substring(cursor))).append('$')
33
+ Rule(resource, Regex(expression.toString()), names.toList())
34
+ }
35
+ }
36
+ }
37
+
38
+ fun find(url: String): AdmissionResource? {
39
+ try { AdmissionUrl.clean(url) } catch (_: Exception) { return null }
40
+ var found: AdmissionResource? = null
41
+ for (rule in rules) {
42
+ if (!matches(rule, url)) continue
43
+ if (found != null) invalid()
44
+ found = AdmissionResource(url, rule.resource.scope, rule.resource.tilesetId)
45
+ }
46
+ return found
47
+ }
48
+
49
+ private fun matches(rule: Rule, url: String): Boolean {
50
+ val pattern = rule.pattern ?: return rule.resource.url == url
51
+ val match = pattern.matchEntire(url) ?: return false
52
+ val values = mutableMapOf<String, String>()
53
+ for ((index, name) in rule.names.withIndex()) {
54
+ val value = match.groupValues[index + 1]
55
+ if (values.containsKey(name) && values[name] != value) return false
56
+ values[name] = value
57
+ }
58
+ val zoom = values["z"]?.toIntOrNull()
59
+ if (zoom != null && zoom > 30) return false
60
+ for (name in listOf("x", "y")) {
61
+ val coordinate = values[name]?.toLongOrNull() ?: continue
62
+ if (coordinate >= (1L shl (zoom ?: 30))) return false
63
+ }
64
+ values["quadkey"]?.let { if (zoom != null && it.length != zoom) return false }
65
+ values["bbox-epsg-3857"]?.let { box ->
66
+ val numbers = box.split(',').map { it.toDoubleOrNull() ?: return false }
67
+ if (numbers.size != 4 || numbers.any { !it.isFinite() || kotlin.math.abs(it) > 20037508.343 } || numbers[0] >= numbers[2] || numbers[1] >= numbers[3]) return false
68
+ }
69
+ values["range"]?.let { range ->
70
+ val pair = range.split('-').map { it.toLongOrNull() ?: return false }
71
+ if (pair.size != 2 || pair[0] % 256 != 0L || pair[1] != pair[0] + 255 || pair[1] > 1114111) return false
72
+ }
73
+ values["fontstack"]?.let { stack ->
74
+ val decoded = try { decode(stack) } catch (_: Exception) { return false }
75
+ if (rule.resource.fontStacks?.contains(decoded) != true) return false
76
+ }
77
+ return true
78
+ }
79
+
80
+ companion object {
81
+ private val placeholder = Regex("\\{([a-z0-9-]+)\\}")
82
+ private val decimal = "(?:0|[1-9][0-9]{0,9})"
83
+ private val coordinate = "-?(?:0|[1-9][0-9]{0,7})(?:\\.[0-9]{1,16})?"
84
+ private val slots = mapOf(
85
+ "z" to "(?:0|[1-9][0-9]?)", "x" to decimal, "y" to decimal,
86
+ "ratio" to "(?:@2x|@3x)?", "quadkey" to "[0-3]{0,30}", "prefix" to "[0-9a-f]{2}",
87
+ "bbox-epsg-3857" to "$coordinate,$coordinate,$coordinate,$coordinate",
88
+ "fontstack" to "(?:[A-Za-z0-9_.~,+-]|%[0-9a-fA-F]{2}){1,768}", "range" to "$decimal-$decimal"
89
+ )
90
+ private val scopes = setOf("style", "tilejson", "tile", "sprite", "glyph", "font")
91
+ private val compact = Regex("[A-Za-z0-9._:-]{1,255}")
92
+
93
+ fun validate(value: AdmissionResource): AdmissionResource {
94
+ try {
95
+ if (value.url.length > AdmissionLimits.URL - 128 || !scopes.contains(value.scope)) invalid()
96
+ if ((value.scope == "tile" || value.scope == "tilejson") && value.tilesetId == null) invalid()
97
+ value.tilesetId?.let { if (!compact.matches(it) || it.contains("tf_native_", true) || it.contains("tf_public_", true)) invalid() }
98
+ if (value.template == null) {
99
+ if (value.fontStacks != null || value.url.contains('{') || value.url.contains('}')) invalid()
100
+ AdmissionUrl.clean(value.url)
101
+ return AdmissionResource(value.url, value.scope, value.tilesetId)
102
+ }
103
+ if (value.template !in setOf("tile", "glyphs")) invalid()
104
+ val authority = value.url.substringBefore('/', "").takeIf { it.isNotEmpty() }
105
+ if (authority == null || !value.url.startsWith("https://")) invalid()
106
+ val path = value.url.indexOf('/', 8)
107
+ if (path < 0 || value.url.substring(0, path).any { it == '{' || it == '}' }) invalid()
108
+ val names = placeholder.findAll(value.url).map { it.groupValues[1] }.toList()
109
+ if (names.isEmpty() || names.size > 16 || names.any { !slots.containsKey(it) }) invalid()
110
+ val masked = placeholder.replace(value.url, "native-template-slot")
111
+ if (masked.contains('{') || masked.contains('}')) invalid()
112
+ AdmissionUrl.clean(masked)
113
+ if (value.template == "tile") {
114
+ if (value.scope != "tile" || value.fontStacks != null || names.any { it == "fontstack" || it == "range" }) invalid()
115
+ return AdmissionResource(value.url, value.scope, value.tilesetId, "tile")
116
+ }
117
+ val stacks = value.fontStacks ?: invalid()
118
+ if (value.scope != "glyph" || !names.containsAll(listOf("fontstack", "range")) || names.any { it != "fontstack" && it != "range" } || stacks.isEmpty() || stacks.size > 16 || stacks.toSet().size != stacks.size) invalid()
119
+ for (stack in stacks) {
120
+ if (stack.isEmpty() || stack.length > 256 || stack != stack.trim() || stack.any { it.code < 32 || it.code in 127..159 || it in "\\/?#%&=" } || stack.contains("tf_native_", true) || stack.contains("tf_public_", true) || stack.split(',').any { it.isEmpty() || it != it.trim() }) invalid()
121
+ }
122
+ return AdmissionResource(value.url, value.scope, value.tilesetId, "glyphs", stacks.toList())
123
+ } catch (_: Exception) { invalid() }
124
+ }
125
+
126
+ private fun decode(value: String): String {
127
+ val bytes = ByteArrayOutputStream()
128
+ var index = 0
129
+ while (index < value.length) {
130
+ if (value[index] == '%') { bytes.write(value.substring(index + 1, index + 3).toInt(16)); index += 3 }
131
+ else { bytes.write(value[index].code); index++ }
132
+ }
133
+ return Charsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(bytes.toByteArray())).toString()
134
+ }
135
+ private fun invalid(): Nothing = throw IllegalArgumentException("Invalid native resource catalog")
136
+ }
137
+ }
@@ -0,0 +1,22 @@
1
+ package dev.tileflow.reactnative
2
+
3
+ import java.util.concurrent.atomic.AtomicBoolean
4
+
5
+ internal fun createAdmissionDocumentScope(admission: AdmissionEngine, context: String, maximumBytes: Int): NativeDocumentScope {
6
+ if (maximumBytes !in 1..8388608) throw IllegalArgumentException("Native document acquisition failed.")
7
+ val active = admission.contextGuard(context)
8
+ return NativeDocumentScope(active, { url, completion ->
9
+ AdmissionUrl.clean(url)
10
+ val completed = AtomicBoolean(false)
11
+ val finish: (AdmissionHttpResponse?) -> Unit = { response ->
12
+ if (completed.compareAndSet(false, true)) completion(response)
13
+ }
14
+ val tagged = "$url${if (url.contains('?')) '&' else '?'}__tf_native_context=$context"
15
+ val request = admission.requestDocument(tagged, maximumBytes, { finish(it) }, { finish(null) })
16
+ AdmissionCancellation {
17
+ request.cancel()
18
+ // The admission transport independently retains its physical network slot.
19
+ finish(null)
20
+ }
21
+ }, context)
22
+ }
@@ -0,0 +1,335 @@
1
+ package dev.tileflow.reactnative
2
+
3
+ import java.util.concurrent.atomic.AtomicBoolean
4
+ import java.util.concurrent.atomic.AtomicInteger
5
+
6
+ // All mutable collections are confined to the injected serial scheduler.
7
+ // Atomic liveness and volatile deadlines are also read at OkHttp network start.
8
+ internal class AdmissionEngine(
9
+ private val installation: String,
10
+ private val scheduler: AdmissionScheduler,
11
+ private val network: AdmissionNetwork,
12
+ private val owns: () -> Boolean,
13
+ private val emit: (Map<String, Any>) -> Unit,
14
+ ) {
15
+ private class Context(val id: String, val mapId: String?, var resources: Map<String, AdmissionResource>) {
16
+ var catalog = AdmissionCatalog(resources.values.toList())
17
+ val live = AtomicBoolean(true)
18
+ val work = linkedMapOf<String, Work>()
19
+ var batch: Batch? = null
20
+ var sequence = 0L
21
+ }
22
+ private class Batch(val id: String, val tickets: List<String>) {
23
+ var timer: AdmissionCancellation? = null
24
+ }
25
+ private class Work(
26
+ val id: String,
27
+ val context: Context,
28
+ val url: String,
29
+ val headers: Map<String, String>,
30
+ val maximumBytes: Int,
31
+ val entered: Long,
32
+ val live: AtomicBoolean,
33
+ val response: (AdmissionHttpResponse) -> Unit,
34
+ val failure: () -> Unit,
35
+ val delegate: (String, (AdmissionHttpResponse?) -> Unit) -> AdmissionCancellation,
36
+ val release: () -> Unit,
37
+ ) {
38
+ var phase = "queue"
39
+ var cancellation: AdmissionCancellation? = null
40
+ var timer: AdmissionCancellation? = null
41
+ var authority: AdmissionAuthority? = null
42
+ @Volatile var deadline = 0L
43
+ var redirects = 0
44
+ }
45
+ private val contexts = linkedMapOf<String, Context>()
46
+ private val live = AtomicBoolean(true)
47
+ private val lossQueued = AtomicBoolean(false)
48
+ private val reservations = AtomicInteger(0)
49
+ @Volatile private var foreground = true
50
+ private var contextSequence = 0L
51
+ private var ticketSequence = 0L
52
+
53
+ fun register(mapId: String?, resources: List<AdmissionResource>): String {
54
+ if (!checkOwnership() || contexts.size >= AdmissionLimits.CONTEXTS || resources.size > AdmissionLimits.RESOURCES ||
55
+ (mapId != null && !Regex("map_[A-Za-z0-9_-]{16}").matches(mapId))) invalid()
56
+ val catalog = linkedMapOf<String, AdmissionResource>()
57
+ for (resource in resources) {
58
+ val snapshot = AdmissionCatalog.validate(resource)
59
+ if (catalog.put(snapshot.url, snapshot) != null) invalid()
60
+ }
61
+ if (contextSequence >= 9007199254740991L) invalid()
62
+ val id = "$installation.${++contextSequence}"
63
+ contexts[id] = Context(id, mapId, catalog)
64
+ return id
65
+ }
66
+
67
+ // Capture the original atomic context lifetime rather than consulting a mutable registry off-thread.
68
+ fun contextGuard(contextId: String): () -> Boolean {
69
+ val context = contexts[contextId] ?: invalid()
70
+ if (!context.live.get() || !checkOwnership()) invalid()
71
+ return { context.live.get() && foreground && checkOwnership() }
72
+ }
73
+
74
+ private fun extension(previous: AdmissionResource, next: AdmissionResource): AdmissionResource {
75
+ if (previous.scope != next.scope || previous.tilesetId != next.tilesetId || previous.template != next.template) invalid()
76
+ if (previous.fontStacks == next.fontStacks) return previous
77
+ if (previous.scope != "glyph" || previous.template != "glyphs") invalid()
78
+ val before = previous.fontStacks ?: invalid()
79
+ val after = next.fontStacks ?: invalid()
80
+ if (!after.containsAll(before) || after.size > 16) invalid()
81
+ return AdmissionCatalog.validate(
82
+ AdmissionResource(previous.url, previous.scope, previous.tilesetId, previous.template, after)
83
+ )
84
+ }
85
+
86
+ // Append-only within a context: rollback can still use its last accepted style.
87
+ // Identical retries are acknowledged; only a glyph template may widen its finite stack set.
88
+ fun extend(contextId: String, resources: List<AdmissionResource>): Int {
89
+ val context = contexts[contextId] ?: invalid()
90
+ if (!checkOwnership() || !context.live.get() || resources.size > AdmissionLimits.RESOURCES) invalid()
91
+ val next = LinkedHashMap(context.resources)
92
+ for (resource in resources) {
93
+ val snapshot = AdmissionCatalog.validate(resource)
94
+ val previous = next[snapshot.url]
95
+ next[snapshot.url] = if (previous == null) snapshot else extension(previous, snapshot)
96
+ }
97
+ val catalog = AdmissionCatalog(next.values.toList())
98
+ context.resources = next
99
+ context.catalog = catalog
100
+ return next.size
101
+ }
102
+
103
+ private fun resource(context: Context, url: String): AdmissionResource? =
104
+ try { context.catalog.find(url) } catch (_: Exception) { null }
105
+
106
+ fun request(
107
+ url: String,
108
+ headers: Map<String, String>,
109
+ onResponse: (AdmissionHttpResponse) -> Unit,
110
+ onFailure: () -> Unit,
111
+ onDelegate: (String, (AdmissionHttpResponse?) -> Unit) -> AdmissionCancellation,
112
+ ): AdmissionCancellation = requestBounded(url, headers, AdmissionLimits.RESPONSE_BYTES.toInt(), onResponse, onFailure, onDelegate)
113
+
114
+ fun requestDocument(
115
+ url: String,
116
+ maximumBytes: Int,
117
+ onResponse: (AdmissionHttpResponse) -> Unit,
118
+ onFailure: () -> Unit,
119
+ ): AdmissionCancellation = requestBounded(url, emptyMap(), maximumBytes, onResponse, onFailure, { _, completion ->
120
+ // Unprotected preparation uses the separate credential-free document channel.
121
+ completion(null); AdmissionCancellation {}
122
+ })
123
+
124
+ private fun requestBounded(
125
+ url: String,
126
+ headers: Map<String, String>,
127
+ maximumBytes: Int,
128
+ onResponse: (AdmissionHttpResponse) -> Unit,
129
+ onFailure: () -> Unit,
130
+ onDelegate: (String, (AdmissionHttpResponse?) -> Unit) -> AdmissionCancellation,
131
+ ): AdmissionCancellation {
132
+ if (maximumBytes !in 1..AdmissionLimits.RESPONSE_BYTES.toInt()) invalid()
133
+ // Bound ingress before posting to the main scheduler. Cancellation
134
+ // retains this transport slot until serialized cleanup has run.
135
+ if (reservations.incrementAndGet() > AdmissionLimits.CONTEXTS * AdmissionLimits.QUEUE) {
136
+ reservations.decrementAndGet(); onFailure(); return AdmissionCancellation {}
137
+ }
138
+ val reserved = AtomicBoolean(true)
139
+ val release: () -> Unit = { if (reserved.getAndSet(false)) reservations.decrementAndGet() }
140
+ val active = AtomicBoolean(true)
141
+ var work: Work? = null
142
+ scheduler.dispatch {
143
+ if (!active.get()) { release(); return@dispatch }
144
+ val tagged = try { AdmissionUrl.strip(url) } catch (_: Exception) {
145
+ active.set(false); release(); onFailure(); return@dispatch
146
+ }
147
+ val context = contexts[tagged.context]
148
+ if (!foreground || !checkOwnership() || context == null || !context.live.get() ||
149
+ resource(context, tagged.url) == null || context.work.size >= AdmissionLimits.QUEUE ||
150
+ headers.keys.any { it.equals(AdmissionLimits.GRANT_HEADER, true) } ||
151
+ ticketSequence >= 9007199254740991L) {
152
+ active.set(false); release(); onFailure(); return@dispatch
153
+ }
154
+ val item = Work((++ticketSequence).toString(), context, tagged.url, headers.toMap(), maximumBytes, scheduler.nowMs(), active, onResponse, onFailure, onDelegate, release)
155
+ work = item
156
+ context.work[item.id] = item
157
+ item.timer = scheduler.after(AdmissionLimits.WAIT_MS) {
158
+ if (context.batch?.tickets?.contains(item.id) == true) retire(context.id)
159
+ else fail(item)
160
+ }
161
+ scheduler.dispatch { drain(context) }
162
+ }
163
+ return AdmissionCancellation {
164
+ if (active.compareAndSet(true, false)) scheduler.dispatch {
165
+ work?.let { item ->
166
+ if (item.context.batch?.tickets?.contains(item.id) == true) {
167
+ event(item.context, "cancel", mapOf("tickets" to listOf(item.id)))
168
+ }
169
+ finish(item)
170
+ }
171
+ }
172
+ }
173
+ }
174
+
175
+ private fun drain(context: Context) {
176
+ if (!context.live.get() || !foreground || !checkOwnership() || context.batch != null) return
177
+ val selected = context.work.values.filter { it.live.get() && it.phase == "queue" }.take(AdmissionLimits.BATCH)
178
+ if (selected.isEmpty()) return
179
+ if (context.sequence >= 9007199254740991L) { retire(context.id); return }
180
+ val batch = Batch((++context.sequence).toString(), selected.map { it.id })
181
+ context.batch = batch
182
+ for (item in selected) item.phase = "javascript"
183
+ batch.timer = scheduler.after(AdmissionLimits.WAIT_MS) {
184
+ if (context.batch === batch) retire(context.id)
185
+ }
186
+ event(context, "batch", mapOf("batch" to batch.id, "tickets" to selected.map { mapOf("ticket" to it.id, "url" to it.url) }))
187
+ }
188
+
189
+ fun complete(contextId: String, generation: Long, batchId: String, results: List<AdmissionResult>): Int {
190
+ val context = contexts[contextId] ?: return 0
191
+ val batch = context.batch ?: return 0
192
+ if (!context.live.get() || generation != 1L || batch.id != batchId || !checkOwnership()) return 0
193
+ if (results.size > AdmissionLimits.BATCH || results.map { it.ticket } != batch.tickets) { retire(contextId); return 0 }
194
+ batch.timer?.cancel()
195
+ context.batch = null
196
+ var accepted = 0
197
+ for (result in results) {
198
+ val item = context.work[result.ticket] ?: continue
199
+ if (!item.live.get()) { finish(item); continue }
200
+ if (!foreground || scheduler.nowMs() - item.entered >= AdmissionLimits.WAIT_MS) { fail(item); continue }
201
+ when (result) {
202
+ is AdmissionResult.Reject -> fail(item)
203
+ is AdmissionResult.Delegate -> {
204
+ if (context.mapId != null) { fail(item); continue }
205
+ item.phase = "network"
206
+ item.deadline = item.entered + AdmissionLimits.WAIT_MS
207
+ if (!canStart(item)) { fail(item); continue }
208
+ try {
209
+ item.cancellation = item.delegate(item.url) { response -> scheduler.dispatch { received(item, item.url, response) } }
210
+ if (!item.live.get()) item.cancellation?.cancel()
211
+ accepted++
212
+ } catch (_: Exception) { fail(item) }
213
+ }
214
+ is AdmissionResult.Grant -> {
215
+ val selected = resource(context, item.url)
216
+ val authority = result.authority
217
+ if (context.mapId == null || selected == null || result.validForMs !in 1..AdmissionLimits.MAX_VALID_MS ||
218
+ authority.grant.length > AdmissionLimits.GRANT || !Regex("tf_native_v1\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+").matches(authority.grant) ||
219
+ !authority.allows(selected, context.mapId)) { fail(item); continue }
220
+ item.authority = authority
221
+ // Anchor at the earlier enqueue time, never at bridge receipt.
222
+ item.deadline = minOf(item.entered + AdmissionLimits.WAIT_MS, item.entered + result.validForMs - AdmissionLimits.SAFETY_MS)
223
+ if (start(item, item.url)) accepted++
224
+ }
225
+ }
226
+ }
227
+ scheduler.dispatch { drain(context) }
228
+ return accepted
229
+ }
230
+
231
+ private fun canStart(item: Work): Boolean {
232
+ if (!item.live.get() || !item.context.live.get() || !foreground || !live.get() || scheduler.nowMs() >= item.deadline) return false
233
+ if (!ownsSafely()) { loseOwnership(); return false }
234
+ return true
235
+ }
236
+
237
+ private fun start(item: Work, url: String): Boolean {
238
+ val authority = item.authority ?: return false
239
+ val selected = resource(item.context, url)
240
+ val mapId = item.context.mapId
241
+ if (selected == null || mapId == null || !authority.allows(selected, mapId) || !canStart(item)) { fail(item); return false }
242
+ item.phase = "network"
243
+ val headers = item.headers + (AdmissionLimits.GRANT_HEADER to authority.grant)
244
+ return try {
245
+ item.cancellation = network.start(AdmissionHttpRequest(url, headers, item.maximumBytes) { canStart(item) }) { response ->
246
+ scheduler.dispatch { received(item, url, response) }
247
+ }
248
+ if (!item.live.get()) item.cancellation?.cancel()
249
+ true
250
+ } catch (_: Exception) { fail(item); false }
251
+ }
252
+
253
+ private fun received(item: Work, url: String, response: AdmissionHttpResponse?) {
254
+ if (!item.live.get() || !item.context.live.get()) return
255
+ if (!checkOwnership()) return
256
+ if (response == null || response.body.size > item.maximumBytes || !foreground || scheduler.nowMs() >= item.deadline) { fail(item); return }
257
+ event(item.context, "response", mapOf("status" to response.code))
258
+ if (item.authority != null && response.code in listOf(301, 302, 303, 307, 308)) {
259
+ if (++item.redirects > AdmissionLimits.REDIRECTS) { fail(item); return }
260
+ val location = response.headers.entries.firstOrNull { it.key.equals("location", true) }?.value
261
+ val target = try { AdmissionUrl.redirect(url, location ?: "", item.context.id) } catch (_: Exception) { fail(item); return }
262
+ if (location.isNullOrEmpty()) { fail(item); return }
263
+ start(item, target)
264
+ return
265
+ }
266
+ if (!item.live.compareAndSet(true, false)) return
267
+ finish(item)
268
+ item.response(AdmissionHttpResponse(response.code, response.headers, response.body, url))
269
+ }
270
+
271
+ private fun finish(item: Work) {
272
+ item.live.set(false)
273
+ item.cancellation?.cancel()
274
+ item.cancellation = null
275
+ item.timer?.cancel()
276
+ item.timer = null
277
+ item.authority = null
278
+ item.context.work.remove(item.id)
279
+ item.release()
280
+ scheduler.dispatch { drain(item.context) }
281
+ }
282
+ private fun fail(item: Work) {
283
+ val notify = item.live.getAndSet(false)
284
+ finish(item)
285
+ if (notify) item.failure()
286
+ }
287
+
288
+ fun retire(contextId: String) {
289
+ val context = contexts.remove(contextId) ?: return
290
+ context.live.set(false)
291
+ context.batch?.timer?.cancel()
292
+ context.batch = null
293
+ for (item in context.work.values.toList()) fail(item)
294
+ event(context, "retired", mapOf("code" to "NATIVE_ADMISSION_CANCELLED"))
295
+ }
296
+
297
+ fun lifecycle(active: Boolean) {
298
+ foreground = active
299
+ if (!active) {
300
+ for (context in contexts.values.toList()) {
301
+ val tickets = context.batch?.tickets
302
+ if (!tickets.isNullOrEmpty()) event(context, "cancel", mapOf("tickets" to tickets))
303
+ for (item in context.work.values.toList()) fail(item)
304
+ }
305
+ }
306
+ safeEmit(mapOf("kind" to "lifecycle", "installation" to installation, "foreground" to active))
307
+ if (active) for (context in contexts.values) scheduler.dispatch { drain(context) }
308
+ }
309
+
310
+ fun close() {
311
+ live.set(false)
312
+ for (context in contexts.keys.toList()) retire(context)
313
+ }
314
+ fun checkOwnership(): Boolean {
315
+ if (!live.get()) return false
316
+ if (!ownsSafely()) { loseOwnership(); return false }
317
+ return true
318
+ }
319
+ private fun ownsSafely() = try { owns() } catch (_: Exception) { false }
320
+ private fun loseOwnership() {
321
+ live.set(false)
322
+ if (!lossQueued.compareAndSet(false, true)) return
323
+ scheduler.dispatch {
324
+ close()
325
+ safeEmit(mapOf("kind" to "ownershipLost", "installation" to installation))
326
+ }
327
+ }
328
+ private fun event(context: Context, kind: String, fields: Map<String, Any>) {
329
+ safeEmit(mapOf("kind" to kind, "installation" to installation, "context" to context.id, "generation" to 1) + fields)
330
+ }
331
+ private fun safeEmit(event: Map<String, Any>) {
332
+ try { emit(event) } catch (_: Exception) { close() }
333
+ }
334
+ private fun invalid(): Nothing = throw IllegalArgumentException("Invalid native admission")
335
+ }
@@ -0,0 +1,68 @@
1
+ package dev.tileflow.reactnative
2
+
3
+ import java.util.concurrent.atomic.AtomicBoolean
4
+ import org.maplibre.android.LibraryLoaderProvider
5
+ import org.maplibre.android.ModuleProvider
6
+ import org.maplibre.android.http.HttpRequest
7
+ import org.maplibre.android.http.HttpResponder
8
+
9
+ internal class AdmissionModuleProvider(
10
+ private val previous: ModuleProvider,
11
+ private val engine: AdmissionEngine,
12
+ ) : ModuleProvider {
13
+ override fun createLibraryLoaderProvider(): LibraryLoaderProvider = previous.createLibraryLoaderProvider()
14
+ override fun createHttpRequest(): HttpRequest = Request(previous.createHttpRequest(), engine)
15
+
16
+ private class Request(private val previous: HttpRequest, private val engine: AdmissionEngine) : HttpRequest {
17
+ private val cancelled = AtomicBoolean(false)
18
+ private val used = AtomicBoolean(false)
19
+ @Volatile private var delegated = false
20
+ @Volatile private var cancellation: AdmissionCancellation? = null
21
+
22
+ override fun executeRequest(responder: HttpResponder, nativePtr: Long, resourceUrl: String, dataRange: String, etag: String, modified: String, offlineUsage: Boolean) {
23
+ if (cancelled.get()) return
24
+ if (!used.compareAndSet(false, true)) {
25
+ responder.handleFailure(HttpRequest.PERMANENT_ERROR, "Native request was already started")
26
+ return
27
+ }
28
+ if (!AdmissionUrl.reserved(resourceUrl)) {
29
+ // Unowned traffic does not pass through our queue or header code.
30
+ delegated = true
31
+ previous.executeRequest(responder, nativePtr, resourceUrl, dataRange, etag, modified, offlineUsage)
32
+ if (cancelled.get()) previous.cancelRequest()
33
+ return
34
+ }
35
+ val headers = linkedMapOf<String, String>()
36
+ if (dataRange.isNotEmpty()) headers["Range"] = dataRange
37
+ if (etag.isNotEmpty()) headers["If-None-Match"] = etag
38
+ else if (modified.isNotEmpty()) headers["If-Modified-Since"] = modified
39
+ cancellation = engine.request(resourceUrl, headers,
40
+ { response ->
41
+ if (!cancelled.get()) responder.onResponse(response.code,
42
+ response.headers["etag"], response.headers["last-modified"], response.headers["cache-control"],
43
+ response.headers["expires"], response.headers["retry-after"], response.headers["x-rate-limit-reset"], response.body)
44
+ },
45
+ { if (!cancelled.get()) responder.handleFailure(HttpRequest.TEMPORARY_ERROR, "Native resource admission failed") },
46
+ { clean, done ->
47
+ previous.executeRequest(object : HttpResponder {
48
+ override fun onResponse(responseCode: Int, eTag: String?, lastModified: String?, cacheControl: String?, expires: String?, retryAfter: String?, xRateLimitReset: String?, body: ByteArray?) {
49
+ val metadata = linkedMapOf<String, String>()
50
+ for ((name, value) in listOf("etag" to eTag, "last-modified" to lastModified, "cache-control" to cacheControl, "expires" to expires, "retry-after" to retryAfter, "x-rate-limit-reset" to xRateLimitReset)) {
51
+ if (value != null) metadata[name] = value
52
+ }
53
+ done(AdmissionHttpResponse(responseCode, metadata, body ?: byteArrayOf()))
54
+ }
55
+ override fun handleFailure(type: Int, errorMessage: String?) { done(null) }
56
+ }, nativePtr, clean, dataRange, etag, modified, offlineUsage)
57
+ AdmissionCancellation { previous.cancelRequest() }
58
+ })
59
+ if (cancelled.get()) cancellation?.cancel()
60
+ }
61
+
62
+ override fun cancelRequest() {
63
+ cancelled.set(true)
64
+ cancellation?.cancel()
65
+ if (delegated) previous.cancelRequest()
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,86 @@
1
+ package dev.tileflow.reactnative
2
+
3
+ import java.io.IOException
4
+ import java.util.concurrent.TimeUnit
5
+ import okhttp3.Call
6
+ import okhttp3.Callback
7
+ import okhttp3.Dispatcher
8
+ import okhttp3.OkHttpClient
9
+ import okhttp3.Request
10
+ import okhttp3.Response
11
+
12
+ internal class AdmissionOkHttpNetwork(private val dispatcher: Dispatcher = Dispatcher()) : AdmissionNetwork {
13
+ private class StartGuard(val allowed: () -> Boolean) {
14
+ override fun toString() = "NativeAdmissionStartGuard"
15
+ }
16
+ private val lock = Any()
17
+ private val calls = mutableSetOf<Call>()
18
+ private var closed = false
19
+ init { dispatcher.maxRequests = 16; dispatcher.maxRequestsPerHost = 8 }
20
+ // This client is independent of MapLibre's global HTTP client and of the
21
+ // bootstrap channel. Never add a logging interceptor or shared cookie jar.
22
+ private val client = OkHttpClient.Builder()
23
+ .dispatcher(dispatcher)
24
+ .followRedirects(false)
25
+ .followSslRedirects(false)
26
+ .retryOnConnectionFailure(false)
27
+ .cache(null)
28
+ .connectTimeout(10, TimeUnit.SECONDS)
29
+ .callTimeout(30, TimeUnit.SECONDS)
30
+ .addNetworkInterceptor { chain ->
31
+ val guard = chain.request().tag(StartGuard::class.java)
32
+ if (guard == null || !guard.allowed()) throw IOException("Native admission is no longer valid")
33
+ // This is a nonblocking liveness/expiry check, never a JavaScript wait.
34
+ chain.proceed(chain.request())
35
+ }
36
+ .build()
37
+
38
+ override fun start(request: AdmissionHttpRequest, callback: (AdmissionHttpResponse?) -> Unit): AdmissionCancellation {
39
+ if (!request.mayStart()) { callback(null); return AdmissionCancellation {} }
40
+ val builder = Request.Builder().url(request.url).get().tag(StartGuard::class.java, StartGuard(request.mayStart))
41
+ for ((name, value) in request.headers) builder.header(name, value)
42
+ val call = client.newCall(builder.build())
43
+ val accepted = synchronized(lock) {
44
+ if (closed || calls.size >= AdmissionLimits.CONTEXTS * AdmissionLimits.QUEUE) false
45
+ else { calls.add(call); true }
46
+ }
47
+ if (!accepted) { callback(null); return AdmissionCancellation {} }
48
+ fun complete(response: AdmissionHttpResponse?) {
49
+ synchronized(lock) { calls.remove(call) }
50
+ callback(response)
51
+ }
52
+ try {
53
+ call.enqueue(object : Callback {
54
+ override fun onFailure(call: Call, e: IOException) { complete(null) }
55
+ override fun onResponse(call: Call, response: Response) {
56
+ val result = try {
57
+ response.use {
58
+ val body = it.body
59
+ val bytes = body?.byteStream()?.use { stream ->
60
+ readNativeDocumentBody(stream, request.maximumBytes, body.contentLength(), request.mayStart)
61
+ } ?: byteArrayOf()
62
+ // Forward only metadata consumed by MapLibre and Location
63
+ // for our own redirect validation, never raw headers.
64
+ val headers = linkedMapOf<String, String>()
65
+ for (name in listOf("etag", "last-modified", "cache-control", "expires", "retry-after", "x-rate-limit-reset", "location")) {
66
+ it.header(name)?.let { value -> headers[name] = value }
67
+ }
68
+ AdmissionHttpResponse(it.code, headers, bytes)
69
+ }
70
+ } catch (_: Exception) { null }
71
+ complete(result)
72
+ }
73
+ })
74
+ } catch (_: Exception) { call.cancel(); complete(null) }
75
+ // Logical cancellation does not release the physical dispatcher slot.
76
+ // Its terminal callback does, so rapid retire/register cannot grow it.
77
+ return AdmissionCancellation { call.cancel() }
78
+ }
79
+
80
+ fun close() {
81
+ val pending = synchronized(lock) { closed = true; calls.toList() }
82
+ for (call in pending) call.cancel()
83
+ client.connectionPool.evictAll()
84
+ dispatcher.executorService.shutdown()
85
+ }
86
+ }