@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.
- 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 +236 -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 +82 -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
package/README.md
CHANGED
|
@@ -1,7 +1,219 @@
|
|
|
1
1
|
# @tileflow/react-native
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
dependencies and is not an application SDK. Do not use it in an application.
|
|
3
|
+
`@tileflow/react-native` provides a Tileflow `Map` backed by MapLibre Native on iOS and Android. It is pre-release. The first alpha release requires the bootstrap and Trusted Publisher checks in the [publishing guide](https://github.com/tileflow/tileflow-sdk/blob/main/PUBLISHING.md).
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
The package targets exactly React 19.2.0, React Native 0.83.10 and `@maplibre/maplibre-react-native` 11.3.10, with MapLibre Native Android 13.2.0 and iOS 6.26.0 through the pinned integration below. Expo consumers use Expo 55.0.31 with a native development build; Expo Go does not contain the required native modules.
|
|
6
|
+
|
|
7
|
+
> Related packages and guides: [documentation index](https://raw.githubusercontent.com/tileflow/tileflow-sdk/main/llms.txt).
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
Start with an Expo development build or a bare React Native application matching those peers. Once the first alpha is available, install the package and its direct dependencies:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install --save-exact @tileflow/react-native@alpha @tileflow/core@alpha @tileflow/interactions@alpha @maplibre/maplibre-react-native@11.3.10
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Keep the lockfile. Complete the [native integration](#native-integration) before building the application. Source on `main` can precede npm; installation or local checks do not establish Hosted availability or physical-device acceptance.
|
|
18
|
+
|
|
19
|
+
The package exports a mounted `Map` and its public types from the ordinary package root. Importing the package does not install native networking, read application configuration, create a Map, start a session, acquire admission, schedule timers or perform network I/O. Those effects begin only inside a mounted component lifecycle.
|
|
20
|
+
|
|
21
|
+
Tileflow `Map` is **Tileflow-only**. Its `source` is the existing Tileflow descriptor object:
|
|
22
|
+
|
|
23
|
+
<!-- docs:check -->
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
import {Map} from '@tileflow/react-native';
|
|
27
|
+
|
|
28
|
+
export function StoreMap() {
|
|
29
|
+
return (
|
|
30
|
+
<Map
|
|
31
|
+
source={{
|
|
32
|
+
map: 'stores',
|
|
33
|
+
manifestUrl: 'https://maps.example.com/tileflow/manifest.json',
|
|
34
|
+
}}
|
|
35
|
+
theme="light"
|
|
36
|
+
style={{flex: 1}}
|
|
37
|
+
testID="store-map"
|
|
38
|
+
/>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
There is no public renderer discriminator, arbitrary `mapStyle`, credential prop, Hosted client, Provider, admission API, native handle or MapLibre ref. An application that needs a wholly unmanaged MapLibre map should use `@maplibre/maplibre-react-native` directly.
|
|
44
|
+
|
|
45
|
+
## Mounted ownership
|
|
46
|
+
|
|
47
|
+
One mounted Tileflow `Map` owns one current source generation, one private Hosted binding/session/controller when required, one native admission context and one upstream MapLibre Map. Multiple mounted Maps may share only the acknowledged process-level networking installation; they never share contexts, sessions, grants, generations, abort state, callbacks or renderer ownership.
|
|
48
|
+
|
|
49
|
+
The upstream native Map is not created until the source is resolved and its private admission context has been acknowledged. Hosted session delivery also requires the application configuration and bootstrap path to be ready. Non-session sources do not read mobile application configuration or acquire Hosted authority.
|
|
50
|
+
|
|
51
|
+
Changing a theme keeps the same native Map, context and session. Replacing the logical source retires the previous owner before the replacement can open another context. Ordinary changes to callback identity, `children`, `style`, `testID` or unchanged semantic props do not recreate the source/session/native owner.
|
|
52
|
+
|
|
53
|
+
Application-owned MapLibre children that are valid children of the upstream Map remain composable. Tileflow still owns the Map style, networking, camera lifecycle callbacks and readiness callbacks.
|
|
54
|
+
|
|
55
|
+
## Source and theme
|
|
56
|
+
|
|
57
|
+
Native sources keep the explicit shape:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
{
|
|
61
|
+
map: 'stores',
|
|
62
|
+
manifestUrl: 'https://maps.example.com/tileflow/manifest.json',
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
There is no implicit native manifest discovery. `theme` accepts one concrete published theme or `system`. A `system` selection subscribes to React Native Appearance only while that selection is mounted. Theme application is transactional: a new concrete style is prepared and applied on the same Map, becomes current only after native readiness, and attempts to restore the previous accepted style if the replacement fails.
|
|
67
|
+
|
|
68
|
+
For the CLI's Native preview, explicitly permit its HTTP origin in the source:
|
|
69
|
+
|
|
70
|
+
```tsx
|
|
71
|
+
<Map
|
|
72
|
+
source={{
|
|
73
|
+
map: 'stores',
|
|
74
|
+
manifestUrl: 'http://127.0.0.1:3333/native/manifest.json',
|
|
75
|
+
developmentOrigin: 'http://127.0.0.1:3333',
|
|
76
|
+
}}
|
|
77
|
+
/>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Use an address reachable from the target device. The exception covers only that exact scheme,
|
|
81
|
+
host and port, including relative styles and resources; other origins still require HTTPS.
|
|
82
|
+
Keep it in development configuration. The application owns any iOS ATS or Android cleartext
|
|
83
|
+
exception. Changing or removing the origin retires the previous source owner. Hosted credentials
|
|
84
|
+
and protected delivery still require HTTPS.
|
|
85
|
+
|
|
86
|
+
`onThemeChange` reports only the safe public selection shape. It does not expose style URLs, style JSON, configuration, session authority or native error messages.
|
|
87
|
+
|
|
88
|
+
## Camera
|
|
89
|
+
|
|
90
|
+
The existing mount-stable camera contract is preserved. Use either an initial camera seed or complete controlled camera ownership for the lifetime of one mounted Map:
|
|
91
|
+
|
|
92
|
+
```tsx
|
|
93
|
+
<Map source={source} initialView={{center: [-9.14, 38.72], zoom: 11}} />
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
or:
|
|
97
|
+
|
|
98
|
+
```tsx
|
|
99
|
+
<Map
|
|
100
|
+
source={source}
|
|
101
|
+
view={{center, zoom, bearing, pitch}}
|
|
102
|
+
onViewChange={(event) => setView(event.view)}
|
|
103
|
+
/>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Controlled mode requires a complete view and `onViewChange`. Programmatic camera operations are private. Native command receipts are correlated internally and are not inferred from delayed region callbacks. Gesture changes are the only camera changes emitted publicly; their controlled settlement occurs after the subsequent React commit. Theme/style restoration preserves the current camera owner.
|
|
107
|
+
|
|
108
|
+
No public imperative camera API or raw upstream Map ref is exported.
|
|
109
|
+
|
|
110
|
+
## Readiness
|
|
111
|
+
|
|
112
|
+
`onLoad` means that the current native style was accepted for the mounted generation. It is not a rendered-frame receipt.
|
|
113
|
+
|
|
114
|
+
`onReadinessChange({status: 'ready'})` requires all of the following for the current generation:
|
|
115
|
+
|
|
116
|
+
- current native style acceptance;
|
|
117
|
+
- a committed positive native layout/view barrier;
|
|
118
|
+
- a fully rendered native map/frame after that barrier;
|
|
119
|
+
- foreground lifecycle state.
|
|
120
|
+
|
|
121
|
+
Readiness is invalidated by source/style transactions, layout changes, camera gesture/command epochs and backgrounding. Resume requires fresh native evidence; JavaScript animation frames, sleeps and arbitrary timers are not readiness proof.
|
|
122
|
+
|
|
123
|
+
## Bounded resource preparation
|
|
124
|
+
|
|
125
|
+
Before native rendering, the mounted owner prepares a bounded closure for the current style. It covers the owned style document, one TileJSON edge per tile source, tile templates, sprite leaves, glyph templates, native-v1 managed font faces and supported base-asset URLs represented by the accepted contracts.
|
|
126
|
+
|
|
127
|
+
Relative URLs are resolved with Core's native URL policy. Protected resources are appended to the per-Map admission catalog only after native acknowledgement. Resource scope and tileset identity are preserved. Foreign/unowned resources are left unchanged and never receive Tileflow authority. Recursive, ambiguous, oversized or dynamically unbounded protected graphs fail closed.
|
|
128
|
+
|
|
129
|
+
Glyph templates use a finite set of exact font-stack expansions. A later theme may extend that same template only through an acknowledged append-only finite union; it cannot change the resource scope, origin or identity.
|
|
130
|
+
|
|
131
|
+
See [Private native resource admission](./docs/native-admission.md) for the transport and security boundaries.
|
|
132
|
+
|
|
133
|
+
## Hosted mobile application configuration
|
|
134
|
+
|
|
135
|
+
Hosted native session delivery uses a scoped, revocable, publishable **mobile application credential** configured once in the native application. It authenticates the distributed application, not its end user. The manifest supplies `mapId`, `apiUrl` and `usageMode`; Tileflow requires the manifest API origin to exactly match the configured canonical HTTPS origin before bootstrap.
|
|
136
|
+
|
|
137
|
+
There is intentionally no credential prop, JS setter, Provider, client object, package-name/bundle-ID authentication or attestation path in this version.
|
|
138
|
+
|
|
139
|
+
See [Native application configuration](./docs/native-configuration.md) for the exact Android resource and iOS Info.plist keys, credential grammar, origin normalization, rotation/revocation guidance and threat model.
|
|
140
|
+
|
|
141
|
+
## Map options and presentation
|
|
142
|
+
|
|
143
|
+
The public `mapOptions` object remains a positive allowlist. It includes the currently supported gesture/control options only. Style inputs, request transformation, headers and upstream lifecycle callbacks remain owned by Tileflow. Runtime validation rejects forged extra keys without reflecting their values.
|
|
144
|
+
|
|
145
|
+
React Native `style`, `testID` and React children are preserved as presentation/composition inputs and do not become semantic source identity.
|
|
146
|
+
|
|
147
|
+
## Safe events and ref
|
|
148
|
+
|
|
149
|
+
The public lifecycle surface remains:
|
|
150
|
+
|
|
151
|
+
- `onLoad`;
|
|
152
|
+
- `onError`;
|
|
153
|
+
- `onReadinessChange`;
|
|
154
|
+
- `onThemeChange`;
|
|
155
|
+
- controlled `onViewChange`;
|
|
156
|
+
- `ref.getSourceState()`.
|
|
157
|
+
|
|
158
|
+
`getSourceState()` returns only the last safe projected source snapshot. Errors use existing bounded codes/fields/kinds. No URL, response body, native event, credential, grant, session object, native handle or raw cause enters public callbacks, refs or serializable state.
|
|
159
|
+
|
|
160
|
+
## Native integration
|
|
161
|
+
|
|
162
|
+
Autolinking registers the private Android/iOS modules used by the mounted lifecycle. Android depends on the exact MapLibre React Native project and MapLibre Native 13.2.0. iOS uses the existing MapLibre React Native CocoaPod plus the exact MapLibre Native 6.26.0 Swift Package Manager product.
|
|
163
|
+
|
|
164
|
+
For the iOS post-install integration, retain the host's normal React Native/MapLibre setup and then call:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
$MLRN.post_install(installer)
|
|
168
|
+
tileflow_native_admission_post_install(installer)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Do not add a second MapLibre Native CocoaPod. See [Private native resource admission](./docs/native-admission.md) for installation-order and teardown details.
|
|
172
|
+
|
|
173
|
+
## Source-checkout harness
|
|
174
|
+
|
|
175
|
+
`harness/AdmissionHarness.tsx` mounts the real public Tileflow `Map` in an existing development host, including two simultaneous Maps under React `StrictMode`. It provides deterministic source-checkout controls for source replacement, theme transactions, controlled camera updates and teardown. The harness is excluded from exports and packed files and creates no service or runner. See [the harness guide](./harness/README.md).
|
|
176
|
+
|
|
177
|
+
## Interactions and markers
|
|
178
|
+
|
|
179
|
+
`Map` accepts portable `annotations`, semantic `interactions`, activation and diagnostic callbacks,
|
|
180
|
+
and an optional marker renderer. Annotation data stays typed through a generic `Map`:
|
|
181
|
+
|
|
182
|
+
```tsx
|
|
183
|
+
<Map
|
|
184
|
+
source={source}
|
|
185
|
+
annotations={places}
|
|
186
|
+
interactions={bindings}
|
|
187
|
+
onInteractionEvent={(event) => {
|
|
188
|
+
selectPlace(event.target);
|
|
189
|
+
}}
|
|
190
|
+
renderMarker={({annotation}) => <PlaceMarker place={annotation.data} />}
|
|
191
|
+
/>
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Tileflow owns each stable native marker host, its coordinate, touch activation and accessibility
|
|
195
|
+
wrapper. A bounded accessible marker is rendered when `renderMarker` is omitted. Large feature sets
|
|
196
|
+
remain in native vector or GeoJSON layers and are selected through semantic rendered-feature
|
|
197
|
+
queries rather than thousands of React marker views.
|
|
198
|
+
|
|
199
|
+
The application owns persistent selection and its presentation. The SDK emits normalized
|
|
200
|
+
annotation or POI targets but no popup, callout, tooltip, sheet, panel or modal renderer. See
|
|
201
|
+
[Native interaction contract](./docs/native-interactions.md).
|
|
202
|
+
|
|
203
|
+
## Current boundary
|
|
204
|
+
|
|
205
|
+
The mounted component does not add location, selection-presentation UI, offline product APIs, an
|
|
206
|
+
Expo config plugin, CLI behavior, deployment or publication. The package remains pre-release.
|
|
207
|
+
|
|
208
|
+
Source and tests describe contracts; they do not establish that a native build, simulator/device run, Hosted service, app-store submission or package publication succeeded. Qualify the exact source revision in the intended host before relying on it.
|
|
209
|
+
|
|
210
|
+
## Foreground location is application-owned
|
|
211
|
+
|
|
212
|
+
`Map` does not request location permission, start a provider, track the device or recenter when a
|
|
213
|
+
fix arrives. Applications can compose a validated foreground fix through the existing accessible
|
|
214
|
+
annotation and controlled-camera contracts while keeping permission, provider lifetime, privacy
|
|
215
|
+
policy, status UI and recenter controls outside Tileflow.
|
|
216
|
+
|
|
217
|
+
See [Application-owned foreground location](./docs/native-location.md) for the package-owned
|
|
218
|
+
source-checkout recipe. The recipe is excluded from package exports and packed runtime files and
|
|
219
|
+
imports no concrete location provider.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require_relative 'ios/tileflow_native_admission'
|
|
3
|
+
|
|
4
|
+
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
|
|
5
|
+
|
|
6
|
+
Pod::Spec.new do |s|
|
|
7
|
+
s.name = 'TileflowNativeAdmission'
|
|
8
|
+
s.version = package['version'].delete_suffix('-development')
|
|
9
|
+
s.summary = 'Private mounted Map transport and lifecycle adapters for Tileflow.'
|
|
10
|
+
s.homepage = package['homepage']
|
|
11
|
+
s.authors = 'Tileflow'
|
|
12
|
+
s.license = { :type => 'Apache-2.0', :file => File.exist?(File.join(__dir__, 'LICENSE')) ? 'LICENSE' : '../../LICENSE' }
|
|
13
|
+
# This private development pod is consumed by local path/autolinking.
|
|
14
|
+
s.source = { :git => 'https://github.com/tileflow/tileflow-sdk.git' }
|
|
15
|
+
s.platforms = { :ios => '15.1' }
|
|
16
|
+
s.source_files = 'ios/*.{h,mm}'
|
|
17
|
+
s.private_header_files = 'ios/*.h'
|
|
18
|
+
s.frameworks = 'Foundation', 'UIKit'
|
|
19
|
+
s.libraries = 'c++'
|
|
20
|
+
s.dependency 'React-Core', '0.83.10'
|
|
21
|
+
s.dependency 'MapLibreReactNative', '11.3.10'
|
|
22
|
+
# Surface integration consumes only the pinned peer's private native view/camera headers.
|
|
23
|
+
# No Tileflow native header is promoted to a public CocoaPods header.
|
|
24
|
+
s.pod_target_xcconfig = {
|
|
25
|
+
'HEADER_SEARCH_PATHS' => '$(inherited) "${PODS_ROOT}/Headers/Private" "${PODS_ROOT}/Headers/Private/MapLibreReactNative"'
|
|
26
|
+
}
|
|
27
|
+
# MapLibre itself is an exact SPM product, not a second CocoaPods SDK.
|
|
28
|
+
install_modules_dependencies(s)
|
|
29
|
+
|
|
30
|
+
s.test_spec 'Admission' do |tests|
|
|
31
|
+
tests.source_files = 'ios/Tests/*.mm'
|
|
32
|
+
tests.requires_app_host = true
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
apply plugin: 'com.android.library'
|
|
2
|
+
|
|
3
|
+
// The host React Native project owns AGP and Kotlin plugin versions.
|
|
4
|
+
def agpMajor = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.')[0].toInteger()
|
|
5
|
+
if (agpMajor < 9 || (agpMajor == 9 && !providers.gradleProperty('android.builtInKotlin').getOrElse('true').toBoolean())) {
|
|
6
|
+
apply plugin: 'org.jetbrains.kotlin.android'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
def nativeVersion = rootProject.ext.has('org.maplibre.reactnative.nativeVersion') ? rootProject.ext.get('org.maplibre.reactnative.nativeVersion').toString() : '13.2.0'
|
|
10
|
+
def nativeVariant = rootProject.ext.has('org.maplibre.reactnative.nativeVariant') ? rootProject.ext.get('org.maplibre.reactnative.nativeVariant').toString() : 'opengl'
|
|
11
|
+
if (nativeVersion != '13.2.0') throw new GradleException('Tileflow native admission requires MapLibre Native 13.2.0')
|
|
12
|
+
|
|
13
|
+
android {
|
|
14
|
+
namespace 'dev.tileflow.reactnative'
|
|
15
|
+
compileSdkVersion rootProject.ext.has('compileSdkVersion') ? rootProject.ext.get('compileSdkVersion') : 36
|
|
16
|
+
defaultConfig {
|
|
17
|
+
minSdkVersion rootProject.ext.has('minSdkVersion') ? rootProject.ext.get('minSdkVersion') : 24
|
|
18
|
+
targetSdkVersion rootProject.ext.has('targetSdkVersion') ? rootProject.ext.get('targetSdkVersion') : 36
|
|
19
|
+
}
|
|
20
|
+
compileOptions {
|
|
21
|
+
sourceCompatibility JavaVersion.VERSION_17
|
|
22
|
+
targetCompatibility JavaVersion.VERSION_17
|
|
23
|
+
}
|
|
24
|
+
if (project.plugins.hasPlugin('org.jetbrains.kotlin.android')) {
|
|
25
|
+
kotlinOptions { jvmTarget = '17' }
|
|
26
|
+
}
|
|
27
|
+
testOptions { unitTests.includeAndroidResources = true }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
repositories {
|
|
31
|
+
google()
|
|
32
|
+
mavenCentral()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
dependencies {
|
|
36
|
+
implementation project(':maplibre_maplibre-react-native')
|
|
37
|
+
implementation('com.facebook.react:react-android:0.83.10') { version { strictly '0.83.10' } }
|
|
38
|
+
implementation("org.maplibre.gl:android-sdk-${nativeVariant}:13.2.0") { version { strictly '13.2.0' } }
|
|
39
|
+
implementation('com.squareup.okhttp3:okhttp:4.12.0') { version { strictly '4.12.0' } }
|
|
40
|
+
testImplementation 'junit:junit:4.13.2'
|
|
41
|
+
testImplementation 'org.robolectric:robolectric:4.14.1'
|
|
42
|
+
testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'
|
|
43
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
package dev.tileflow.reactnative
|
|
2
|
+
|
|
3
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
4
|
+
|
|
5
|
+
internal object AdmissionBootstrapLimits {
|
|
6
|
+
const val QUEUE = 32
|
|
7
|
+
const val REQUEST_BYTES = 2048
|
|
8
|
+
const val RESPONSE_BYTES = 65536
|
|
9
|
+
const val TIMEOUT_MS = 30000L
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
internal class AdmissionBootstrapRequest(
|
|
13
|
+
val url: String,
|
|
14
|
+
val credential: String,
|
|
15
|
+
val body: String,
|
|
16
|
+
val mayStart: () -> Boolean,
|
|
17
|
+
) {
|
|
18
|
+
override fun toString() = "AdmissionBootstrapRequest(redacted)"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
internal class AdmissionBootstrapReply(val status: Int, val cacheControl: String, val body: ByteArray) {
|
|
22
|
+
override fun toString() = "AdmissionBootstrapReply(redacted)"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
internal interface AdmissionBootstrapNetwork {
|
|
26
|
+
fun start(request: AdmissionBootstrapRequest, completion: (AdmissionBootstrapReply?) -> Unit): AdmissionCancellation
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The serial owner only bounds transport work. It never parses a grant,
|
|
30
|
+
// admits resources, counts commercial requests, or creates session IDs.
|
|
31
|
+
internal class AdmissionBootstrap(
|
|
32
|
+
private val installation: String,
|
|
33
|
+
private val scheduler: AdmissionScheduler,
|
|
34
|
+
private val network: AdmissionBootstrapNetwork,
|
|
35
|
+
private val owns: () -> Boolean,
|
|
36
|
+
) {
|
|
37
|
+
private class Context(val mapId: String?) {
|
|
38
|
+
val live = AtomicBoolean(true)
|
|
39
|
+
var endpoint: String? = null
|
|
40
|
+
var credential: String? = null
|
|
41
|
+
var lastRequest = 0L
|
|
42
|
+
val pending = linkedMapOf<String, Work>()
|
|
43
|
+
}
|
|
44
|
+
private class Work(
|
|
45
|
+
val id: String,
|
|
46
|
+
val context: Context,
|
|
47
|
+
val deadline: Long,
|
|
48
|
+
val completion: (AdmissionBootstrapReply?) -> Unit,
|
|
49
|
+
) {
|
|
50
|
+
val live = AtomicBoolean(true)
|
|
51
|
+
var network: AdmissionCancellation? = null
|
|
52
|
+
var timer: AdmissionCancellation? = null
|
|
53
|
+
}
|
|
54
|
+
private val contexts = linkedMapOf<String, Context>()
|
|
55
|
+
private val active = AtomicBoolean(true)
|
|
56
|
+
@Volatile private var foreground = true
|
|
57
|
+
private var pendingCount = 0
|
|
58
|
+
private val mobileCredential = Regex("tf_public_[0-9a-f]{48}")
|
|
59
|
+
private val bodyPattern = Regex("\\{\"mapId\":\"(map_[A-Za-z0-9_-]{16})\",\"sessionId\":\"([A-Za-z0-9._:-]{1,255})\",\"surfaceId\":\"([a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?)\"\\}")
|
|
60
|
+
|
|
61
|
+
override fun toString() = "AdmissionBootstrap(redacted)"
|
|
62
|
+
|
|
63
|
+
fun register(context: String, mapId: String?) {
|
|
64
|
+
if (!active.get() || !owned() || !validContext(context) || contexts.containsKey(context) || contexts.size >= AdmissionLimits.CONTEXTS ||
|
|
65
|
+
(mapId != null && !Regex("map_[A-Za-z0-9_-]{16}").matches(mapId))) invalid()
|
|
66
|
+
contexts[context] = Context(mapId)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
fun start(contextId: String, requestId: String, url: String, credential: String, body: String, completion: (AdmissionBootstrapReply?) -> Unit) {
|
|
70
|
+
val context = contexts[contextId] ?: invalid()
|
|
71
|
+
if (!active.get() || !foreground || !owned() || !context.live.get() || context.mapId == null || pendingCount >= AdmissionBootstrapLimits.QUEUE) invalid()
|
|
72
|
+
val sequence = sequence(requestId)
|
|
73
|
+
if (sequence <= context.lastRequest || !mobileCredential.matches(credential) || body.length > AdmissionBootstrapLimits.REQUEST_BYTES) invalid()
|
|
74
|
+
val match = bodyPattern.matchEntire(body) ?: invalid()
|
|
75
|
+
if (match.groupValues[1] != context.mapId || match.groupValues.drop(2).any { it.startsWith("tf_native_") || mobileCredential.matches(it) }) invalid()
|
|
76
|
+
AdmissionUrl.clean(url)
|
|
77
|
+
if (url != AdmissionUrl.origin(url) + "/v1/sessions/start" ||
|
|
78
|
+
(context.endpoint != null && (context.endpoint != url || context.credential != credential))) invalid()
|
|
79
|
+
// The validated controller is the source of the first endpoint/credential.
|
|
80
|
+
// Bind subsequent bootstrap calls to that exact pair for this context.
|
|
81
|
+
context.endpoint = url; context.credential = credential; context.lastRequest = sequence
|
|
82
|
+
val work = Work(requestId, context, scheduler.nowMs() + AdmissionBootstrapLimits.TIMEOUT_MS, completion)
|
|
83
|
+
context.pending[requestId] = work; pendingCount++
|
|
84
|
+
work.timer = scheduler.after(AdmissionBootstrapLimits.TIMEOUT_MS) { finish(work, null) }
|
|
85
|
+
try {
|
|
86
|
+
work.network = network.start(AdmissionBootstrapRequest(url, credential, body) { mayStart(work) }) { reply ->
|
|
87
|
+
scheduler.dispatch {
|
|
88
|
+
val valid = mayStart(work) && reply != null && reply.status in 100..599 &&
|
|
89
|
+
reply.cacheControl.length <= 1024 && reply.body.size <= AdmissionBootstrapLimits.RESPONSE_BYTES
|
|
90
|
+
finish(work, if (valid) reply else null)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (!work.live.get()) work.network?.cancel()
|
|
94
|
+
} catch (_: Exception) { finish(work, null) }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fun cancel(contextId: String, requestId: String) {
|
|
98
|
+
if (!validContext(contextId)) invalid()
|
|
99
|
+
val sequence = sequence(requestId)
|
|
100
|
+
val context = contexts[contextId] ?: return
|
|
101
|
+
// A cancellation can overtake dispatch. A per-context high-water mark
|
|
102
|
+
// prevents replay without retaining an unbounded tombstone collection.
|
|
103
|
+
context.lastRequest = maxOf(context.lastRequest, sequence)
|
|
104
|
+
context.pending[requestId]?.let { finish(it, null) }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
fun retire(contextId: String) {
|
|
108
|
+
val context = contexts.remove(contextId) ?: return
|
|
109
|
+
context.live.set(false)
|
|
110
|
+
context.credential = null; context.endpoint = null
|
|
111
|
+
for (work in context.pending.values.toList()) finish(work, null)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
fun lifecycle(resumed: Boolean) {
|
|
115
|
+
foreground = resumed
|
|
116
|
+
if (!resumed) for (context in contexts.values.toList()) for (work in context.pending.values.toList()) finish(work, null)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
fun close() {
|
|
120
|
+
active.set(false)
|
|
121
|
+
for (context in contexts.keys.toList()) retire(context)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private fun mayStart(work: Work): Boolean = active.get() && foreground && work.live.get() && work.context.live.get() &&
|
|
125
|
+
scheduler.nowMs() < work.deadline && owned()
|
|
126
|
+
|
|
127
|
+
private fun finish(work: Work, reply: AdmissionBootstrapReply?) {
|
|
128
|
+
if (!work.live.compareAndSet(true, false)) return
|
|
129
|
+
work.context.pending.remove(work.id); pendingCount--
|
|
130
|
+
work.timer?.cancel(); work.timer = null
|
|
131
|
+
work.network?.cancel(); work.network = null
|
|
132
|
+
try { work.completion(reply) } catch (_: Exception) { /* A consumer cannot reopen retired transport work. */ }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private fun validContext(value: String) = AdmissionUrl.validToken(value) && value.startsWith("$installation.")
|
|
136
|
+
private fun sequence(value: String): Long {
|
|
137
|
+
if (!value.startsWith("$installation.")) invalid()
|
|
138
|
+
val suffix = value.substring(installation.length + 1)
|
|
139
|
+
if (!Regex("[1-9][0-9]{0,15}").matches(suffix)) invalid()
|
|
140
|
+
return suffix.toLongOrNull()?.takeIf { it <= 9007199254740991L } ?: invalid()
|
|
141
|
+
}
|
|
142
|
+
private fun owned() = try { owns() } catch (_: Exception) { false }
|
|
143
|
+
private fun invalid(): Nothing = throw IllegalArgumentException("Invalid native bootstrap")
|
|
144
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
package dev.tileflow.reactnative
|
|
2
|
+
|
|
3
|
+
import java.io.ByteArrayOutputStream
|
|
4
|
+
import java.io.IOException
|
|
5
|
+
import java.util.concurrent.TimeUnit
|
|
6
|
+
import okhttp3.Call
|
|
7
|
+
import okhttp3.Callback
|
|
8
|
+
import okhttp3.Dispatcher
|
|
9
|
+
import okhttp3.MediaType.Companion.toMediaType
|
|
10
|
+
import okhttp3.OkHttpClient
|
|
11
|
+
import okhttp3.Request
|
|
12
|
+
import okhttp3.RequestBody.Companion.toRequestBody
|
|
13
|
+
import okhttp3.Response
|
|
14
|
+
|
|
15
|
+
internal class AdmissionBootstrapOkHttpNetwork : AdmissionBootstrapNetwork {
|
|
16
|
+
private class Guard(val mayStart: () -> Boolean) {
|
|
17
|
+
override fun toString() = "NativeBootstrapGuard(redacted)"
|
|
18
|
+
}
|
|
19
|
+
private val lock = Any()
|
|
20
|
+
private val calls = mutableSetOf<Call>()
|
|
21
|
+
private var closed = false
|
|
22
|
+
private val dispatcher = Dispatcher().apply { maxRequests = 4; maxRequestsPerHost = 4 }
|
|
23
|
+
// Deliberately independent of both MapLibre and protected-resource HTTP.
|
|
24
|
+
// Defaults use no cookie jar, cache, authenticator, or application logger.
|
|
25
|
+
private val client = OkHttpClient.Builder()
|
|
26
|
+
.dispatcher(dispatcher)
|
|
27
|
+
.followRedirects(false).followSslRedirects(false)
|
|
28
|
+
.retryOnConnectionFailure(false).cache(null)
|
|
29
|
+
.connectTimeout(10, TimeUnit.SECONDS)
|
|
30
|
+
.callTimeout(AdmissionBootstrapLimits.TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
|
31
|
+
.addNetworkInterceptor { chain ->
|
|
32
|
+
if (chain.request().tag(Guard::class.java)?.mayStart?.invoke() != true) throw IOException("Native bootstrap is cancelled")
|
|
33
|
+
chain.proceed(chain.request())
|
|
34
|
+
}.build()
|
|
35
|
+
|
|
36
|
+
override fun start(request: AdmissionBootstrapRequest, completion: (AdmissionBootstrapReply?) -> Unit): AdmissionCancellation {
|
|
37
|
+
if (!request.mayStart()) { completion(null); return AdmissionCancellation {} }
|
|
38
|
+
val call = client.newCall(Request.Builder().url(request.url)
|
|
39
|
+
.post(request.body.toRequestBody("application/json".toMediaType()))
|
|
40
|
+
.header("X-Tileflow-Mobile-Client", request.credential)
|
|
41
|
+
.header("Cache-Control", "no-store")
|
|
42
|
+
.tag(Guard::class.java, Guard(request.mayStart)).build())
|
|
43
|
+
val accepted = synchronized(lock) {
|
|
44
|
+
if (closed || calls.size >= AdmissionBootstrapLimits.QUEUE) false else { calls.add(call); true }
|
|
45
|
+
}
|
|
46
|
+
if (!accepted) { completion(null); return AdmissionCancellation {} }
|
|
47
|
+
fun complete(reply: AdmissionBootstrapReply?) {
|
|
48
|
+
synchronized(lock) { calls.remove(call) }
|
|
49
|
+
completion(reply)
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
call.enqueue(object : Callback {
|
|
53
|
+
override fun onFailure(call: Call, error: IOException) { complete(null) }
|
|
54
|
+
override fun onResponse(call: Call, response: Response) {
|
|
55
|
+
val reply = try {
|
|
56
|
+
response.use {
|
|
57
|
+
val cacheControl = it.header("cache-control") ?: ""
|
|
58
|
+
val body = it.body
|
|
59
|
+
if (cacheControl.length > 1024 || body == null || body.contentLength() > AdmissionBootstrapLimits.RESPONSE_BYTES) throw IOException("Invalid bootstrap response")
|
|
60
|
+
val bytes = ByteArrayOutputStream()
|
|
61
|
+
body.byteStream().use { stream ->
|
|
62
|
+
val buffer = ByteArray(4096)
|
|
63
|
+
while (true) {
|
|
64
|
+
val count = stream.read(buffer)
|
|
65
|
+
if (count < 0) break
|
|
66
|
+
if (!request.mayStart() || count > AdmissionBootstrapLimits.RESPONSE_BYTES - bytes.size()) throw IOException("Invalid bootstrap response")
|
|
67
|
+
bytes.write(buffer, 0, count)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
AdmissionBootstrapReply(it.code, cacheControl, bytes.toByteArray())
|
|
71
|
+
}
|
|
72
|
+
} catch (_: Exception) { null }
|
|
73
|
+
complete(reply)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
} catch (_: Exception) { call.cancel(); complete(null) }
|
|
77
|
+
// Retain the capacity reservation until OkHttp's terminal callback.
|
|
78
|
+
// Repeated cancellation cannot grow its dispatcher queue without bound.
|
|
79
|
+
return AdmissionCancellation { call.cancel() }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
fun close() {
|
|
83
|
+
val pending = synchronized(lock) { closed = true; calls.toList() }
|
|
84
|
+
for (call in pending) call.cancel()
|
|
85
|
+
client.connectionPool.evictAll()
|
|
86
|
+
dispatcher.executorService.shutdown()
|
|
87
|
+
}
|
|
88
|
+
override fun toString() = "AdmissionBootstrapOkHttpNetwork(redacted)"
|
|
89
|
+
}
|