@tileflow/react-native 0.0.0-bootstrap.0 → 0.1.0-alpha.1

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 +236 -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 +82 -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,56 @@
1
+ package dev.tileflow.reactnative
2
+
3
+ import org.maplibre.android.ModuleProvider
4
+
5
+ // Install/remove run on the module's serial scheduler. Network threads only
6
+ // read owns(). Never restore a provider after another owner has replaced it.
7
+ internal class AdmissionProviderLease(
8
+ private val read: () -> ModuleProvider,
9
+ private val write: (ModuleProvider) -> Unit,
10
+ ) {
11
+ val previous: ModuleProvider = read()
12
+ @Volatile private var candidate: ModuleProvider? = null
13
+ @Volatile private var removal: Map<String, Boolean>? = null
14
+ private var attempted = false
15
+
16
+ fun install(provider: ModuleProvider) {
17
+ if (attempted || removal != null || provider === previous) invalid()
18
+ attempted = true
19
+ try {
20
+ if (read() !== previous) invalid()
21
+ candidate = provider
22
+ write(provider)
23
+ if (read() !== provider) invalid()
24
+ } catch (_: Exception) {
25
+ // A setter may mutate successfully before throwing. Roll back only
26
+ // when identity still proves that the candidate is ours.
27
+ try { remove() } catch (_: Exception) { /* No acknowledgement on failed restoration. */ }
28
+ invalid()
29
+ }
30
+ }
31
+
32
+ fun owns(): Boolean = try {
33
+ val installed = candidate
34
+ installed != null && removal == null && read() === installed
35
+ } catch (_: Exception) { false }
36
+
37
+ fun remove(): Map<String, Boolean> {
38
+ removal?.let { return it }
39
+ val current = try { read() } catch (_: Exception) { invalid() }
40
+ val installed = candidate
41
+ if (current !== installed) {
42
+ val restored = current === previous
43
+ return mapOf("removed" to restored, "ownershipLost" to !restored).also { removal = it }
44
+ }
45
+ try { write(previous) } catch (_: Exception) {
46
+ // Verify the postcondition even when the setter throws afterwards.
47
+ if (try { read() !== previous } catch (_: Exception) { true }) invalid()
48
+ }
49
+ val after = try { read() } catch (_: Exception) { invalid() }
50
+ if (after === installed) invalid()
51
+ val restored = after === previous
52
+ return mapOf("removed" to restored, "ownershipLost" to !restored).also { removal = it }
53
+ }
54
+
55
+ private fun invalid(): Nothing = throw IllegalArgumentException("Native provider ownership is unavailable")
56
+ }
@@ -0,0 +1,85 @@
1
+ package dev.tileflow.reactnative
2
+
3
+ internal fun interface AdmissionCancellation { fun cancel() }
4
+
5
+ internal interface AdmissionScheduler {
6
+ fun nowMs(): Long
7
+ fun dispatch(action: () -> Unit)
8
+ fun after(delayMs: Long, action: () -> Unit): AdmissionCancellation
9
+ }
10
+
11
+ internal interface AdmissionNetwork {
12
+ fun start(request: AdmissionHttpRequest, callback: (AdmissionHttpResponse?) -> Unit): AdmissionCancellation
13
+ }
14
+
15
+ // Deliberately not data classes: generated string representations must not
16
+ // expose headers, authority, response bodies or URLs to diagnostic tooling.
17
+ internal class AdmissionHttpRequest(
18
+ val url: String,
19
+ val headers: Map<String, String>,
20
+ val maximumBytes: Int,
21
+ val mayStart: () -> Boolean,
22
+ ) {
23
+ constructor(url: String, headers: Map<String, String>, mayStart: () -> Boolean) :
24
+ this(url, headers, AdmissionLimits.RESPONSE_BYTES.toInt(), mayStart)
25
+ init { require(maximumBytes in 1..AdmissionLimits.RESPONSE_BYTES.toInt()) { "Invalid native resource bound" } }
26
+ override fun toString() = "AdmissionHttpRequest(redacted)"
27
+ }
28
+
29
+ internal class AdmissionHttpResponse(
30
+ val code: Int,
31
+ val headers: Map<String, String>,
32
+ val body: ByteArray,
33
+ val url: String? = null,
34
+ ) {
35
+ override fun toString() = "AdmissionHttpResponse(redacted)"
36
+ }
37
+
38
+ internal class AdmissionResource(
39
+ val url: String,
40
+ val scope: String,
41
+ val tilesetId: String?,
42
+ val template: String? = null,
43
+ fontStacks: List<String>? = null,
44
+ ) {
45
+ val fontStacks: List<String>? = fontStacks?.let { java.util.Collections.unmodifiableList(it.toList()) }
46
+ override fun toString() = "AdmissionResource(redacted)"
47
+ }
48
+
49
+ internal class AdmissionAuthority(
50
+ val grant: String,
51
+ val mapId: String,
52
+ val resourceOrigins: List<String>,
53
+ val resourceScopes: List<String>,
54
+ val tilesetIds: List<String>,
55
+ ) {
56
+ fun allows(resource: AdmissionResource, expectedMap: String): Boolean =
57
+ mapId == expectedMap && resourceOrigins.contains(AdmissionUrl.origin(resource.url)) &&
58
+ resourceScopes.contains(resource.scope) &&
59
+ (resource.scope != "style" || AdmissionUrl.styleMatchesMap(resource.url, expectedMap)) &&
60
+ (resource.tilesetId == null || tilesetIds.contains(resource.tilesetId))
61
+ override fun toString() = "AdmissionAuthority(redacted)"
62
+ }
63
+
64
+ internal sealed class AdmissionResult(val ticket: String) {
65
+ class Grant(ticket: String, val validForMs: Long, val authority: AdmissionAuthority) : AdmissionResult(ticket)
66
+ class Delegate(ticket: String) : AdmissionResult(ticket)
67
+ class Reject(ticket: String) : AdmissionResult(ticket)
68
+ override fun toString() = "AdmissionResult(redacted)"
69
+ }
70
+
71
+ internal object AdmissionLimits {
72
+ const val CONTEXTS = 16
73
+ const val RESOURCES = 128
74
+ const val QUEUE = 128
75
+ const val BATCH = 8
76
+ const val URL = 2048
77
+ const val BRIDGE_BYTES = 524288
78
+ const val GRANT = 24576
79
+ const val WAIT_MS = 30000L
80
+ const val SAFETY_MS = 1000L
81
+ const val MAX_VALID_MS = 900000L
82
+ const val REDIRECTS = 3
83
+ const val RESPONSE_BYTES = 8388608L
84
+ const val GRANT_HEADER = "X-Tileflow-Native-Grant"
85
+ }
@@ -0,0 +1,100 @@
1
+ package dev.tileflow.reactnative
2
+
3
+ import java.net.URI
4
+ import java.util.Locale
5
+ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
6
+
7
+ internal object AdmissionUrl {
8
+ private const val PARAMETER = "__tf_native_context"
9
+ private val token = Regex("[A-Za-z0-9_.-]{1,96}")
10
+ private val compact = Regex("[A-Za-z0-9._:-]{1,255}")
11
+ private val scopes = setOf("style", "tilejson", "tile", "sprite", "glyph", "font")
12
+ private val percent = Regex("%([0-9a-fA-F]{2})")
13
+ private val nativeStyle = Regex("/maps/(map_[A-Za-z0-9_-]{16})/native/v([1-9][0-9]{0,15})/([A-Za-z][A-Za-z0-9_-]{0,63})\\.json")
14
+ private val reservedStyle = Regex("^/maps/[^/?#]+/native(?:[/?#]|$)")
15
+ private fun decoded(value: String) = percent.replace(value) { it.groupValues[1].toInt(16).toChar().toString() }
16
+ fun validToken(value: String) = token.matches(value) && !value.startsWith("tf_")
17
+ fun reserved(value: String): Boolean {
18
+ val query = value.indexOf('?')
19
+ if (query < 0) return false
20
+ return value.substring(query + 1).split('&').any {
21
+ decoded(it.substringBefore('=')).lowercase(Locale.ROOT).startsWith("__tf_native")
22
+ }
23
+ }
24
+ fun origin(value: String): String {
25
+ val parsed = value.toHttpUrlOrNull() ?: invalid()
26
+ val port = if (parsed.port == if (parsed.scheme == "https") 443 else 80) "" else ":${parsed.port}"
27
+ val host = if (parsed.host.contains(':')) "[${parsed.host}]" else parsed.host
28
+ return "${parsed.scheme}://$host$port"
29
+ }
30
+ fun styleMatchesMap(value: String, mapId: String): Boolean {
31
+ return try {
32
+ val path = value.substring(origin(value).length)
33
+ if (!reservedStyle.containsMatchIn(decoded(path))) true
34
+ else {
35
+ val match = nativeStyle.matchEntire(path)
36
+ val version = match?.groupValues?.get(2)?.toLongOrNull()
37
+ match != null && match.groupValues[1] == mapId && version != null && version in 1L..9007199254740991L
38
+ }
39
+ } catch (_: Exception) { false }
40
+ }
41
+ fun clean(value: String): String = clean(value, null)
42
+
43
+ /** Development HTTP is limited to credential-free document reads. */
44
+ fun document(value: String, developmentOrigin: String?): String {
45
+ if (developmentOrigin != null) {
46
+ if (!developmentOrigin.startsWith("http://") || origin("$developmentOrigin/") != developmentOrigin) invalid()
47
+ clean("$developmentOrigin/", developmentOrigin)
48
+ }
49
+ return clean(value, developmentOrigin)
50
+ }
51
+
52
+ private fun clean(value: String, developmentOrigin: String?): String {
53
+ if (value.length > AdmissionLimits.URL || value.any { it.code !in 33..126 || it == '\\' || it == '#' } || reserved(value)) invalid()
54
+ val parsed = value.toHttpUrlOrNull() ?: invalid()
55
+ if ((parsed.scheme != "https" && (parsed.scheme != "http" || origin(value) != developmentOrigin)) ||
56
+ parsed.username.isNotEmpty() || parsed.password.isNotEmpty() || parsed.toString() != value) invalid()
57
+ val text = decoded(value).lowercase(Locale.ROOT)
58
+ if (text.contains("tf_native_") || text.contains("tf_public_")) invalid()
59
+ return value
60
+ }
61
+ fun resource(value: AdmissionResource): AdmissionResource {
62
+ clean(value.url)
63
+ if (value.url.length > AdmissionLimits.URL - 128 || !scopes.contains(value.scope)) invalid()
64
+ if ((value.scope == "tile" || value.scope == "tilejson") && value.tilesetId == null) invalid()
65
+ value.tilesetId?.let {
66
+ if (!compact.matches(it) || it.contains("tf_native_", true) || it.contains("tf_public_", true)) invalid()
67
+ }
68
+ return value
69
+ }
70
+ class Discriminated(val context: String, val url: String)
71
+ fun strip(value: String): Discriminated {
72
+ if (value.length > AdmissionLimits.URL || value.contains('#') || value.contains('\\')) invalid()
73
+ val queryAt = value.indexOf('?')
74
+ if (queryAt < 0) invalid()
75
+ val parts = value.substring(queryAt + 1).split('&')
76
+ val matches = parts.withIndex().filter {
77
+ decoded(it.value.substringBefore('=')).lowercase(Locale.ROOT).startsWith("__tf_native")
78
+ }
79
+ if (matches.size != 1) invalid()
80
+ val selected = matches.single()
81
+ if (!selected.value.startsWith("$PARAMETER=")) invalid()
82
+ val context = selected.value.substring(PARAMETER.length + 1)
83
+ if (!validToken(context)) invalid()
84
+ val remaining = parts.filterIndexed { index, _ -> index != selected.index }
85
+ val url = value.substring(0, queryAt) + if (remaining.isEmpty()) "" else "?" + remaining.joinToString("&")
86
+ return Discriminated(context, clean(url))
87
+ }
88
+ fun redirect(previous: String, location: String, context: String): String {
89
+ if (location.length > AdmissionLimits.URL || location.any { it.code !in 33..126 } || location.contains('\\')) invalid()
90
+ val resolved = try { URI(previous).resolve(location).toString() } catch (_: Exception) { invalid() }
91
+ val clean = if (reserved(resolved)) {
92
+ val tagged = strip(resolved)
93
+ if (tagged.context != context) invalid()
94
+ tagged.url
95
+ } else clean(resolved)
96
+ if (origin(clean) != origin(previous)) invalid()
97
+ return clean
98
+ }
99
+ private fun invalid(): Nothing = throw IllegalArgumentException("Invalid native resource")
100
+ }
@@ -0,0 +1,16 @@
1
+ package dev.tileflow.reactnative
2
+
3
+ import android.os.Handler
4
+ import android.os.Looper
5
+ import android.os.SystemClock
6
+
7
+ internal class HandlerAdmissionScheduler : AdmissionScheduler {
8
+ private val handler = Handler(Looper.getMainLooper())
9
+ override fun nowMs(): Long = SystemClock.elapsedRealtime()
10
+ override fun dispatch(action: () -> Unit) { handler.post { action() } }
11
+ override fun after(delayMs: Long, action: () -> Unit): AdmissionCancellation {
12
+ val task = Runnable { action() }
13
+ handler.postDelayed(task, delayMs)
14
+ return AdmissionCancellation { handler.removeCallbacks(task) }
15
+ }
16
+ }
@@ -0,0 +1,95 @@
1
+ package dev.tileflow.reactnative
2
+
3
+ import java.util.Locale
4
+
5
+ internal class MobileConfigurationException : RuntimeException("Native application configuration is invalid.")
6
+
7
+ /** The platform adapter must return only literal strings, never resolved resource aliases. */
8
+ internal interface MobileConfigurationValues : AutoCloseable {
9
+ val size: Int
10
+ val applicationOwned: Boolean
11
+ fun literalString(index: Int): String?
12
+ override fun close()
13
+ }
14
+
15
+ /** Application data only. Never use this object as Map, session or transport identity. */
16
+ internal class MobileConfiguration private constructor(val apiOrigin: String, val credential: String) {
17
+ override fun toString() = "MobileConfiguration"
18
+
19
+ companion object {
20
+ private val originPattern = Regex("[Hh][Tt][Tt][Pp][Ss]://([A-Za-z0-9.-]+)(?::([1-9][0-9]{0,4}))?/?")
21
+ private val labelPattern = Regex("[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?")
22
+ private val octetPattern = Regex("(?:0|[1-9][0-9]{0,2})")
23
+ private fun invalid(): Nothing = throw MobileConfigurationException()
24
+
25
+ private fun origin(input: String): String {
26
+ if (input.isEmpty() || input.length > 2048) invalid()
27
+ val match = originPattern.matchEntire(input) ?: invalid()
28
+ val host = match.groupValues[1].lowercase(Locale.ROOT)
29
+ val labels = host.split('.')
30
+ if (host.length > 253 || labels.any { !labelPattern.matches(it) }) invalid()
31
+ if (labels.last().first() !in 'a'..'z') {
32
+ if (labels.size != 4 || labels.any { !octetPattern.matches(it) || it.toInt() > 255 }) invalid()
33
+ }
34
+ val port = match.groupValues[2].let { if (it.isEmpty()) 443 else it.toInt() }
35
+ if (port > 65535) invalid()
36
+ return "https://$host" + if (port == 443) "" else ":$port"
37
+ }
38
+
39
+ fun parse(input: Any?): MobileConfiguration {
40
+ try {
41
+ if (input !is List<*> || input.size != 2) invalid()
42
+ var apiOrigin: String? = null
43
+ var credential: String? = null
44
+ for (item in input) {
45
+ if (item !is String || item.length > 2080) invalid()
46
+ val separator = item.indexOf('=')
47
+ if (separator <= 0) invalid()
48
+ val value = item.substring(separator + 1)
49
+ when (item.substring(0, separator)) {
50
+ "apiOrigin" -> { if (apiOrigin != null) invalid(); apiOrigin = origin(value) }
51
+ "credential" -> {
52
+ if (credential != null || value.length != 58 || !value.startsWith("tf_public_") ||
53
+ value.substring(10).any { it !in '0'..'9' && it !in 'a'..'f' }) invalid()
54
+ credential = value
55
+ }
56
+ else -> invalid()
57
+ }
58
+ }
59
+ return MobileConfiguration(apiOrigin ?: invalid(), credential ?: invalid())
60
+ } catch (_: Exception) { invalid() }
61
+ }
62
+
63
+ fun load(values: MobileConfigurationValues): MobileConfiguration {
64
+ try {
65
+ try {
66
+ if (!values.applicationOwned || values.size != 2) invalid()
67
+ return parse(listOf(values.literalString(0), values.literalString(1)))
68
+ } finally { values.close() }
69
+ } catch (_: Exception) { invalid() }
70
+ }
71
+ }
72
+ }
73
+
74
+ /** Process-scoped immutable configuration, including failed reads. Contains no React context. */
75
+ internal class MobileConfigurationCache {
76
+ private var phase = 0
77
+ private var configuration: MobileConfiguration? = null
78
+
79
+ @Synchronized fun read(source: () -> MobileConfiguration): MobileConfiguration {
80
+ if (phase == 2) return configuration ?: throw MobileConfigurationException()
81
+ if (phase != 0) { phase = 3; throw MobileConfigurationException() }
82
+ phase = 1
83
+ try {
84
+ val result = source()
85
+ if (phase != 1) throw MobileConfigurationException()
86
+ configuration = result
87
+ phase = 2
88
+ return result
89
+ } catch (_: Exception) {
90
+ configuration = null
91
+ phase = 3
92
+ throw MobileConfigurationException()
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,43 @@
1
+ package dev.tileflow.reactnative
2
+
3
+ import android.content.Context
4
+ import android.content.res.Resources
5
+ import android.util.TypedValue
6
+
7
+ internal fun mobileConfigurationLiteral(value: TypedValue): String? {
8
+ if (value.type != TypedValue.TYPE_STRING || value.resourceId != 0) return null
9
+ val text = value.string as? String ?: return null
10
+ return if (text.length <= 2080) text else null
11
+ }
12
+
13
+ /** Only this named application array is accepted. There is no manifest or BuildConfig fallback. */
14
+ internal fun readMobileConfigurationResources(context: Context): MobileConfiguration {
15
+ try {
16
+ val application = context.applicationContext
17
+ val resources = application.resources
18
+ val id = resources.getIdentifier("tileflow_mobile_configuration", "array", application.packageName)
19
+ if (id == 0 || resources.getResourcePackageName(id) != application.packageName ||
20
+ resources.getResourceTypeName(id) != "array") throw MobileConfigurationException()
21
+ // A complex array is a resource bag, not a scalar/reference. Reject a whole-array alias.
22
+ // Resources.getValue does not return complex bags; obtainTypedArray reads the bag below.
23
+ var scalar = false
24
+ try {
25
+ resources.getValue(id, TypedValue(), false)
26
+ scalar = true
27
+ } catch (_: Resources.NotFoundException) { /* Expected only for a complex bag. */ }
28
+ if (scalar) throw MobileConfigurationException()
29
+ val array = resources.obtainTypedArray(id)
30
+ return MobileConfiguration.load(object : MobileConfigurationValues {
31
+ override val size get() = array.length()
32
+ override val applicationOwned = true
33
+ override fun literalString(index: Int): String? {
34
+ val value = TypedValue()
35
+ return if (array.getValue(index, value)) mobileConfigurationLiteral(value) else null
36
+ }
37
+ override fun close() = array.recycle()
38
+ })
39
+ } catch (_: Exception) {
40
+ // Resource IDs, values, exception messages and configured origins are not diagnostics.
41
+ throw MobileConfigurationException()
42
+ }
43
+ }
@@ -0,0 +1,101 @@
1
+ package dev.tileflow.reactnative
2
+
3
+ import java.io.ByteArrayOutputStream
4
+ import java.io.IOException
5
+ import java.io.InputStream
6
+ import java.net.URI
7
+ import java.util.concurrent.TimeUnit
8
+ import java.util.concurrent.atomic.AtomicBoolean
9
+ import okhttp3.Authenticator
10
+ import okhttp3.Call
11
+ import okhttp3.Callback
12
+ import okhttp3.CookieJar
13
+ import okhttp3.Dispatcher
14
+ import okhttp3.OkHttpClient
15
+ import okhttp3.Request
16
+ import okhttp3.Response
17
+
18
+ internal fun readNativeDocumentBody(source: InputStream, maximumBytes: Int, declared: Long, active: () -> Boolean): ByteArray {
19
+ if (maximumBytes !in 1..8388608 || declared > maximumBytes) throw IOException("Native document acquisition failed.")
20
+ val output = ByteArrayOutputStream(minOf(maximumBytes, 8192))
21
+ val buffer = ByteArray(8192)
22
+ while (true) {
23
+ if (!active()) throw IOException("Native document acquisition failed.")
24
+ val count = source.read(buffer, 0, minOf(buffer.size, maximumBytes - output.size() + 1))
25
+ if (count < 0) return output.toByteArray()
26
+ if (count == 0 || output.size() + count > maximumBytes) throw IOException("Native document acquisition failed.")
27
+ output.write(buffer, 0, count)
28
+ }
29
+ }
30
+
31
+ /** Initial manifests and unprotected preparation never use the MapLibre session or application credential. */
32
+ internal class NativeDocumentOkHttpNetwork {
33
+ private class Guard(val active: () -> Boolean)
34
+ private val dispatcher = Dispatcher().apply { maxRequests = 4; maxRequestsPerHost = 4 }
35
+ private val client = OkHttpClient.Builder()
36
+ .dispatcher(dispatcher)
37
+ .cookieJar(CookieJar.NO_COOKIES)
38
+ .authenticator(Authenticator.NONE)
39
+ .proxyAuthenticator(Authenticator.NONE)
40
+ .cache(null)
41
+ .followRedirects(false)
42
+ .followSslRedirects(false)
43
+ .callTimeout(30, TimeUnit.SECONDS)
44
+ .addInterceptor { chain ->
45
+ if (chain.request().tag(Guard::class.java)?.active?.invoke() != true) throw IOException("Native document acquisition failed.")
46
+ chain.proceed(chain.request())
47
+ }
48
+ .build()
49
+
50
+ fun start(url: String, maximumBytes: Int, active: () -> Boolean, completion: (AdmissionHttpResponse?) -> Unit, developmentOrigin: String? = null): AdmissionCancellation {
51
+ val live = AtomicBoolean(true)
52
+ val completed = AtomicBoolean(false)
53
+ var redirects = 0
54
+ var call: Call? = null
55
+ val guard = { live.get() && active() }
56
+ fun finish(response: AdmissionHttpResponse?) {
57
+ if (completed.compareAndSet(false, true)) completion(response)
58
+ }
59
+ fun issue(target: String) {
60
+ if (!guard()) { finish(null); return }
61
+ try {
62
+ AdmissionUrl.document(target, developmentOrigin)
63
+ val next = client.newCall(Request.Builder().url(target).get().tag(Guard::class.java, Guard(guard)).build())
64
+ call = next
65
+ next.enqueue(object : Callback {
66
+ override fun onFailure(call: Call, error: IOException) { finish(null) }
67
+ override fun onResponse(call: Call, response: Response) {
68
+ try {
69
+ var redirect: String? = null
70
+ val received = response.use { reply ->
71
+ if (!guard()) throw IOException("Native document acquisition failed.")
72
+ if (reply.code in listOf(301, 302, 303, 307, 308)) {
73
+ if (++redirects > 3) throw IOException("Native document acquisition failed.")
74
+ val location = reply.header("Location") ?: throw IOException("Native document acquisition failed.")
75
+ if (location.length > 2048 || location.any { it.code !in 33..126 || it == '\\' }) throw IOException("Native document acquisition failed.")
76
+ redirect = AdmissionUrl.document(URI(target).resolve(location).toString(), developmentOrigin)
77
+ if (AdmissionUrl.origin(redirect!!) != AdmissionUrl.origin(target)) throw IOException("Native document acquisition failed.")
78
+ null
79
+ } else {
80
+ val body = reply.body ?: throw IOException("Native document acquisition failed.")
81
+ val bytes = readNativeDocumentBody(body.byteStream(), maximumBytes, body.contentLength(), guard)
82
+ AdmissionHttpResponse(reply.code, emptyMap(), bytes, target)
83
+ }
84
+ }
85
+ if (redirect != null) issue(redirect!!) else finish(received)
86
+ } catch (_: Exception) { finish(null) }
87
+ }
88
+ })
89
+ if (!guard()) next.cancel()
90
+ } catch (_: Exception) { finish(null) }
91
+ }
92
+ issue(url)
93
+ return AdmissionCancellation {
94
+ live.set(false)
95
+ call?.cancel()
96
+ // OkHttp's callback is the physical terminal acknowledgement.
97
+ }
98
+ }
99
+
100
+ fun close() { dispatcher.cancelAll() }
101
+ }
@@ -0,0 +1,136 @@
1
+ package dev.tileflow.reactnative
2
+
3
+ import java.util.UUID
4
+ import java.util.concurrent.atomic.AtomicBoolean
5
+
6
+ internal class NativeDocumentScope(
7
+ val isActive: () -> Boolean,
8
+ val load: (String, (AdmissionHttpResponse?) -> Unit) -> AdmissionCancellation,
9
+ val context: String? = null,
10
+ ) {
11
+ override fun toString() = "NativeDocumentScope(redacted)"
12
+ }
13
+ internal class NativeDocumentHeader(val url: String, val status: Int) {
14
+ override fun toString() = "NativeDocumentHeader(redacted)"
15
+ }
16
+ internal class NativeDocumentChunk(val bytes: ByteArray, val last: Boolean) {
17
+ override fun toString() = "NativeDocumentChunk(redacted)"
18
+ }
19
+
20
+ /** All registry mutations run on the native serial scheduler, independently of JavaScript. */
21
+ internal class NativeDocumentRegistry(
22
+ private val scheduler: AdmissionScheduler,
23
+ private val load: (String, Int, NativeDocumentScope?, String?, () -> Boolean, (AdmissionHttpResponse?) -> Unit) -> AdmissionCancellation,
24
+ private val identifier: () -> String = { UUID.randomUUID().toString() },
25
+ ) {
26
+ private class Work(val id: String, val url: String, val maximumBytes: Int, val scope: NativeDocumentScope?, val developmentOrigin: String?, val deadline: Long) {
27
+ val live = AtomicBoolean(true)
28
+ var nativeFinished = false
29
+ var cancellation: AdmissionCancellation? = null
30
+ var timer: AdmissionCancellation? = null
31
+ var body: ByteArray? = null
32
+ var offset = 0
33
+ var header: NativeDocumentHeader? = null
34
+ var requested = false
35
+ var waiter: ((NativeDocumentHeader?) -> Unit)? = null
36
+ }
37
+ private val work = linkedMapOf<String, Work>()
38
+ @Volatile private var foreground = true
39
+ @Volatile private var closed = false
40
+
41
+ fun open(url: String, maximumBytes: Int, scope: NativeDocumentScope?, developmentOrigin: String? = null): String {
42
+ if (scope != null && developmentOrigin != null) invalid()
43
+ AdmissionUrl.document(url, developmentOrigin)
44
+ if (closed || !foreground || work.size >= 16 || maximumBytes !in 1..8388608 || (scope != null && !scope.isActive())) invalid()
45
+ val id = identifier()
46
+ if (!AdmissionUrl.validToken(id) || work.containsKey(id)) invalid()
47
+ val item = Work(id, url, maximumBytes, scope, developmentOrigin, scheduler.nowMs() + 30000)
48
+ work[id] = item
49
+ item.timer = scheduler.after(30000) { retireQuietly(id) }
50
+ try {
51
+ val cancellation = load(url, maximumBytes, scope, developmentOrigin, { active(item) }) { response ->
52
+ scheduler.dispatch { received(item, response) }
53
+ }
54
+ item.cancellation = cancellation
55
+ if (!item.live.get()) cancellation.cancel()
56
+ } catch (_: Exception) {
57
+ item.nativeFinished = true
58
+ retireQuietly(id)
59
+ invalid()
60
+ }
61
+ return id
62
+ }
63
+
64
+ fun response(id: String, callback: (NativeDocumentHeader?) -> Unit) {
65
+ val item = work[id]
66
+ if (item == null || !active(item) || item.requested) { callback(null); return }
67
+ item.requested = true
68
+ if (item.header != null) callback(item.header)
69
+ else item.waiter = callback
70
+ }
71
+
72
+ fun chunk(id: String, maximumBytes: Int): NativeDocumentChunk {
73
+ val item = work[id] ?: invalid()
74
+ if (!active(item) || !item.requested || maximumBytes !in 1..65536) { retireQuietly(id); invalid() }
75
+ val bytes = item.body ?: invalid()
76
+ val end = minOf(bytes.size, item.offset + maximumBytes)
77
+ val value = bytes.copyOfRange(item.offset, end)
78
+ item.offset = end
79
+ val last = end == bytes.size
80
+ if (last) cancel(id)
81
+ return NativeDocumentChunk(value, last)
82
+ }
83
+
84
+ fun cancel(id: String) {
85
+ if (!AdmissionUrl.validToken(id)) invalid()
86
+ val item = work[id] ?: return
87
+ item.live.set(false)
88
+ item.timer?.cancel(); item.timer = null
89
+ val waiter = item.waiter; item.waiter = null
90
+ item.body?.fill(0); item.body = null; item.header = null
91
+ try {
92
+ if (!item.nativeFinished) item.cancellation?.cancel()
93
+ item.cancellation = null
94
+ } finally {
95
+ if (item.nativeFinished) work.remove(id)
96
+ waiter?.invoke(null)
97
+ }
98
+ }
99
+
100
+ fun retireContext(context: String) {
101
+ for (item in work.values.toList()) if (item.scope?.context == context) retireQuietly(item.id)
102
+ }
103
+ fun lifecycle(active: Boolean) {
104
+ foreground = active
105
+ if (!active) for (id in work.keys.toList()) retireQuietly(id)
106
+ }
107
+ fun close() {
108
+ closed = true
109
+ for (id in work.keys.toList()) retireQuietly(id)
110
+ }
111
+ private fun retireQuietly(id: String) {
112
+ try { cancel(id) } catch (_: Exception) { /* Logical retirement precedes retryable native cleanup. */ }
113
+ }
114
+
115
+ private fun active(item: Work): Boolean = try {
116
+ item.live.get() && !closed && foreground && scheduler.nowMs() < item.deadline && (item.scope?.isActive() != false)
117
+ } catch (_: Exception) { false }
118
+
119
+ private fun received(item: Work, response: AdmissionHttpResponse?) {
120
+ if (item.nativeFinished) return
121
+ item.nativeFinished = true
122
+ if (!active(item) || response == null || response.code !in 100..599 || response.body.size > item.maximumBytes) {
123
+ response?.body?.fill(0); retireQuietly(item.id); return
124
+ }
125
+ val finalUrl = response.url ?: item.url
126
+ try {
127
+ AdmissionUrl.document(finalUrl, item.developmentOrigin)
128
+ if (AdmissionUrl.origin(finalUrl) != AdmissionUrl.origin(item.url)) invalid()
129
+ } catch (_: Exception) { response.body.fill(0); retireQuietly(item.id); return }
130
+ item.body = response.body
131
+ item.header = NativeDocumentHeader(finalUrl, response.code)
132
+ val waiter = item.waiter; item.waiter = null
133
+ waiter?.invoke(item.header)
134
+ }
135
+ private fun invalid(): Nothing = throw IllegalArgumentException("Native document acquisition failed.")
136
+ }