@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.
- package/README.md +216 -4
- package/TileflowNativeAdmission.podspec +34 -0
- package/android/build.gradle +43 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionBootstrap.kt +144 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionBootstrapOkHttpNetwork.kt +89 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionCatalog.kt +137 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionDocumentScope.kt +22 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionEngine.kt +335 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionModuleProvider.kt +68 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionOkHttpNetwork.kt +86 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionProviderLease.kt +56 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionTypes.kt +85 -0
- package/android/src/main/java/dev/tileflow/reactnative/AdmissionUrl.kt +100 -0
- package/android/src/main/java/dev/tileflow/reactnative/HandlerAdmissionScheduler.kt +16 -0
- package/android/src/main/java/dev/tileflow/reactnative/MobileConfiguration.kt +95 -0
- package/android/src/main/java/dev/tileflow/reactnative/MobileConfigurationResources.kt +43 -0
- package/android/src/main/java/dev/tileflow/reactnative/NativeDocumentOkHttpNetwork.kt +101 -0
- package/android/src/main/java/dev/tileflow/reactnative/NativeDocumentRegistry.kt +136 -0
- package/android/src/main/java/dev/tileflow/reactnative/NativeSurfaceState.kt +115 -0
- package/android/src/main/java/dev/tileflow/reactnative/TileflowNativeAdmissionModule.kt +234 -0
- package/android/src/main/java/dev/tileflow/reactnative/TileflowNativeAdmissionPackage.kt +16 -0
- package/android/src/main/java/dev/tileflow/reactnative/TileflowNativeConfigurationModule.kt +43 -0
- package/android/src/main/java/dev/tileflow/reactnative/TileflowNativeDocumentsModule.kt +80 -0
- package/android/src/main/java/dev/tileflow/reactnative/TileflowNativeSurfaceModule.kt +277 -0
- package/android/src/main/res/raw/tileflow_configuration_keep.xml +3 -0
- package/dist/contract-DWs-lDPo.d.ts +144 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +6989 -0
- package/dist/internal/camera.d.ts +65 -0
- package/dist/internal/camera.js +325 -0
- package/dist/internal/hosted-binding.d.ts +18 -0
- package/dist/internal/hosted-binding.js +277 -0
- package/dist/internal/initial-view.d.ts +11 -0
- package/dist/internal/initial-view.js +8 -0
- package/dist/internal/interactions.d.ts +76 -0
- package/dist/internal/interactions.js +794 -0
- package/dist/internal/native-admission-bridge.d.ts +29 -0
- package/dist/internal/native-admission-bridge.js +351 -0
- package/dist/internal/native-admission.d.ts +78 -0
- package/dist/internal/native-admission.js +1614 -0
- package/dist/internal/native-appearance.d.ts +21 -0
- package/dist/internal/native-appearance.js +110 -0
- package/dist/internal/native-configuration-bridge.d.ts +9 -0
- package/dist/internal/native-configuration-bridge.js +343 -0
- package/dist/internal/session.d.ts +125 -0
- package/dist/internal/session.js +887 -0
- package/dist/internal/source-state.d.ts +11 -0
- package/dist/internal/source-state.js +26 -0
- package/dist/native-admission-contract-k7LKE1vW.d.ts +89 -0
- package/docs/native-admission.md +108 -0
- package/docs/native-configuration.md +210 -0
- package/docs/native-interactions.md +161 -0
- package/docs/native-location.md +148 -0
- package/ios/TFAdmissionBootstrap.h +17 -0
- package/ios/TFAdmissionBootstrap.mm +168 -0
- package/ios/TFAdmissionCatalog.h +13 -0
- package/ios/TFAdmissionCatalog.mm +193 -0
- package/ios/TFAdmissionDocumentScope.h +7 -0
- package/ios/TFAdmissionDocumentScope.mm +45 -0
- package/ios/TFAdmissionEngine.h +57 -0
- package/ios/TFAdmissionEngine.mm +371 -0
- package/ios/TFAdmissionInstallation.h +27 -0
- package/ios/TFAdmissionInstallation.mm +117 -0
- package/ios/TFAdmissionNetwork.h +16 -0
- package/ios/TFAdmissionNetwork.mm +268 -0
- package/ios/TFAdmissionURL.mm +116 -0
- package/ios/TFDocumentBounds.h +9 -0
- package/ios/TFDocumentBounds.mm +19 -0
- package/ios/TFMobileConfiguration.h +23 -0
- package/ios/TFMobileConfiguration.mm +139 -0
- package/ios/TFNativeAdmissionURLProtocol.mm +69 -0
- package/ios/TFNativeDocumentRegistry.h +28 -0
- package/ios/TFNativeDocumentRegistry.mm +193 -0
- package/ios/TFNativeDocumentURL.h +6 -0
- package/ios/TFNativeSurfaceState.h +28 -0
- package/ios/TFNativeSurfaceState.mm +128 -0
- package/ios/TileflowNativeAdmission.h +9 -0
- package/ios/TileflowNativeAdmission.mm +184 -0
- package/ios/TileflowNativeConfiguration.mm +41 -0
- package/ios/TileflowNativeDocuments.h +12 -0
- package/ios/TileflowNativeDocuments.mm +105 -0
- package/ios/TileflowNativeSurface.mm +403 -0
- package/ios/tileflow_native_admission.rb +45 -0
- package/package.json +51 -7
- 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
|
+
}
|