also looking at this
feat(maps): add key-free native Android maps and shared map controls
#363
28 files
+1857
-481
| @@ -76,7 +76,7 @@ on: | ||
| 76 | 76 | android_suite: |
| 77 | 77 | description: Android native fixture coverage for the android-navigation scope |
| 78 | 78 | type: choice |
| 79 | options: [focused, full, system-bars] | |
| 79 | options: [focused, full, system-bars, maps, accessibility] | |
| 80 | 80 | default: focused |
| 81 | 81 | selectors: |
| 82 | 82 | description: Optional XCTest selector for focused native iOS rendering or navigation |
| @@ -618,7 +618,7 @@ jobs: | ||
| 618 | 618 | api-level: ${{ fromJSON((inputs.scope != 'android-navigation' || inputs.android_suite == 'full') && '[34,35]' || inputs.android_suite == 'system-bars' && '[35]' || '[34]') }} |
| 619 | 619 | env: |
| 620 | 620 | CRAFT_ANDROID_NATIVE_SUITE: ${{ inputs.scope != 'android-navigation' && 'full' || inputs.android_suite || 'focused' }} |
| 621 | CRAFT_ANDROID_TEST_CLASS: ${{ inputs.scope == 'android-navigation' && inputs.android_suite == 'system-bars' && 'dev.craft.navigationtest.NativeNavigationTest#nativeScreenBackgroundOptionAppliesAndResets' || inputs.scope == 'android-navigation' && inputs.android_suite != 'full' && 'dev.craft.navigationtest.NativeNavigationTest#outgoingTabTapWinsOverSettlingSwipe,dev.craft.navigationtest.NativeNavigationTest#thirdTabTapWinsOverSettlingSwipe,dev.craft.navigationtest.NativeNavigationTest#cancelledSwipeTargetIsVisibleWhenSelectedLater,dev.craft.navigationtest.NativeNavigationTest#completedReverseSwipeKeepsRetainedTabVisible,dev.craft.navigationtest.NativeNavigationTest#nativePullToRefreshSurvivesViewReattachment,dev.craft.navigationtest.NativeNavigationTest#pushedScreenTabAccessoryOpensNativeRouteAndClearsOnBack,dev.craft.navigationtest.NativeFlatListTest#refreshAppearanceUsesTheScreenContrastFallback,dev.craft.navigationtest.NativeNavigationTest#nativeDatePickerKeepsFollowingControlsMounted' || '' }} | |
| 621 | CRAFT_ANDROID_TEST_CLASS: ${{ inputs.scope == 'android-navigation' && inputs.android_suite == 'accessibility' && 'dev.craft.navigationtest.NativeNavigationTest#nativeTestIdsRemainQueryableWithoutReplacingVisibleLabels' || inputs.scope == 'android-navigation' && inputs.android_suite == 'maps' && 'dev.craft.navigationtest.NativeMapTest#keyFreeBasemapsKeepTheRecordedRouteAndDirectionSignsVisible,dev.craft.navigationtest.NativeMapTest#nativeMapMutationsUpdateThePinAndCameraWithoutReloadingTheStyle,dev.craft.navigationtest.NativeMapTest#showingTheLocationDotDoesNotRequestPermissionDuringRendering,dev.craft.navigationtest.NativeMapTest#controlledRegionEchoPreservesTheCameraAfterANativeDrag,dev.craft.navigationtest.NativeMapTest#backgroundAndResumeKeepTheNativeMapAndCamera' || inputs.scope == 'android-navigation' && inputs.android_suite == 'system-bars' && 'dev.craft.navigationtest.NativeNavigationTest#nativeScreenBackgroundOptionAppliesAndResets' || inputs.scope == 'android-navigation' && inputs.android_suite != 'full' && 'dev.craft.navigationtest.NativeNavigationTest#outgoingTabTapWinsOverSettlingSwipe,dev.craft.navigationtest.NativeNavigationTest#thirdTabTapWinsOverSettlingSwipe,dev.craft.navigationtest.NativeNavigationTest#cancelledSwipeTargetIsVisibleWhenSelectedLater,dev.craft.navigationtest.NativeNavigationTest#completedReverseSwipeKeepsRetainedTabVisible,dev.craft.navigationtest.NativeNavigationTest#nativePullToRefreshSurvivesViewReattachment,dev.craft.navigationtest.NativeNavigationTest#pushedScreenTabAccessoryOpensNativeRouteAndClearsOnBack,dev.craft.navigationtest.NativeFlatListTest#refreshAppearanceUsesTheScreenContrastFallback,dev.craft.navigationtest.NativeNavigationTest#nativeDatePickerKeepsFollowingControlsMounted' || '' }} | |
| 622 | 622 | if: ${{ inputs.scope != 'ios-navigation' && inputs.scope != 'ios-render' }} |
| 623 | 623 | runs-on: ubuntu-latest |
| 624 | 624 | timeout-minutes: 35 |
| @@ -18,7 +18,7 @@ Do not run the hook again after it has written the section. | ||
| 18 | 18 | The tag starts [Releaser](../.github/workflows/release.yml); pushing a tag is |
| 19 | 19 | the publication trigger, not proof that the release finished. |
| 20 | 20 | |
| 21 | Both release and CI setup pin Pantry CLI 0.11.64 as well as the Pantry action | |
| 21 | Both release and CI setup pin Pantry CLI 0.11.93 as well as the Pantry action | |
| 22 | 22 | revision; leaving the CLI at `latest` would add a GitHub API lookup before the |
| 23 | 23 | build can even start. |
| 24 | 24 | |
| @@ -38,7 +38,9 @@ publishes the GitHub release. Only then does a separate job publish the Zig | ||
| 38 | 38 | package to pantry and notify the registry to index the complete release. |
| 39 | 39 | Discord is notified only after the complete release is public, never from an |
| 40 | 40 | individual platform's staging job. |
| 41 | The npm job separately scans and publishes the public JavaScript packages. | |
| 41 | The npm preparation job builds and scans the exact public JavaScript archives. | |
| 42 | The npm publication job waits for the verified GitHub release to become public | |
| 43 | and publishes those scanned archives without rebuilding them. | |
| 42 | 44 | |
| 43 | 45 | Watch the entire Releaser run, not just the release page. Confirm its final |
| 44 | 46 | status is green, the page contains all four `craft-*.zip` archives plus the |
| @@ -54,8 +56,8 @@ publishing the incomplete draft. Do not interpret a successful tag push or a | ||
| 54 | 56 | partial set of staged archives as a completed release. |
| 55 | 57 | |
| 56 | 58 | The GitHub release is gated as a unit; the underlying package registries do not |
| 57 | offer a cross-registry transaction. The Zig package waits for the completed | |
| 58 | GitHub release, but npm publication is deliberately independent and may become | |
| 59 | visible earlier. The final workflow status remains the release signal. If the | |
| 60 | post-release Zig publication fails, the GitHub release still contains the full | |
| 61 | verified archive set; rerun that failed job rather than republishing the draft. | |
| 59 | offer a cross-registry transaction. Both Zig and npm publication wait for the completed GitHub release. Either | |
| 60 | registry publication can still fail after the native archives become public, so | |
| 61 | the final workflow status remains the release signal. If a post-release registry | |
| 62 | publication fails, the GitHub release still contains the full verified archive | |
| 63 | set; inspect and rerun the failed job rather than republishing the draft. | |
| @@ -154,7 +154,7 @@ next route. | ||
| 154 | 154 | | `Image` | `UIImageView` | `ImageView` | |
| 155 | 155 | | `ImageBackground` | `CraftNativeImageBackgroundView` | `CraftNativeImageBackgroundView` | |
| 156 | 156 | | `Video` | `CraftNativeVideoView` (`AVPlayerViewController`) | `CraftNativeVideoView` (`VideoView`) | |
| 157 | | `Map` | `CraftNativeMapView` (`MKMapView`) | `CraftNativeMapView` (`GoogleMap`) | | |
| 157 | | `Map` | `CraftNativeMapView` (`MKMapView`) | `CraftNativeMapView` (native MapLibre or Google Maps) | | |
| 158 | 158 | | `MapPolyline`, `MapMarker` | overlays inside `CraftNativeMapView` | overlays inside `CraftNativeMapView` | |
| 159 | 159 | | `Svg` | `CraftNativeSvgView` | `CraftNativeSvgView` | |
| 160 | 160 | | `G`, `SvgText` | drawn inside `CraftNativeSvgView` | drawn inside `CraftNativeSvgView` | |
| @@ -228,12 +228,21 @@ or `fitToBounds`, which recenters only when its overlay geometry changes. Explic | ||
| 228 | 228 | authored camera bounds on both hosts; only `fitToBounds` adds framing padding. A marker's `onPress` or |
| 229 | 229 | `onMarkerPress` receives `nativeEvent.id`; a marker's own handler wins, and the map-level handler is |
| 230 | 230 | used as a fallback when the marker has no non-empty handler. |
| 231 | It is backed by MapKit on iOS and the Google Maps SDK on | |
| 232 | Android; the Android template adds that dependency only for screens that opt | |
| 233 | into the native map host. The shared `mapType` values are `standard`, | |
| 231 | It is backed by MapKit on iOS and the configured native provider on Android. | |
| 232 | Set the Craft Android config's `mapProvider` to `maplibre` for a key-free native | |
| 233 | GL view; `google` preserves the existing Google Maps host. Only the selected | |
| 234 | provider's SDK is included in the generated Android project. These STX map | |
| 235 | components do not use the DOM-based `@craft-native/ts-maps` adapter. | |
| 236 | The shared `mapType` values are `standard`, | |
| 234 | 237 | `satellite`, and `hybrid`; Android's terrain-only style is intentionally not |
| 235 | part of the cross-platform contract. Tile styling, clustering, directions, and | |
| 236 | offline tiles remain outside this contract. Set `showsUserLocation` to opt into the | |
| 238 | part of the cross-platform contract. `tileOverlay` accepts an HTTPS raster URL | |
| 239 | template and its visible provider attribution. `edgePadding` reserves logical | |
| 240 | points around a fitted route for floating controls. `pitchEnabled` controls | |
| 241 | tilt gestures; `cameraPitch` and `cameraHeading` set the camera explicitly. | |
| 242 | Markers accept `pinColor` for trailheads and inspected profile positions, or | |
| 243 | `bearing` for a contrasting route-direction sign. Clustering, turn-by-turn | |
| 244 | directions, and offline tile downloads remain outside this view contract. | |
| 245 | Set `showsUserLocation` to opt into the | |
| 237 | 246 | platform location dot; the host only enables it when the app's existing |
| 238 | 247 | location permission is already granted, and never prompts from a renderer |
| 239 | 248 | pass. Android also forwards `MapView` start/stop lifecycle callbacks as map |
| @@ -147,18 +147,28 @@ craft android build --native-bundle ../native-screen.js | ||
| 147 | 147 | Native mode maps STX `View`, `SafeAreaView`, `KeyboardAvoidingView`, `StatusBar`, |
| 148 | 148 | `RefreshControl`, `Text`, `Button`, `Link`, `Icon`, |
| 149 | 149 | `Pressable`, `TouchableOpacity`, `TouchableHighlight`, `Checkbox`, `TextInput`, `Picker`, `DatePicker`, `Modal`, `Switch`, `Slider`, `ActivityIndicator`, `Image`, `ImageBackground`, `Video`, `Svg`, `G`, `SvgText`, `ScrollView`, |
| 150 | `Map`, `SectionList`, and `FlatList` to Android views. `Map` uses the Google | |
| 151 | Maps SDK for trail polylines and marker taps; its overlays carry flat | |
| 150 | `Map`, `SectionList`, and `FlatList` to Android views. `Map` uses the configured | |
| 151 | native map provider for trail polylines and marker taps; its overlays carry flat | |
| 152 | 152 | latitude/longitude `geometry` arrays, `strokeWidth` is expressed in logical |
| 153 | 153 | points, and the camera supports controlled `region`, one-time `initialRegion`, |
| 154 | and `fitToBounds`. Set `mapsApiKey` in the Android config to emit the SDK manifest key; | |
| 154 | and `fitToBounds`. Select `mapProvider: 'maplibre'` for the key-free native | |
| 155 | MapLibre renderer. It uses native GL views and needs neither a Google map SDK | |
| 156 | dependency nor a Google map key. `mapProvider: 'google'` retains the existing | |
| 157 | Google Maps host and uses `mapsApiKey` for its SDK manifest key; Google remains | |
| 158 | the default for compatibility with existing projects. | |
| 159 | ||
| 155 | 160 | Coordinates must be JSON numbers (numeric strings are not coerced), are |
| 156 | 161 | range-checked at the native boundary, and invalid points or pole-crossing |
| 157 | 162 | regions are ignored. `fitToBounds` waits for the map viewport to |
| 158 | 163 | be measured before caching a successful camera fit, so an early map callback |
| 159 | 164 | does not lose the initial route framing. |
| 160 | without it, the native map remains an explicit opt-in integration that needs | |
| 161 | the app's own Maps credentials. `SectionList` uses the same | |
| 165 | The shared controls include `showsCompass`, `showsScale`, `pitchEnabled`, | |
| 166 | `cameraPitch`, `cameraHeading`, and floating-control `edgePadding`. Markers | |
| 167 | accept `pinColor` and route-direction `bearing`. The builder's | |
| 168 | `nativeMapProviders` export advertises the providers it actually implements, | |
| 169 | so a build orchestrator can refuse an unsupported explicit choice. | |
| 170 | ||
| 171 | `SectionList` uses the same | |
| 162 | 172 | native scroll host for ordinary `.stx` slot children; grouped section data |
| 163 | 173 | should use `FlatList` until the section-data protocol is available. The |
| 164 | 174 | generated host routes both list names through `CraftNativeScrollView` for this |
| @@ -27,6 +27,14 @@ const keepProjects = process.argv.includes('--keep') | ||
| 27 | 27 | const workspace = createTempDir('craft-android-templates-') |
| 28 | 28 | |
| 29 | 29 | const fixtures: CompileFixture[] = [ |
| 30 | { | |
| 31 | // Compile the key-free engine in both Debug and minified Release, with | |
| 32 | // real MapLibre and location APIs resolved from the published SDK. | |
| 33 | directoryName: 'native-maplibre', | |
| 34 | name: 'CraftNativeMapLibre', | |
| 35 | packageName: 'dev.craft.fixture.maplibre', | |
| 36 | config: { renderer: 'native', mapProvider: 'maplibre', enableGeolocation: true }, | |
| 37 | }, | |
| 30 | 38 | { |
| 31 | 39 | directoryName: 'native', |
| 32 | 40 | name: 'CraftNativeTemplate', |
| @@ -794,6 +794,7 @@ describe('Craft Android builder', () => { | ||
| 794 | 794 | const templates = readdirSync(join(import.meta.dir, '../templates')) |
| 795 | 795 | .filter(name => name.endsWith('.kt.template')) |
| 796 | 796 | const generator = readFileSync(join(import.meta.dir, 'index.ts'), 'utf8') |
| 797 | + readFileSync(join(import.meta.dir, 'native-map.ts'), 'utf8') | |
| 797 | 798 | |
| 798 | 799 | for (const template of templates) expect(generator).toContain(`'${template}'`) |
| 799 | 800 | }) |
| @@ -7,8 +7,12 @@ | ||
| 7 | 7 | import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' |
| 8 | 8 | import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path' |
| 9 | 9 | import { $ } from 'bun' |
| 10 | import { androidMapHost, type AndroidMapProvider } from './native-map.js' | |
| 10 | 11 | import { renderAndroidPromiseRuntime } from './promise-runtime' |
| 11 | 12 | |
| 13 | export type { AndroidMapProvider } from './native-map.js' | |
| 14 | /** Providers this published builder can generate as real Android map views. */ | |
| 15 | export const nativeMapProviders = ['google', 'maplibre'] as const | |
| 12 | 16 | export { runKotlinFixture } from './testing.js' |
| 13 | 17 | export { createAndroidEmulator } from './emulator.js' |
| 14 | 18 | export type { AndroidEmulatorOptions, AndroidEmulatorSession } from './emulator.js' |
| @@ -155,7 +159,9 @@ export interface CraftAndroidConfig { | ||
| 155 | 159 | trustedOrigins?: string[] |
| 156 | 160 | appIconPath?: string |
| 157 | 161 | googleServicesFile?: string |
| 158 | /** Google Maps Android SDK key used by native `Map` screens. */ | |
| 162 | /** Native map renderer. MapLibre needs no SDK key; Google remains the default for compatibility. */ | |
| 163 | mapProvider?: AndroidMapProvider | |
| 164 | /** Google Maps Android SDK key, used only by the Google native map host. */ | |
| 159 | 165 | mapsApiKey?: string |
| 160 | 166 | devServerURL?: string |
| 161 | 167 | hasBundledFallback?: boolean |
| @@ -448,6 +454,7 @@ function normalizeAndroidNetworkConfig(config: CraftAndroidConfig): void { | ||
| 448 | 454 | } |
| 449 | 455 | |
| 450 | 456 | function validateAndroidConfig(config: CraftAndroidConfig): void { |
| 457 | androidMapHost(config.mapProvider) | |
| 451 | 458 | if (!config.appName.trim()) throw new Error('Android app name must not be empty') |
| 452 | 459 | if (config.renderer !== undefined && config.renderer !== 'web' && config.renderer !== 'native') { |
| 453 | 460 | throw new Error(`Unknown Android renderer: ${config.renderer}. Expected web or native.`) |
| @@ -923,6 +930,8 @@ export async function init(options: InitOptions): Promise<void> { | ||
| 923 | 930 | config.renderer === 'native' ? 'MainActivityNative.kt.template' : 'MainActivity.kt.template', |
| 924 | 931 | ), 'utf-8') |
| 925 | 932 | const mainActivity = mainActivityTemplate |
| 933 | .replace(/\{\{NATIVE_MAP_IMPORTS\}\}/g, () => androidMapHost(config.mapProvider).imports) | |
| 934 | .replace(/\{\{NATIVE_MAP_HOST\}\}/g, () => readFileSync(join(TEMPLATES_DIR, androidMapHost(config.mapProvider).template), 'utf8')) | |
| 926 | 935 | .replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName) |
| 927 | 936 | .replace(/\{\{APP_NAME\}\}/g, name) |
| 928 | 937 | |
| @@ -1049,7 +1058,7 @@ export async function init(options: InitOptions): Promise<void> { | ||
| 1049 | 1058 | // Starts the crash reporter before Application.onCreate, for every way |
| 1050 | 1059 | // the process starts (CraftCrashReporter.kt). Inert without a key. |
| 1051 | 1060 | .replace(/\{\{CRASH_REPORTER_PROVIDER\}\}/g, ` <provider android:name=".CraftCrashReporterInit" android:authorities="\${applicationId}.craft-crash" android:exported="false" android:initOrder="1000" />`) |
| 1052 | .replace(/\{\{GOOGLE_MAPS_API_KEY\}\}/g, config.mapsApiKey | |
| 1061 | .replace(/\{\{GOOGLE_MAPS_API_KEY\}\}/g, config.mapProvider !== 'maplibre' && config.mapsApiKey | |
| 1053 | 1062 | ? ` <meta-data android:name="com.google.android.geo.API_KEY" android:value="${escapeXml(config.mapsApiKey)}" />` |
| 1054 | 1063 | : '') |
| 1055 | 1064 | .replace(/\{\{HEALTH_CONNECT_QUERIES\}\}/g, config.enableHealthConnect |
| @@ -1088,8 +1097,8 @@ export async function init(options: InitOptions): Promise<void> { | ||
| 1088 | 1097 | .replace(/\{\{REFRESH_LAYOUT_DEPENDENCY\}\}/g, config.renderer === 'native' |
| 1089 | 1098 | ? ' implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")' |
| 1090 | 1099 | : '') |
| 1091 | .replace(/\{\{GOOGLE_MAPS_DEPENDENCY\}\}/g, config.renderer === 'native' | |
| 1092 | ? ' // Native STX Map overlays and trail routes\n implementation("com.google.android.gms:play-services-maps:18.2.0")' | |
| 1100 | .replace(/\{\{NATIVE_MAP_DEPENDENCY\}\}/g, config.renderer === 'native' | |
| 1101 | ? ` // Native STX Map overlays and trail routes\n implementation("${androidMapHost(config.mapProvider).dependency}")` | |
| 1093 | 1102 | : '') |
| 1094 | 1103 | .replace(/\{\{HEALTH_CONNECT_DEPENDENCIES\}\}/g, config.enableHealthConnect |
| 1095 | 1104 | ? ` implementation("androidx.health.connect:connect-client:1.1.0") |
| @@ -0,0 +1,60 @@ | ||
| 1 | import { afterAll, describe, expect, it } from 'bun:test' | |
| 2 | import { readFileSync } from 'node:fs' | |
| 3 | import { join } from 'node:path' | |
| 4 | import { init } from './index' | |
| 5 | import { createTempDir, removeAllTempDirs } from '../../typescript/src/temp-dir' | |
| 6 | ||
| 7 | afterAll(removeAllTempDirs) | |
| 8 | ||
| 9 | describe('native Android map provider generation', () => { | |
| 10 | it('builds a MapLibre host without a Google map SDK dependency or key', async () => { | |
| 11 | const output = createTempDir('craft-native-maplibre-') | |
| 12 | await init({ name: 'Native maps', packageName: 'com.craft.native', output, runtimeDir: null, config: { renderer: 'native', mapProvider: 'maplibre', enableGeolocation: true } }) | |
| 13 | const source = readFileSync(join(output, 'app/src/main/java/com/craft/native/MainActivity.kt'), 'utf8') | |
| 14 | const gradle = readFileSync(join(output, 'app/build.gradle.kts'), 'utf8') | |
| 15 | const manifest = readFileSync(join(output, 'app/src/main/AndroidManifest.xml'), 'utf8') | |
| 16 | expect(source).toContain('MapLibre.getInstance(context)') | |
| 17 | expect(source).toContain('MapView(context)') | |
| 18 | expect(source).toContain('GeoJsonSource("craft-map-overlays")') | |
| 19 | expect(source).toContain('iconRotationAlignment("map")') | |
| 20 | expect(source).toContain('LocationComponentActivationOptions.builder(context, style)') | |
| 21 | expect(source).not.toContain('com.google.android.gms.maps') | |
| 22 | expect(source).not.toContain('GoogleMap') | |
| 23 | expect(source).not.toContain('{{NATIVE_MAP_') | |
| 24 | expect(gradle).toContain('org.maplibre.gl:android-sdk:13.7.1') | |
| 25 | expect(gradle).not.toContain('play-services-maps') | |
| 26 | expect(manifest).not.toContain('com.google.android.geo.API_KEY') | |
| 27 | expect(JSON.parse(readFileSync(join(output, 'craft.config.json'), 'utf8')).mapProvider).toBe('maplibre') | |
| 28 | // Native STX uses the Android JavaScript engine and real views, not the | |
| 29 | // ts-maps DOM adapter mounted as a substitute for the native map. | |
| 30 | expect(source).toContain('JavaScriptSandbox.isSupported()') | |
| 31 | expect(source).not.toContain('import android.webkit.WebView') | |
| 32 | }) | |
| 33 | ||
| 34 | it('keeps the existing Google host when explicitly selected', async () => { | |
| 35 | const output = createTempDir('craft-native-google-map-') | |
| 36 | await init({ name: 'Google maps', packageName: 'com.craft.native', output, runtimeDir: null, config: { renderer: 'native', mapProvider: 'google', mapsApiKey: 'restricted-fixture-key' } }) | |
| 37 | const source = readFileSync(join(output, 'app/src/main/java/com/craft/native/MainActivity.kt'), 'utf8') | |
| 38 | const gradle = readFileSync(join(output, 'app/build.gradle.kts'), 'utf8') | |
| 39 | const manifest = readFileSync(join(output, 'app/src/main/AndroidManifest.xml'), 'utf8') | |
| 40 | expect(source).toContain('com.google.android.gms.maps.GoogleMap') | |
| 41 | expect(source).not.toContain('org.maplibre.android') | |
| 42 | expect(gradle).toContain('com.google.android.gms:play-services-maps:18.2.0') | |
| 43 | expect(gradle).not.toContain('org.maplibre.gl:android-sdk') | |
| 44 | expect(manifest).toContain('android:name="com.google.android.geo.API_KEY" android:value="restricted-fixture-key"') | |
| 45 | }) | |
| 46 | ||
| 47 | it('does not emit an unused Google map key for a MapLibre app', async () => { | |
| 48 | const output = createTempDir('craft-maplibre-manifest-') | |
| 49 | await init({ name: 'MapLibre', output, runtimeDir: null, config: { renderer: 'native', mapProvider: 'maplibre', mapsApiKey: 'unused-fixture-key' } }) | |
| 50 | const manifest = readFileSync(join(output, 'app/src/main/AndroidManifest.xml'), 'utf8') | |
| 51 | expect(manifest).not.toContain('com.google.android.geo.API_KEY') | |
| 52 | expect(manifest).not.toContain('unused-fixture-key') | |
| 53 | }) | |
| 54 | ||
| 55 | it('rejects an unsupported native map provider before generating a project', async () => { | |
| 56 | const output = createTempDir('craft-unknown-map-provider-') | |
| 57 | // Exercise untyped JSON input, which still needs runtime validation. | |
| 58 | await expect(init({ name: 'Unknown maps', output, runtimeDir: null, config: { renderer: 'native', mapProvider: 'unknown' as never } })).rejects.toThrow('Unknown Android native map provider') | |
| 59 | }) | |
| 60 | }) | |
| @@ -0,0 +1,41 @@ | ||
| 1 | /** Build-time native map selection. Both providers are native Android views. */ | |
| 2 | export type AndroidMapProvider = 'google' | 'maplibre' | |
| 3 | ||
| 4 | export function androidMapHost(provider: AndroidMapProvider = 'google'): { template: string, imports: string, dependency: string } { | |
| 5 | if (provider === 'google') return { | |
| 6 | template: 'CraftNativeMapGoogle.kt.template', | |
| 7 | imports: `import com.google.android.gms.maps.CameraUpdateFactory | |
| 8 | import com.google.android.gms.maps.GoogleMap | |
| 9 | import com.google.android.gms.maps.MapView | |
| 10 | import com.google.android.gms.maps.OnMapReadyCallback | |
| 11 | import com.google.android.gms.maps.model.BitmapDescriptorFactory | |
| 12 | import com.google.android.gms.maps.model.LatLng | |
| 13 | import com.google.android.gms.maps.model.LatLngBounds | |
| 14 | import com.google.android.gms.maps.model.Marker | |
| 15 | import com.google.android.gms.maps.model.MarkerOptions | |
| 16 | import com.google.android.gms.maps.model.Polyline | |
| 17 | import com.google.android.gms.maps.model.PolylineOptions`, | |
| 18 | dependency: 'com.google.android.gms:play-services-maps:18.2.0', | |
| 19 | } | |
| 20 | if (provider === 'maplibre') return { | |
| 21 | template: 'CraftNativeMapLibre.kt.template', | |
| 22 | imports: `import org.maplibre.android.MapLibre | |
| 23 | import org.maplibre.android.camera.CameraPosition | |
| 24 | import org.maplibre.android.camera.CameraUpdateFactory | |
| 25 | import org.maplibre.android.geometry.LatLng | |
| 26 | import org.maplibre.android.geometry.LatLngBounds | |
| 27 | import org.maplibre.android.location.LocationComponentActivationOptions | |
| 28 | import org.maplibre.android.maps.MapLibreMap | |
| 29 | import org.maplibre.android.maps.MapView | |
| 30 | import org.maplibre.android.maps.OnMapReadyCallback | |
| 31 | import org.maplibre.android.maps.Style | |
| 32 | import org.maplibre.android.style.expressions.Expression | |
| 33 | import org.maplibre.android.style.layers.CircleLayer | |
| 34 | import org.maplibre.android.style.layers.LineLayer | |
| 35 | import org.maplibre.android.style.layers.SymbolLayer | |
| 36 | import org.maplibre.android.style.layers.PropertyFactory.* | |
| 37 | import org.maplibre.android.style.sources.GeoJsonSource`, | |
| 38 | dependency: 'org.maplibre.gl:android-sdk:13.7.1', | |
| 39 | } | |
| 40 | throw new Error(`Unknown Android native map provider: ${provider}. Expected google or maplibre.`) | |
| 41 | } | |
| @@ -0,0 +1,440 @@ | ||
| 1 | /** The Android host for the small, host-neutral native trail map contract. */ | |
| 2 | private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMapReadyCallback { | |
| 3 | private val mapView = MapView(context) | |
| 4 | private val scaleView = LinearLayout(context).apply { | |
| 5 | orientation = LinearLayout.VERTICAL | |
| 6 | setPadding(4, 2, 4, 2) | |
| 7 | background = GradientDrawable().apply { setColor(Color.argb(230, 255, 255, 255)); cornerRadius = 4f } | |
| 8 | importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO | |
| 9 | } | |
| 10 | private val scaleLabel = TextView(context).apply { textSize = 11f; setTextColor(Color.rgb(37, 51, 33)) } | |
| 11 | private val scaleLine = View(context).apply { setBackgroundColor(Color.rgb(37, 51, 33)) } | |
| 12 | private var googleMap: GoogleMap? = null | |
| 13 | private var tileOverlay: com.google.android.gms.maps.model.TileOverlay? = null | |
| 14 | private var tileTemplate: String? = null | |
| 15 | private val tileAttribution = TextView(context).apply { textSize = 9f; setTextColor(Color.DKGRAY); setBackgroundColor(Color.argb(220, 255, 255, 255)); maxLines = 2; visibility = View.GONE } | |
| 16 | private var pendingProps = JSONObject() | |
| 17 | private var pendingChildren = JSONArray() | |
| 18 | private val markers = mutableMapOf<String, Marker>() | |
| 19 | private val polylines = mutableMapOf<String, Polyline>() | |
| 20 | private val markerHandlers = mutableMapOf<String, String>() | |
| 21 | private var onMarkerTap: ((String) -> Unit)? = null | |
| 22 | var onMapPress: ((LatLng) -> Unit)? = null | |
| 23 | var onRegionChangeComplete: ((JSONObject) -> Unit)? = null | |
| 24 | private var userChangingCamera = false | |
| 25 | private var markerBearings = mutableMapOf<String, Float>() | |
| 26 | private var markerPinColors = mutableMapOf<String, Int>() | |
| 27 | private val pinIcons = mutableMapOf<Int, com.google.android.gms.maps.model.BitmapDescriptor>() | |
| 28 | private var directionIcon: com.google.android.gms.maps.model.BitmapDescriptor? = null | |
| 29 | private var lifecycleStarted = false | |
| 30 | private var destroyed = false | |
| 31 | private var initialCameraKey: String? = null | |
| 32 | ||
| 33 | init { | |
| 34 | addView(mapView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) | |
| 35 | scaleView.addView(scaleLabel) | |
| 36 | scaleView.addView(scaleLine, LinearLayout.LayoutParams(1, 2)) | |
| 37 | addView(scaleView, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.START).apply { | |
| 38 | leftMargin = (8 * resources.displayMetrics.density).roundToInt() | |
| 39 | bottomMargin = (24 * resources.displayMetrics.density).roundToInt() | |
| 40 | }) | |
| 41 | scaleView.visibility = View.GONE | |
| 42 | addView(tileAttribution, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.END).apply { rightMargin = (8 * resources.displayMetrics.density).roundToInt(); bottomMargin = (8 * resources.displayMetrics.density).roundToInt() }) | |
| 43 | mapView.onCreate(null) | |
| 44 | mapView.getMapAsync(this) | |
| 45 | } | |
| 46 | ||
| 47 | override fun onMapReady(map: GoogleMap) { | |
| 48 | if (destroyed) return | |
| 49 | googleMap = map | |
| 50 | map.setOnMapClickListener { point -> onMapPress?.invoke(point) } | |
| 51 | map.setOnCameraMoveStartedListener { reason -> | |
| 52 | if (reason == GoogleMap.OnCameraMoveStartedListener.REASON_GESTURE) userChangingCamera = true | |
| 53 | } | |
| 54 | map.setOnCameraMoveListener { updateScale() } | |
| 55 | map.setOnCameraIdleListener { | |
| 56 | updateScale() | |
| 57 | if (userChangingCamera) { | |
| 58 | userChangingCamera = false | |
| 59 | val bounds = map.projection.visibleRegion.latLngBounds | |
| 60 | val position = map.cameraPosition | |
| 61 | val longitudeDelta = (bounds.northeast.longitude - bounds.southwest.longitude + 360) % 360 | |
| 62 | onRegionChangeComplete?.invoke(JSONObject() | |
| 63 | .put("center", JSONObject().put("latitude", position.target.latitude).put("longitude", position.target.longitude)) | |
| 64 | .put("span", JSONObject().put("latitudeDelta", bounds.northeast.latitude - bounds.southwest.latitude).put("longitudeDelta", longitudeDelta)) | |
| 65 | .put("heading", position.bearing).put("pitch", position.tilt)) | |
| 66 | } | |
| 67 | } | |
| 68 | map.setOnMarkerClickListener { marker -> | |
| 69 | val id = markers.entries.firstOrNull { it.value == marker }?.key ?: return@setOnMarkerClickListener false | |
| 70 | onMarkerTap?.invoke(id) | |
| 71 | // The native contract dispatches the marker event itself. Consume | |
| 72 | // the click so Google Maps does not also show an unsolicited info | |
| 73 | // window for the marker title. | |
| 74 | true | |
| 75 | } | |
| 76 | applyPending() | |
| 77 | } | |
| 78 | ||
| 79 | fun configure(props: JSONObject, children: JSONArray, markerHandler: (String) -> Unit) { | |
| 80 | pendingProps = props | |
| 81 | pendingChildren = children | |
| 82 | onMarkerTap = markerHandler | |
| 83 | applyPending() | |
| 84 | } | |
| 85 | ||
| 86 | fun markerHandler(id: String): String? = markerHandlers[id] | |
| 87 | ||
| 88 | private fun applyPending() { | |
| 89 | val map = googleMap ?: return | |
| 90 | applyMapPadding() | |
| 91 | map.uiSettings.isCompassEnabled = craftNativeBoolean(pendingProps.opt("showsCompass")) | |
| 92 | // The toolbar is replaced by authored actions; the scale uses the native projection. | |
| 93 | map.uiSettings.isMapToolbarEnabled = false | |
| 94 | map.uiSettings.isScrollGesturesEnabled = craftNativeBoolean(pendingProps.opt("scrollEnabled"), true) | |
| 95 | map.uiSettings.isZoomGesturesEnabled = craftNativeBoolean(pendingProps.opt("zoomEnabled"), true) | |
| 96 | map.uiSettings.isRotateGesturesEnabled = craftNativeBoolean(pendingProps.opt("rotateEnabled")) | |
| 97 | map.uiSettings.isTiltGesturesEnabled = craftNativeBoolean(pendingProps.opt("pitchEnabled"), true) | |
| 98 | map.mapType = when (craftNativeOptionalString(pendingProps.opt("mapType")).orEmpty().lowercase()) { | |
| 99 | "satellite" -> GoogleMap.MAP_TYPE_SATELLITE | |
| 100 | "hybrid" -> GoogleMap.MAP_TYPE_HYBRID | |
| 101 | "terrain" -> GoogleMap.MAP_TYPE_TERRAIN | |
| 102 | else -> GoogleMap.MAP_TYPE_NORMAL | |
| 103 | } | |
| 104 | applyTileOverlay() | |
| 105 | val showUserLocation = craftNativeBoolean(pendingProps.opt("showsUserLocation")) && hasLocationPermission() | |
| 106 | runCatching { map.isMyLocationEnabled = showUserLocation } | |
| 107 | val nextMarkers = mutableMapOf<String, Pair<LatLng, String?>>() | |
| 108 | val nextBearings = mutableMapOf<String, Float>() | |
| 109 | val nextPinColors = mutableMapOf<String, Int>() | |
| 110 | val nextHandlers = mutableMapOf<String, String>() | |
| 111 | val nextPolylines = mutableMapOf<String, Triple<List<LatLng>, Int, Float>>() | |
| 112 | for (index in 0 until pendingChildren.length()) { | |
| 113 | val child = pendingChildren.optJSONObject(index) ?: continue | |
| 114 | val type = child.optString("type").lowercase() | |
| 115 | val props = child.optJSONObject("props") ?: JSONObject() | |
| 116 | val events = child.optJSONObject("events") ?: JSONObject() | |
| 117 | val id = craftNativeOptionalString(props.opt("id")) | |
| 118 | ?: craftNativeOptionalString(child.opt("id")) | |
| 119 | ?: "overlay-$index" | |
| 120 | val points = coordinates(props.optJSONArray("geometry")) | |
| 121 | if (type == "mapmarker" || type == "marker") { | |
| 122 | points.firstOrNull()?.let { point -> | |
| 123 | nextMarkers[id] = point to (props.opt("title") as? String) | |
| 124 | (props.opt("pinColor") as? String)?.let { value -> runCatching { craftNativeColor(value) }.getOrNull()?.let { nextPinColors[id] = it } } | |
| 125 | (props.opt("bearing") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.let { nextBearings[id] = (it % 360).toFloat() } | |
| 126 | listOf(events.opt("onPress"), events.opt("onMarkerPress")) | |
| 127 | .firstNotNullOfOrNull(::craftNativeOptionalString) | |
| 128 | ?.let { nextHandlers[id] = it } | |
| 129 | } | |
| 130 | } else if (type == "mappolyline" || type == "polyline") { | |
| 131 | if (points.size > 1) { | |
| 132 | // Keep malformed authored widths inert, matching the iOS | |
| 133 | // host. An omitted or null width still gets the shared | |
| 134 | // default; an attacker-controlled NaN, infinity, zero, | |
| 135 | // or negative value must not become a valid overlay. | |
| 136 | val rawWidth = props.opt("strokeWidth") | |
| 137 | val authoredWidth = when (rawWidth) { | |
| 138 | null, JSONObject.NULL -> 3.0 | |
| 139 | is Number -> rawWidth.toDouble() | |
| 140 | else -> 3.0 | |
| 141 | } | |
| 142 | // MapKit's lineWidth is expressed in points, while the | |
| 143 | // Google Maps SDK consumes screen pixels. Keep the | |
| 144 | // protocol in logical points and convert only at this | |
| 145 | // Android host boundary so dense displays stay in parity. | |
| 146 | val width = (authoredWidth * resources.displayMetrics.density).toFloat() | |
| 147 | if (!authoredWidth.isFinite() || !width.isFinite() || width <= 0f) continue | |
| 148 | nextPolylines[id] = Triple(points, color(craftNativeOptionalString(props.opt("strokeColor")).orEmpty()), width) | |
| 149 | } | |
| 150 | } | |
| 151 | } | |
| 152 | markerBearings = nextBearings | |
| 153 | markerPinColors = nextPinColors | |
| 154 | pinIcons.keys.filter { it !in markerPinColors.values }.forEach { pinIcons.remove(it) } | |
| 155 | markers.keys.filter { nextMarkers[it] == null }.forEach { markers.remove(it)?.remove() } | |
| 156 | for ((id, value) in nextMarkers) { | |
| 157 | val marker = markers[id] | |
| 158 | if (marker != null) { | |
| 159 | marker.position = value.first | |
| 160 | marker.title = value.second | |
| 161 | marker.setIcon(if (markerBearings.containsKey(id)) directionMarkerIcon() else pinMarkerIcon(id)) | |
| 162 | marker.isFlat = markerBearings.containsKey(id) | |
| 163 | marker.rotation = markerBearings[id] ?: 0f | |
| 164 | marker.setAnchor(0.5f, if (markerBearings.containsKey(id) || markerPinColors.containsKey(id)) 0.5f else 1f) | |
| 165 | } else { | |
| 166 | map.addMarker(MarkerOptions().position(value.first).title(value.second).icon(if (markerBearings.containsKey(id)) directionMarkerIcon() else pinMarkerIcon(id)).flat(markerBearings.containsKey(id)).rotation(markerBearings[id] ?: 0f).anchor(0.5f, if (markerBearings.containsKey(id) || markerPinColors.containsKey(id)) 0.5f else 1f))?.let { markers[id] = it } | |
| 167 | } | |
| 168 | } | |
| 169 | markerHandlers.clear() | |
| 170 | markerHandlers.putAll(nextHandlers) | |
| 171 | polylines.keys.filter { nextPolylines[it] == null }.forEach { id -> | |
| 172 | polylines.remove(id)?.remove() | |
| 173 | } | |
| 174 | for ((id, value) in nextPolylines) { | |
| 175 | val polyline = polylines[id] | |
| 176 | if (polyline != null) { | |
| 177 | polyline.points = value.first | |
| 178 | polyline.color = value.second | |
| 179 | polyline.width = value.third | |
| 180 | } else { | |
| 181 | map.addPolyline(PolylineOptions().addAll(value.first).color(value.second).width(value.third))?.let { polylines[id] = it } | |
| 182 | } | |
| 183 | } | |
| 184 | val explicitRegion = region(pendingProps.optJSONObject("region")) | |
| 185 | val initialRegion = region(pendingProps.optJSONObject("initialRegion")) | |
| 186 | if (explicitRegion != null) { | |
| 187 | // A controlled region is an exact camera request. The padding | |
| 188 | // used for fitToBounds is framing chrome, not part of the region | |
| 189 | // contract, and would otherwise zoom every Android screen out. | |
| 190 | moveCamera(explicitRegion, padding = 0) | |
| 191 | initialCameraKey = null | |
| 192 | } else if (initialRegion != null) { | |
| 193 | val key = "region:${pendingProps.optJSONObject("initialRegion")}" | |
| 194 | if (initialCameraKey != key) { | |
| 195 | moveCamera(initialRegion, padding = 0) | |
| 196 | initialCameraKey = key | |
| 197 | } | |
| 198 | } else if (craftNativeBoolean(pendingProps.opt("fitToBounds"))) { | |
| 199 | val key = "fit:${overlayKey(nextMarkers, nextPolylines)}:${pendingProps.optJSONObject("edgePadding")}" | |
| 200 | if (initialCameraKey != key) { | |
| 201 | if (fitToBounds()) initialCameraKey = key | |
| 202 | } | |
| 203 | } else { | |
| 204 | initialCameraKey = null | |
| 205 | } | |
| 206 | applyCameraOrientation() | |
| 207 | updateScale() | |
| 208 | } | |
| 209 | ||
| 210 | private fun applyTileOverlay() { | |
| 211 | val map = googleMap ?: return | |
| 212 | val options = pendingProps.optJSONObject("tileOverlay") | |
| 213 | val template = options?.optString("urlTemplate").orEmpty() | |
| 214 | val credit = options?.optString("attribution").orEmpty() | |
| 215 | val next = template.takeIf { it.startsWith("https://") && listOf("{z}", "{x}", "{y}").all { key -> it.contains(key) } && credit.isNotBlank() } | |
| 216 | if (next != tileTemplate) { | |
| 217 | tileOverlay?.remove(); tileOverlay = null; tileTemplate = next | |
| 218 | if (next != null) { | |
| 219 | val provider = object : com.google.android.gms.maps.model.UrlTileProvider(256, 256) { | |
| 220 | override fun getTileUrl(x: Int, y: Int, zoom: Int): java.net.URL? = runCatching { java.net.URL(next.replace("{x}", x.toString()).replace("{y}", y.toString()).replace("{z}", zoom.toString())) }.getOrNull() | |
| 221 | } | |
| 222 | tileOverlay = map.addTileOverlay(com.google.android.gms.maps.model.TileOverlayOptions().tileProvider(provider).zIndex(-1f)) | |
| 223 | } | |
| 224 | } | |
| 225 | if (next != null) map.mapType = GoogleMap.MAP_TYPE_NONE | |
| 226 | tileAttribution.text = credit | |
| 227 | tileAttribution.visibility = if (next == null) View.GONE else View.VISIBLE | |
| 228 | } | |
| 229 | ||
| 230 | private fun applyMapPadding() { | |
| 231 | val authored = pendingProps.optJSONObject("edgePadding") | |
| 232 | fun inset(name: String): Double = (authored?.opt(name) as? Number)?.toDouble()?.takeIf { it.isFinite() }?.coerceIn(0.0, 10_000.0)?.times(resources.displayMetrics.density) ?: 0.0 | |
| 233 | var left = inset("left"); var top = inset("top"); var right = inset("right"); var bottom = inset("bottom") | |
| 234 | val horizontal = left + right | |
| 235 | val vertical = top + bottom | |
| 236 | val availableWidth = (width - 40 * resources.displayMetrics.density).coerceAtLeast(0f).toDouble() | |
| 237 | val availableHeight = (height - 40 * resources.displayMetrics.density).coerceAtLeast(0f).toDouble() | |
| 238 | if (horizontal > availableWidth && horizontal > 0) { left *= availableWidth / horizontal; right *= availableWidth / horizontal } | |
| 239 | if (vertical > availableHeight && vertical > 0) { top *= availableHeight / vertical; bottom *= availableHeight / vertical } | |
| 240 | googleMap?.setPadding(left.roundToInt(), top.roundToInt(), right.roundToInt(), bottom.roundToInt()) | |
| 241 | } | |
| 242 | ||
| 243 | private fun updateScale() { | |
| 244 | val map = googleMap ?: return | |
| 245 | if (!craftNativeBoolean(pendingProps.opt("showsScale")) || width <= 0 || height <= 0) { scaleView.visibility = View.GONE; return } | |
| 246 | val maximumWidth = (80 * resources.displayMetrics.density).roundToInt() | |
| 247 | val origin = map.projection.fromScreenLocation(android.graphics.Point(width / 2, height / 2)) | |
| 248 | val end = map.projection.fromScreenLocation(android.graphics.Point(width / 2 + maximumWidth, height / 2)) | |
| 249 | val distance = FloatArray(1) | |
| 250 | android.location.Location.distanceBetween(origin.latitude, origin.longitude, end.latitude, end.longitude, distance) | |
| 251 | val metres = distance[0].toDouble() | |
| 252 | if (!metres.isFinite() || metres <= 0) { scaleView.visibility = View.GONE; return } | |
| 253 | val magnitude = Math.pow(10.0, kotlin.math.floor(kotlin.math.log10(metres))) | |
| 254 | val length = listOf(5.0, 2.0, 1.0).firstOrNull { it * magnitude <= metres }?.times(magnitude) ?: magnitude | |
| 255 | scaleLabel.text = if (length >= 1000) "${length / 1000} km" else "${length.roundToInt()} m" | |
| 256 | scaleLine.layoutParams = LinearLayout.LayoutParams((maximumWidth * length / metres).roundToInt().coerceAtLeast(1), 2) | |
| 257 | scaleView.visibility = View.VISIBLE | |
| 258 | } | |
| 259 | ||
| 260 | private fun applyCameraOrientation() { | |
| 261 | val map = googleMap ?: return | |
| 262 | val camera = map.cameraPosition | |
| 263 | val pitch = (pendingProps.opt("cameraPitch") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.coerceIn(0.0, 60.0)?.toFloat() ?: camera.tilt | |
| 264 | val heading = (pendingProps.opt("cameraHeading") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.toFloat() ?: camera.bearing | |
| 265 | if (kotlin.math.abs(camera.tilt - pitch) > 0.01f || kotlin.math.abs(camera.bearing - heading) > 0.01f) { | |
| 266 | map.moveCamera(CameraUpdateFactory.newCameraPosition(com.google.android.gms.maps.model.CameraPosition.Builder(camera).tilt(pitch).bearing(heading).build())) | |
| 267 | } | |
| 268 | } | |
| 269 | ||
| 270 | private fun pinMarkerIcon(id: String): com.google.android.gms.maps.model.BitmapDescriptor { | |
| 271 | val color = markerPinColors[id] ?: return BitmapDescriptorFactory.defaultMarker() | |
| 272 | return pinIcons.getOrPut(color) { | |
| 273 | val size = (18 * resources.displayMetrics.density).roundToInt().coerceAtLeast(18) | |
| 274 | val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) | |
| 275 | val canvas = Canvas(bitmap) | |
| 276 | val paint = Paint(Paint.ANTI_ALIAS_FLAG) | |
| 277 | paint.color = Color.WHITE | |
| 278 | canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint) | |
| 279 | paint.color = color | |
| 280 | canvas.drawCircle(size / 2f, size / 2f, size * 0.38f, paint) | |
| 281 | BitmapDescriptorFactory.fromBitmap(bitmap) | |
| 282 | } | |
| 283 | } | |
| 284 | ||
| 285 | private fun directionMarkerIcon(): com.google.android.gms.maps.model.BitmapDescriptor { | |
| 286 | directionIcon?.let { return it } | |
| 287 | val size = (26 * resources.displayMetrics.density).roundToInt().coerceAtLeast(24) | |
| 288 | val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) | |
| 289 | val canvas = Canvas(bitmap) | |
| 290 | val paint = Paint(Paint.ANTI_ALIAS_FLAG) | |
| 291 | paint.color = Color.WHITE | |
| 292 | canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint) | |
| 293 | paint.color = Color.rgb(36, 114, 75) | |
| 294 | canvas.drawCircle(size / 2f, size / 2f, size * 0.43f, paint) | |
| 295 | paint.color = Color.WHITE | |
| 296 | paint.textSize = size * 0.76f | |
| 297 | paint.textAlign = Paint.Align.CENTER | |
| 298 | paint.typeface = Typeface.DEFAULT_BOLD | |
| 299 | canvas.drawText("↑", size / 2f, size / 2f - (paint.ascent() + paint.descent()) / 2, paint) | |
| 300 | return BitmapDescriptorFactory.fromBitmap(bitmap).also { directionIcon = it } | |
| 301 | } | |
| 302 | ||
| 303 | override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { | |
| 304 | super.onSizeChanged(width, height, oldWidth, oldHeight) | |
| 305 | applyMapPadding() | |
| 306 | updateScale() | |
| 307 | // MapView can become ready before its host has a usable viewport. Do | |
| 308 | // not mark a fit as complete until the camera update can be issued. | |
| 309 | if (width > 0 && height > 0 && craftNativeBoolean(pendingProps.opt("fitToBounds")) && initialCameraKey == null) { | |
| 310 | applyPending() | |
| 311 | } | |
| 312 | } | |
| 313 | ||
| 314 | private fun moveCamera(bounds: LatLngBounds, padding: Int = 24) { | |
| 315 | post { | |
| 316 | if (width > 0 && height > 0) { | |
| 317 | googleMap?.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, padding)) | |
| 318 | applyCameraOrientation() | |
| 319 | } | |
| 320 | } | |
| 321 | } | |
| 322 | ||
| 323 | private fun overlayKey( | |
| 324 | markers: Map<String, Pair<LatLng, String?>>, | |
| 325 | polylines: Map<String, Triple<List<LatLng>, Int, Float>>, | |
| 326 | ): String { | |
| 327 | val markerKey = markers.toSortedMap().entries.joinToString("|") { (id, value) -> | |
| 328 | "$id:${value.first.latitude},${value.first.longitude}" | |
| 329 | } | |
| 330 | val polylineKey = polylines.toSortedMap().entries.joinToString("|") { (id, value) -> | |
| 331 | "$id:" + value.first.joinToString(";") { point -> "${point.latitude},${point.longitude}" } | |
| 332 | } | |
| 333 | return "m[$markerKey]p[$polylineKey]" | |
| 334 | } | |
| 335 | ||
| 336 | private fun fitToBounds(): Boolean { | |
| 337 | val map = googleMap ?: return false | |
| 338 | if (width <= 0 || height <= 0) return false | |
| 339 | val builder = LatLngBounds.Builder() | |
| 340 | var count = 0 | |
| 341 | markers.values.forEach { builder.include(it.position); count++ } | |
| 342 | polylines.values.flatMap { it.points }.forEach { builder.include(it); count++ } | |
| 343 | if (count == 0) return false | |
| 344 | post { | |
| 345 | if (width > 0 && height > 0) { | |
| 346 | applyMapPadding() | |
| 347 | val padding = if (pendingProps.has("edgePadding")) 0 else (24 * resources.displayMetrics.density).roundToInt() | |
| 348 | map.moveCamera(CameraUpdateFactory.newLatLngBounds(builder.build(), padding)) | |
| 349 | applyCameraOrientation() | |
| 350 | } | |
| 351 | } | |
| 352 | return true | |
| 353 | } | |
| 354 | ||
| 355 | override fun onAttachedToWindow() { | |
| 356 | super.onAttachedToWindow() | |
| 357 | if (destroyed) return | |
| 358 | mapView.onStart() | |
| 359 | mapView.onResume() | |
| 360 | lifecycleStarted = true | |
| 361 | } | |
| 362 | ||
| 363 | override fun onDetachedFromWindow() { | |
| 364 | if (lifecycleStarted) { | |
| 365 | mapView.onPause() | |
| 366 | mapView.onStop() | |
| 367 | lifecycleStarted = false | |
| 368 | } | |
| 369 | super.onDetachedFromWindow() | |
| 370 | } | |
| 371 | ||
| 372 | fun destroy() { | |
| 373 | if (destroyed) return | |
| 374 | destroyed = true | |
| 375 | if (lifecycleStarted) { | |
| 376 | mapView.onPause() | |
| 377 | mapView.onStop() | |
| 378 | lifecycleStarted = false | |
| 379 | } | |
| 380 | onMarkerTap = null | |
| 381 | onMapPress = null | |
| 382 | onRegionChangeComplete = null | |
| 383 | directionIcon = null | |
| 384 | pinIcons.clear(); markerPinColors.clear() | |
| 385 | tileOverlay?.remove(); tileOverlay = null | |
| 386 | markerHandlers.clear() | |
| 387 | markers.values.forEach { it.remove() } | |
| 388 | markers.clear() | |
| 389 | polylines.values.forEach { it.remove() } | |
| 390 | polylines.clear() | |
| 391 | mapView.onDestroy() | |
| 392 | googleMap = null | |
| 393 | } | |
| 394 | ||
| 395 | fun lowMemory() { | |
| 396 | if (!destroyed) mapView.onLowMemory() | |
| 397 | } | |
| 398 | ||
| 399 | private fun hasLocationPermission(): Boolean = | |
| 400 | ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || | |
| 401 | ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED | |
| 402 | ||
| 403 | private fun coordinates(geometry: JSONArray?): List<LatLng> { | |
| 404 | if (geometry == null) return emptyList() | |
| 405 | return (0 until geometry.length() - 1 step 2).mapNotNull { index -> | |
| 406 | val latitude = finiteNumber(geometry.opt(index)) ?: return@mapNotNull null | |
| 407 | val longitude = finiteNumber(geometry.opt(index + 1)) ?: return@mapNotNull null | |
| 408 | if (latitude.isFinite() && longitude.isFinite() && | |
| 409 | latitude >= -90.0 && latitude <= 90.0 && | |
| 410 | longitude >= -180.0 && longitude <= 180.0 | |
| 411 | ) LatLng(latitude, longitude) else null | |
| 412 | } | |
| 413 | } | |
| 414 | ||
| 415 | private fun region(value: JSONObject?): LatLngBounds? { | |
| 416 | val center = value?.optJSONObject("center") ?: return null | |
| 417 | val span = value.optJSONObject("span") ?: return null | |
| 418 | val latitude = finiteNumber(center.opt("latitude")) ?: return null | |
| 419 | val longitude = finiteNumber(center.opt("longitude")) ?: return null | |
| 420 | val latitudeDelta = finiteNumber(span.opt("latitudeDelta")) | |
| 421 | ?: finiteNumber(span.opt("latitude_delta")) | |
| 422 | ?: return null | |
| 423 | val longitudeDelta = finiteNumber(span.opt("longitudeDelta")) | |
| 424 | ?: finiteNumber(span.opt("longitude_delta")) | |
| 425 | ?: return null | |
| 426 | if (!latitude.isFinite() || !longitude.isFinite() || !latitudeDelta.isFinite() || !longitudeDelta.isFinite() || | |
| 427 | latitude < -90.0 || latitude > 90.0 || longitude < -180.0 || longitude > 180.0 || | |
| 428 | latitudeDelta <= 0.0 || latitudeDelta > 180.0 || | |
| 429 | longitudeDelta <= 0.0 || longitudeDelta > 360.0 || | |
| 430 | latitude - latitudeDelta / 2.0 < -90.0 || latitude + latitudeDelta / 2.0 > 90.0 | |
| 431 | ) return null | |
| 432 | return LatLngBounds(LatLng(latitude - latitudeDelta / 2, longitude - longitudeDelta / 2), LatLng(latitude + latitudeDelta / 2, longitude + longitudeDelta / 2)) | |
| 433 | } | |
| 434 | ||
| 435 | private fun finiteNumber(value: Any?): Double? = | |
| 436 | (value as? Number)?.toDouble()?.takeIf { it.isFinite() } | |
| 437 | ||
| 438 | private fun color(value: String): Int = runCatching { craftNativeColor(value) }.getOrDefault(Color.BLUE) | |
| 439 | } | |
| 440 | ||
| @@ -0,0 +1,438 @@ | ||
| 1 | /** A key-free native Android map. STX geometry never enters a WebView. */ | |
| 2 | internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMapReadyCallback { | |
| 3 | private val mapView: MapView | |
| 4 | private var map: MapLibreMap? = null | |
| 5 | private var destroyed = false | |
| 6 | private var lifecycleStarted = false | |
| 7 | private var lifecycleResumed = false | |
| 8 | private var lifecycleOwner: androidx.lifecycle.LifecycleOwner? = null | |
| 9 | private val lifecycleObserver = object : androidx.lifecycle.DefaultLifecycleObserver { | |
| 10 | override fun onStart(owner: androidx.lifecycle.LifecycleOwner) { startMap() } | |
| 11 | override fun onResume(owner: androidx.lifecycle.LifecycleOwner) { resumeMap() } | |
| 12 | override fun onPause(owner: androidx.lifecycle.LifecycleOwner) { pauseMap() } | |
| 13 | override fun onStop(owner: androidx.lifecycle.LifecycleOwner) { stopMap() } | |
| 14 | } | |
| 15 | private var pendingProps = JSONObject() | |
| 16 | private var pendingChildren = JSONArray() | |
| 17 | private var styleKey: String? = null | |
| 18 | private var styleVersion = 0 | |
| 19 | private var readyStyle: Style? = null | |
| 20 | private var initialCameraKey: String? = null | |
| 21 | private var reportedRegion: LatLngBounds? = null | |
| 22 | private var reportedCamera: CameraPosition? = null | |
| 23 | private var reportedViewport: Pair<Int, Int>? = null | |
| 24 | private var userChangingCamera = false | |
| 25 | private var geometryPoints = emptyList<LatLng>() | |
| 26 | private val markerHandlers = mutableMapOf<String, String>() | |
| 27 | private var onMarkerTap: ((String) -> Unit)? = null | |
| 28 | var onMapPress: ((LatLng) -> Unit)? = null | |
| 29 | var onRegionChangeComplete: ((JSONObject) -> Unit)? = null | |
| 30 | private val attribution = TextView(context).apply { | |
| 31 | textSize = 9f | |
| 32 | setTextColor(Color.DKGRAY) | |
| 33 | setBackgroundColor(Color.argb(230, 255, 255, 255)) | |
| 34 | maxLines = 2 | |
| 35 | } | |
| 36 | private val scale = LinearLayout(context).apply { | |
| 37 | orientation = LinearLayout.VERTICAL | |
| 38 | setPadding(4, 2, 4, 2) | |
| 39 | setBackgroundColor(Color.argb(230, 255, 255, 255)) | |
| 40 | importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO | |
| 41 | visibility = View.GONE | |
| 42 | } | |
| 43 | private val scaleLabel = TextView(context).apply { textSize = 11f; setTextColor(Color.rgb(37, 51, 33)) } | |
| 44 | private val scaleLine = View(context).apply { setBackgroundColor(Color.rgb(37, 51, 33)) } | |
| 45 | ||
| 46 | init { | |
| 47 | MapLibre.getInstance(context) | |
| 48 | mapView = MapView(context) | |
| 49 | addView(mapView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) | |
| 50 | scale.addView(scaleLabel) | |
| 51 | scale.addView(scaleLine, LinearLayout.LayoutParams(1, 2)) | |
| 52 | addView(scale, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.START).apply { | |
| 53 | leftMargin = (8 * resources.displayMetrics.density).roundToInt() | |
| 54 | bottomMargin = (24 * resources.displayMetrics.density).roundToInt() | |
| 55 | }) | |
| 56 | addView(attribution, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.END).apply { | |
| 57 | rightMargin = (8 * resources.displayMetrics.density).roundToInt() | |
| 58 | bottomMargin = (8 * resources.displayMetrics.density).roundToInt() | |
| 59 | }) | |
| 60 | mapView.onCreate(null) | |
| 61 | mapView.addOnDidFinishLoadingMapListener { if (!destroyed && initialCameraKey == null) applyPending() } | |
| 62 | mapView.getMapAsync(this) | |
| 63 | } | |
| 64 | ||
| 65 | override fun onMapReady(nativeMap: MapLibreMap) { | |
| 66 | if (destroyed) return | |
| 67 | map = nativeMap | |
| 68 | nativeMap.addOnMapClickListener { point -> | |
| 69 | val screen = nativeMap.projection.toScreenLocation(point) | |
| 70 | val radius = 18 * resources.displayMetrics.density | |
| 71 | val hit = nativeMap.queryRenderedFeatures(android.graphics.RectF(screen.x - radius, screen.y - radius, screen.x + radius, screen.y + radius), "craft-map-markers", "craft-map-directions").firstOrNull() | |
| 72 | val id = hit?.takeIf { it.hasProperty("craftMarkerId") }?.getStringProperty("craftMarkerId") | |
| 73 | if (id != null) onMarkerTap?.invoke(id) else onMapPress?.invoke(point) | |
| 74 | true | |
| 75 | } | |
| 76 | nativeMap.addOnCameraMoveStartedListener { reason -> | |
| 77 | if (reason == MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE) userChangingCamera = true | |
| 78 | } | |
| 79 | nativeMap.addOnCameraMoveListener { updateScale() } | |
| 80 | nativeMap.addOnCameraIdleListener { | |
| 81 | updateScale() | |
| 82 | if (userChangingCamera) { | |
| 83 | userChangingCamera = false | |
| 84 | val bounds = nativeMap.projection.visibleRegion.latLngBounds | |
| 85 | val camera = nativeMap.cameraPosition | |
| 86 | val center = camera.target | |
| 87 | if (center != null) { | |
| 88 | val event = JSONObject() | |
| 89 | .put("center", JSONObject().put("latitude", center.latitude).put("longitude", center.longitude)) | |
| 90 | .put("span", JSONObject().put("latitudeDelta", bounds.latitudeNorth - bounds.latitudeSouth).put("longitudeDelta", bounds.longitudeEast - bounds.longitudeWest)) | |
| 91 | .put("heading", camera.bearing).put("pitch", camera.tilt) | |
| 92 | reportedRegion = region(event) | |
| 93 | reportedCamera = camera | |
| 94 | reportedViewport = width to height | |
| 95 | onRegionChangeComplete?.invoke(event) | |
| 96 | } | |
| 97 | } | |
| 98 | } | |
| 99 | applyPending() | |
| 100 | } | |
| 101 | ||
| 102 | fun configure(props: JSONObject, children: JSONArray, markerHandler: (String) -> Unit) { | |
| 103 | pendingProps = props | |
| 104 | pendingChildren = children | |
| 105 | onMarkerTap = markerHandler | |
| 106 | applyPending() | |
| 107 | } | |
| 108 | ||
| 109 | fun markerHandler(id: String): String? = markerHandlers[id] | |
| 110 | ||
| 111 | private fun applyPending() { | |
| 112 | val nativeMap = map ?: return | |
| 113 | if (destroyed) return | |
| 114 | nativeMap.uiSettings.isCompassEnabled = craftNativeBoolean(pendingProps.opt("showsCompass")) | |
| 115 | nativeMap.uiSettings.isScrollGesturesEnabled = craftNativeBoolean(pendingProps.opt("scrollEnabled"), true) | |
| 116 | nativeMap.uiSettings.isZoomGesturesEnabled = craftNativeBoolean(pendingProps.opt("zoomEnabled"), true) | |
| 117 | nativeMap.uiSettings.isRotateGesturesEnabled = craftNativeBoolean(pendingProps.opt("rotateEnabled")) | |
| 118 | nativeMap.uiSettings.isTiltGesturesEnabled = craftNativeBoolean(pendingProps.opt("pitchEnabled"), true) | |
| 119 | // The authored attribution is kept visible rather than hidden in a menu. | |
| 120 | nativeMap.uiSettings.isAttributionEnabled = false | |
| 121 | nativeMap.uiSettings.isLogoEnabled = false | |
| 122 | applyPadding() | |
| 123 | val nextStyle = basemapStyle() | |
| 124 | if (styleKey != nextStyle) { | |
| 125 | styleKey = nextStyle | |
| 126 | readyStyle = null | |
| 127 | val version = ++styleVersion | |
| 128 | nativeMap.setStyle(Style.Builder().fromJson(nextStyle)) { loaded -> | |
| 129 | if (!destroyed && version == styleVersion) { | |
| 130 | readyStyle = loaded | |
| 131 | installOverlays(loaded) | |
| 132 | applyPending() | |
| 133 | } | |
| 134 | } | |
| 135 | return | |
| 136 | } | |
| 137 | val loaded = readyStyle ?: return | |
| 138 | updateOverlays(loaded) | |
| 139 | applyLocation(nativeMap, loaded) | |
| 140 | val explicit = region(pendingProps.optJSONObject("region")) | |
| 141 | val initial = region(pendingProps.optJSONObject("initialRegion")) | |
| 142 | if (explicit != null) { | |
| 143 | // A measured region is a bounding envelope, especially with | |
| 144 | // padding or rotation. Refitting its unchanged echo would zoom | |
| 145 | // out after every gesture. New requests still move the camera. | |
| 146 | if (!isCurrentReportedRegion(explicit)) moveCamera(explicit, 0) | |
| 147 | initialCameraKey = null | |
| 148 | } else if (initial != null) { | |
| 149 | val key = "region:${pendingProps.optJSONObject("initialRegion")}" | |
| 150 | if (initialCameraKey != key) { moveCamera(initial, 0); initialCameraKey = key } | |
| 151 | } else if (craftNativeBoolean(pendingProps.opt("fitToBounds"))) { | |
| 152 | val key = "$width,$height:${pendingProps.optJSONObject("edgePadding")}:${pendingProps.opt("cameraPitch")}:${pendingProps.opt("cameraHeading")}:" + geometryPoints.joinToString(";") { "${it.latitude},${it.longitude}" } | |
| 153 | if (initialCameraKey != key && geometryPoints.isNotEmpty() && width > 0 && height > 0) { | |
| 154 | val distinct = geometryPoints.distinctBy { it.latitude to it.longitude } | |
| 155 | if (distinct.size == 1) nativeMap.moveCamera(CameraUpdateFactory.newLatLngZoom(distinct.first(), 15.0)) | |
| 156 | else moveCamera(LatLngBounds.Builder().includes(geometryPoints).build(), if (pendingProps.has("edgePadding")) 0 else (24 * resources.displayMetrics.density).roundToInt()) | |
| 157 | initialCameraKey = key | |
| 158 | } | |
| 159 | } else initialCameraKey = null | |
| 160 | applyOrientation() | |
| 161 | updateScale() | |
| 162 | } | |
| 163 | ||
| 164 | private fun basemapStyle(): String { | |
| 165 | val custom = pendingProps.optJSONObject("tileOverlay") | |
| 166 | val template = custom?.optString("urlTemplate").orEmpty() | |
| 167 | val credit = custom?.optString("attribution").orEmpty() | |
| 168 | val customValid = template.startsWith("https://") && listOf("{z}", "{x}", "{y}").all { template.contains(it) } && credit.isNotBlank() | |
| 169 | val type = pendingProps.optString("mapType", "standard").lowercase() | |
| 170 | val satellite = type == "satellite" || type == "hybrid" | |
| 171 | val url = when { | |
| 172 | customValid -> template | |
| 173 | satellite -> "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" | |
| 174 | type == "terrain" -> "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}" | |
| 175 | else -> "https://tile.openstreetmap.org/{z}/{x}/{y}.png" | |
| 176 | } | |
| 177 | val attributionText = when { | |
| 178 | customValid -> credit | |
| 179 | satellite -> "Imagery © Esri, Maxar, Earthstar Geographics, and the GIS User Community" | |
| 180 | type == "terrain" -> "Topography: Esri, USGS, NOAA, OpenStreetMap contributors" | |
| 181 | else -> "© OpenStreetMap contributors" | |
| 182 | } | |
| 183 | attribution.text = attributionText | |
| 184 | val sources = JSONObject().put("basemap", rasterSource(url, attributionText)) | |
| 185 | val layers = JSONArray().put(JSONObject().put("id", "basemap").put("type", "raster").put("source", "basemap")) | |
| 186 | if (!customValid && type == "hybrid") { | |
| 187 | sources.put("labels", rasterSource("https://services.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}", "Labels © Esri")) | |
| 188 | layers.put(JSONObject().put("id", "labels").put("type", "raster").put("source", "labels")) | |
| 189 | attribution.text = "$attributionText · Labels © Esri" | |
| 190 | } | |
| 191 | return JSONObject().put("version", 8).put("sources", sources).put("layers", layers).toString() | |
| 192 | } | |
| 193 | ||
| 194 | private fun rasterSource(url: String, credit: String): JSONObject = JSONObject() | |
| 195 | .put("type", "raster").put("tiles", JSONArray().put(url)).put("tileSize", 256).put("maxzoom", 19).put("attribution", credit) | |
| 196 | ||
| 197 | private fun installOverlays(style: Style) { | |
| 198 | style.addSource(GeoJsonSource("craft-map-overlays")) | |
| 199 | style.addImage("craft-map-direction", directionBitmap()) | |
| 200 | style.addLayer(LineLayer("craft-map-routes", "craft-map-overlays") | |
| 201 | .withFilter(Expression.eq(Expression.get("kind"), "line")) | |
| 202 | .withProperties(lineColor(Expression.toColor(Expression.get("color"))), lineWidth(Expression.get("width")), lineCap("round"), lineJoin("round"))) | |
| 203 | style.addLayer(CircleLayer("craft-map-markers", "craft-map-overlays") | |
| 204 | .withFilter(Expression.eq(Expression.get("kind"), "marker")) | |
| 205 | .withProperties(circleColor(Expression.toColor(Expression.get("color"))), circleRadius(7f), circleStrokeColor(Color.WHITE), circleStrokeWidth(2f))) | |
| 206 | style.addLayer(SymbolLayer("craft-map-directions", "craft-map-overlays") | |
| 207 | .withFilter(Expression.eq(Expression.get("kind"), "direction")) | |
| 208 | .withProperties(iconImage("craft-map-direction"), iconRotate(Expression.get("bearing")), iconRotationAlignment("map"), iconAllowOverlap(true), iconIgnorePlacement(true), iconAnchor("center"))) | |
| 209 | } | |
| 210 | ||
| 211 | private fun updateOverlays(style: Style) { | |
| 212 | val features = JSONArray() | |
| 213 | val pointsForFit = mutableListOf<LatLng>() | |
| 214 | markerHandlers.clear() | |
| 215 | for (index in 0 until pendingChildren.length()) { | |
| 216 | val child = pendingChildren.optJSONObject(index) ?: continue | |
| 217 | val type = child.optString("type").lowercase() | |
| 218 | val props = child.optJSONObject("props") ?: JSONObject() | |
| 219 | val events = child.optJSONObject("events") ?: JSONObject() | |
| 220 | val id = craftNativeOptionalString(props.opt("id")) ?: craftNativeOptionalString(child.opt("id")) ?: "overlay-$index" | |
| 221 | val points = coordinates(props.optJSONArray("geometry")) | |
| 222 | val properties = JSONObject().put("craftOverlayId", id) | |
| 223 | val geometry = JSONObject() | |
| 224 | if ((type == "mapmarker" || type == "marker") && points.isNotEmpty()) { | |
| 225 | val point = points.first() | |
| 226 | val bearing = finiteNumber(props.opt("bearing")) | |
| 227 | properties.put("kind", if (bearing == null) "marker" else "direction").put("craftMarkerId", id) | |
| 228 | properties.put("bearing", (bearing ?: 0.0) % 360).put("color", cssColor(props.opt("pinColor"), Color.rgb(36, 114, 75))) | |
| 229 | geometry.put("type", "Point").put("coordinates", JSONArray().put(point.longitude).put(point.latitude)) | |
| 230 | listOf(events.opt("onPress"), events.opt("onMarkerPress")).firstNotNullOfOrNull(::craftNativeOptionalString)?.let { markerHandlers[id] = it } | |
| 231 | pointsForFit.add(point) | |
| 232 | } else if ((type == "mappolyline" || type == "polyline") && points.size > 1) { | |
| 233 | val authoredWidth = props.opt("strokeWidth") | |
| 234 | val width = if (authoredWidth == null || authoredWidth == JSONObject.NULL) 3.0 else finiteNumber(authoredWidth) ?: continue | |
| 235 | if (width <= 0 || !width.toFloat().isFinite()) continue | |
| 236 | properties.put("kind", "line").put("color", cssColor(props.opt("strokeColor"), Color.BLUE)).put("width", width) | |
| 237 | geometry.put("type", "LineString").put("coordinates", JSONArray().apply { points.forEach { put(JSONArray().put(it.longitude).put(it.latitude)) } }) | |
| 238 | pointsForFit.addAll(points) | |
| 239 | } else continue | |
| 240 | features.put(JSONObject().put("type", "Feature").put("properties", properties).put("geometry", geometry)) | |
| 241 | } | |
| 242 | geometryPoints = pointsForFit | |
| 243 | style.getSourceAs<GeoJsonSource>("craft-map-overlays")?.setGeoJson(JSONObject().put("type", "FeatureCollection").put("features", features).toString()) | |
| 244 | } | |
| 245 | ||
| 246 | private fun cssColor(value: Any?, fallback: Int): String { | |
| 247 | val color = (value as? String)?.let { runCatching { craftNativeColor(it) }.getOrNull() } ?: fallback | |
| 248 | return "rgba(${Color.red(color)},${Color.green(color)},${Color.blue(color)},${Color.alpha(color) / 255.0})" | |
| 249 | } | |
| 250 | ||
| 251 | private fun directionBitmap(): Bitmap { | |
| 252 | val size = (26 * resources.displayMetrics.density).roundToInt().coerceAtLeast(24) | |
| 253 | val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) | |
| 254 | bitmap.density = resources.displayMetrics.densityDpi | |
| 255 | val canvas = Canvas(bitmap) | |
| 256 | val paint = Paint(Paint.ANTI_ALIAS_FLAG) | |
| 257 | paint.color = Color.WHITE | |
| 258 | canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint) | |
| 259 | paint.color = Color.rgb(36, 114, 75) | |
| 260 | canvas.drawCircle(size / 2f, size / 2f, size * 0.43f, paint) | |
| 261 | paint.color = Color.WHITE | |
| 262 | paint.textSize = size * 0.76f | |
| 263 | paint.textAlign = Paint.Align.CENTER | |
| 264 | paint.typeface = Typeface.DEFAULT_BOLD | |
| 265 | canvas.drawText("↑", size / 2f, size / 2f - (paint.ascent() + paint.descent()) / 2, paint) | |
| 266 | return bitmap | |
| 267 | } | |
| 268 | ||
| 269 | private fun applyLocation(nativeMap: MapLibreMap, style: Style) { | |
| 270 | val permitted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || | |
| 271 | ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED | |
| 272 | val enabled = permitted && craftNativeBoolean(pendingProps.opt("showsUserLocation")) | |
| 273 | val component = nativeMap.locationComponent | |
| 274 | if (!enabled && !component.isLocationComponentActivated) return | |
| 275 | try { | |
| 276 | if (!component.isLocationComponentActivated) component.activateLocationComponent(LocationComponentActivationOptions.builder(context, style).useDefaultLocationEngine(true).build()) | |
| 277 | component.isLocationComponentEnabled = enabled | |
| 278 | } catch (error: SecurityException) { | |
| 279 | // Permission may be revoked between the check and native activation. | |
| 280 | if (component.isLocationComponentActivated) component.isLocationComponentEnabled = false | |
| 281 | } | |
| 282 | } | |
| 283 | ||
| 284 | private fun applyPadding() { | |
| 285 | val value = pendingProps.optJSONObject("edgePadding") | |
| 286 | fun inset(key: String): Double = (finiteNumber(value?.opt(key)) ?: 0.0).coerceIn(0.0, 10_000.0) * resources.displayMetrics.density | |
| 287 | var left = inset("left"); var right = inset("right"); var top = inset("top"); var bottom = inset("bottom") | |
| 288 | val availableWidth = (width - 40 * resources.displayMetrics.density).coerceAtLeast(0f) | |
| 289 | val availableHeight = (height - 40 * resources.displayMetrics.density).coerceAtLeast(0f) | |
| 290 | val horizontal = left + right; val vertical = top + bottom | |
| 291 | if (horizontal > availableWidth && horizontal > 0) { left *= availableWidth / horizontal; right *= availableWidth / horizontal } | |
| 292 | if (vertical > availableHeight && vertical > 0) { top *= availableHeight / vertical; bottom *= availableHeight / vertical } | |
| 293 | map?.setPadding(left.roundToInt(), top.roundToInt(), right.roundToInt(), bottom.roundToInt()) | |
| 294 | // Floating STX controls reserve the same space for credits and scale | |
| 295 | // as for route fitting, so a profile never covers either utility. | |
| 296 | (attribution.layoutParams as LayoutParams).let { params -> | |
| 297 | val margin = bottom.roundToInt() + (8 * resources.displayMetrics.density).roundToInt() | |
| 298 | if (params.bottomMargin != margin) { params.bottomMargin = margin; attribution.layoutParams = params } | |
| 299 | } | |
| 300 | (scale.layoutParams as LayoutParams).let { params -> | |
| 301 | val margin = bottom.roundToInt() + (32 * resources.displayMetrics.density).roundToInt() | |
| 302 | if (params.bottomMargin != margin) { params.bottomMargin = margin; scale.layoutParams = params } | |
| 303 | } | |
| 304 | } | |
| 305 | ||
| 306 | private fun moveCamera(bounds: LatLngBounds, padding: Int) { | |
| 307 | post { | |
| 308 | val nativeMap = map | |
| 309 | if (!destroyed && nativeMap != null && width > 0 && height > 0) { | |
| 310 | val camera = nativeMap.cameraPosition | |
| 311 | val bearing = finiteNumber(pendingProps.opt("cameraHeading")) ?: camera.bearing | |
| 312 | val tilt = finiteNumber(pendingProps.opt("cameraPitch"))?.coerceIn(0.0, 60.0) ?: camera.tilt | |
| 313 | val insets = nativeMap.padding.map { it + padding }.toIntArray() | |
| 314 | val target = nativeMap.getCameraForLatLngBounds(bounds, insets, bearing, tilt) | |
| 315 | if (target == null) initialCameraKey = null | |
| 316 | else nativeMap.moveCamera(CameraUpdateFactory.newCameraPosition(target)) | |
| 317 | } | |
| 318 | } | |
| 319 | } | |
| 320 | ||
| 321 | private fun isCurrentReportedRegion(requested: LatLngBounds): Boolean { | |
| 322 | val reported = reportedRegion ?: return false | |
| 323 | val measured = reportedCamera ?: return false | |
| 324 | val current = map?.cameraPosition ?: return false | |
| 325 | val measuredTarget = measured.target ?: return false | |
| 326 | val currentTarget = current.target ?: return false | |
| 327 | fun same(a: Double, b: Double): Boolean = abs(a - b) < 0.000000001 | |
| 328 | return reportedViewport == (width to height) && | |
| 329 | same(requested.latitudeNorth, reported.latitudeNorth) && same(requested.latitudeSouth, reported.latitudeSouth) && | |
| 330 | same(requested.longitudeEast, reported.longitudeEast) && same(requested.longitudeWest, reported.longitudeWest) && | |
| 331 | abs(current.zoom - measured.zoom) < 0.000001 && | |
| 332 | same(currentTarget.latitude, measuredTarget.latitude) && same(currentTarget.longitude, measuredTarget.longitude) | |
| 333 | } | |
| 334 | ||
| 335 | private fun applyOrientation() { | |
| 336 | val nativeMap = map ?: return | |
| 337 | val camera = nativeMap.cameraPosition | |
| 338 | val pitch = finiteNumber(pendingProps.opt("cameraPitch"))?.coerceIn(0.0, 60.0) ?: camera.tilt | |
| 339 | val heading = finiteNumber(pendingProps.opt("cameraHeading")) ?: camera.bearing | |
| 340 | if (abs(camera.tilt - pitch) > 0.01 || abs(camera.bearing - heading) > 0.01) { | |
| 341 | nativeMap.moveCamera(CameraUpdateFactory.newCameraPosition(CameraPosition.Builder(camera).tilt(pitch).bearing(heading).build())) | |
| 342 | } | |
| 343 | } | |
| 344 | ||
| 345 | private fun updateScale() { | |
| 346 | val nativeMap = map ?: return | |
| 347 | if (!craftNativeBoolean(pendingProps.opt("showsScale")) || width <= 0 || height <= 0) { scale.visibility = View.GONE; return } | |
| 348 | val pixels = 80 * resources.displayMetrics.density | |
| 349 | val start = nativeMap.projection.fromScreenLocation(android.graphics.PointF(width / 2f, height / 2f)) | |
| 350 | val end = nativeMap.projection.fromScreenLocation(android.graphics.PointF(width / 2f + pixels, height / 2f)) | |
| 351 | val measured = FloatArray(1) | |
| 352 | android.location.Location.distanceBetween(start.latitude, start.longitude, end.latitude, end.longitude, measured) | |
| 353 | val meters = measured[0].toDouble() | |
| 354 | if (!meters.isFinite() || meters <= 0) { scale.visibility = View.GONE; return } | |
| 355 | val magnitude = Math.pow(10.0, kotlin.math.floor(kotlin.math.log10(meters))) | |
| 356 | val length = listOf(5.0, 2.0, 1.0).firstOrNull { it * magnitude <= meters }?.times(magnitude) ?: magnitude | |
| 357 | scaleLabel.text = if (length >= 1000) "${length / 1000} km" else "${length.roundToInt()} m" | |
| 358 | scaleLine.layoutParams = LinearLayout.LayoutParams((pixels * length / meters).roundToInt().coerceAtLeast(1), 2) | |
| 359 | scale.visibility = View.VISIBLE | |
| 360 | } | |
| 361 | ||
| 362 | override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { | |
| 363 | super.onSizeChanged(width, height, oldWidth, oldHeight) | |
| 364 | if (width > 0 && height > 0) applyPending() | |
| 365 | } | |
| 366 | ||
| 367 | override fun onAttachedToWindow() { | |
| 368 | super.onAttachedToWindow() | |
| 369 | if (destroyed) return | |
| 370 | lifecycleOwner = context as? androidx.lifecycle.LifecycleOwner | |
| 371 | val owner = lifecycleOwner | |
| 372 | if (owner != null) owner.lifecycle.addObserver(lifecycleObserver) | |
| 373 | else { startMap(); resumeMap() } | |
| 374 | } | |
| 375 | ||
| 376 | override fun onDetachedFromWindow() { | |
| 377 | lifecycleOwner?.lifecycle?.removeObserver(lifecycleObserver) | |
| 378 | lifecycleOwner = null | |
| 379 | stopMap() | |
| 380 | super.onDetachedFromWindow() | |
| 381 | } | |
| 382 | ||
| 383 | private fun startMap() { | |
| 384 | if (!destroyed && !lifecycleStarted) { mapView.onStart(); lifecycleStarted = true } | |
| 385 | } | |
| 386 | ||
| 387 | private fun resumeMap() { | |
| 388 | startMap() | |
| 389 | if (!destroyed && !lifecycleResumed) { mapView.onResume(); lifecycleResumed = true } | |
| 390 | } | |
| 391 | ||
| 392 | private fun pauseMap() { | |
| 393 | if (lifecycleResumed) { mapView.onPause(); lifecycleResumed = false } | |
| 394 | } | |
| 395 | ||
| 396 | private fun stopMap() { | |
| 397 | pauseMap() | |
| 398 | if (lifecycleStarted) { mapView.onStop(); lifecycleStarted = false } | |
| 399 | } | |
| 400 | ||
| 401 | fun lowMemory() { if (!destroyed) mapView.onLowMemory() } | |
| 402 | ||
| 403 | fun destroy() { | |
| 404 | if (destroyed) return | |
| 405 | destroyed = true | |
| 406 | styleVersion++ | |
| 407 | onMarkerTap = null; onMapPress = null; onRegionChangeComplete = null | |
| 408 | markerHandlers.clear(); geometryPoints = emptyList() | |
| 409 | lifecycleOwner?.lifecycle?.removeObserver(lifecycleObserver) | |
| 410 | lifecycleOwner = null | |
| 411 | stopMap() | |
| 412 | mapView.onDestroy() | |
| 413 | readyStyle = null; map = null | |
| 414 | reportedRegion = null; reportedCamera = null; reportedViewport = null | |
| 415 | } | |
| 416 | ||
| 417 | private fun coordinates(value: JSONArray?): List<LatLng> { | |
| 418 | if (value == null) return emptyList() | |
| 419 | return (0 until value.length() - 1 step 2).mapNotNull { index -> | |
| 420 | val latitude = finiteNumber(value.opt(index)) ?: return@mapNotNull null | |
| 421 | val longitude = finiteNumber(value.opt(index + 1)) ?: return@mapNotNull null | |
| 422 | if (latitude in -90.0..90.0 && longitude in -180.0..180.0) LatLng(latitude, longitude) else null | |
| 423 | } | |
| 424 | } | |
| 425 | ||
| 426 | private fun region(value: JSONObject?): LatLngBounds? { | |
| 427 | val center = value?.optJSONObject("center") ?: return null | |
| 428 | val span = value.optJSONObject("span") ?: return null | |
| 429 | val latitude = finiteNumber(center.opt("latitude")) ?: return null | |
| 430 | val longitude = finiteNumber(center.opt("longitude")) ?: return null | |
| 431 | val height = finiteNumber(span.opt("latitudeDelta")) ?: finiteNumber(span.opt("latitude_delta")) ?: return null | |
| 432 | val width = finiteNumber(span.opt("longitudeDelta")) ?: finiteNumber(span.opt("longitude_delta")) ?: return null | |
| 433 | if (latitude !in -90.0..90.0 || longitude !in -180.0..180.0 || height <= 0 || height > 180 || width <= 0 || width > 360 || latitude - height / 2 < -90 || latitude + height / 2 > 90) return null | |
| 434 | return LatLngBounds.from(latitude + height / 2, longitude + width / 2, latitude - height / 2, longitude - width / 2) | |
| 435 | } | |
| 436 | ||
| 437 | private fun finiteNumber(value: Any?): Double? = (value as? Number)?.toDouble()?.takeIf { it.isFinite() } | |
| 438 | } | |
| @@ -110,17 +110,7 @@ import androidx.javascriptengine.Message | ||
| 110 | 110 | import androidx.javascriptengine.MessagePort |
| 111 | 111 | import androidx.javascriptengine.MessagePortClient |
| 112 | 112 | import com.craft.runtime.CraftNative |
| 113 | import com.google.android.gms.maps.CameraUpdateFactory | |
| 114 | import com.google.android.gms.maps.GoogleMap | |
| 115 | import com.google.android.gms.maps.MapView | |
| 116 | import com.google.android.gms.maps.OnMapReadyCallback | |
| 117 | import com.google.android.gms.maps.model.BitmapDescriptorFactory | |
| 118 | import com.google.android.gms.maps.model.LatLng | |
| 119 | import com.google.android.gms.maps.model.LatLngBounds | |
| 120 | import com.google.android.gms.maps.model.Marker | |
| 121 | import com.google.android.gms.maps.model.MarkerOptions | |
| 122 | import com.google.android.gms.maps.model.Polyline | |
| 123 | import com.google.android.gms.maps.model.PolylineOptions | |
| 113 | ||
| 124 | 114 | import org.json.JSONArray |
| 125 | 115 | import org.json.JSONObject |
| 126 | 116 | import java.io.File |
| @@ -597,422 +587,7 @@ private class CraftNativeSvgView(context: Context) : View(context) { | ||
| 597 | 587 | private data class Quadruple<T1, T2, T3, T4>(val first: T1, val second: T2, val third: T3, val fourth: T4) |
| 598 | 588 | } |
| 599 | 589 | |
| 600 | /** The Android host for the small, host-neutral native trail map contract. */ | |
| 601 | private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMapReadyCallback { | |
| 602 | private val mapView = MapView(context) | |
| 603 | private val scaleView = LinearLayout(context).apply { | |
| 604 | orientation = LinearLayout.VERTICAL | |
| 605 | setPadding(4, 2, 4, 2) | |
| 606 | background = GradientDrawable().apply { setColor(Color.argb(230, 255, 255, 255)); cornerRadius = 4f } | |
| 607 | importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO | |
| 608 | } | |
| 609 | private val scaleLabel = TextView(context).apply { textSize = 11f; setTextColor(Color.rgb(37, 51, 33)) } | |
| 610 | private val scaleLine = View(context).apply { setBackgroundColor(Color.rgb(37, 51, 33)) } | |
| 611 | private var googleMap: GoogleMap? = null | |
| 612 | private var tileOverlay: com.google.android.gms.maps.model.TileOverlay? = null | |
| 613 | private var tileTemplate: String? = null | |
| 614 | private val tileAttribution = TextView(context).apply { textSize = 9f; setTextColor(Color.DKGRAY); setBackgroundColor(Color.argb(220, 255, 255, 255)); maxLines = 2; visibility = View.GONE } | |
| 615 | private var pendingProps = JSONObject() | |
| 616 | private var pendingChildren = JSONArray() | |
| 617 | private val markers = mutableMapOf<String, Marker>() | |
| 618 | private val polylines = mutableMapOf<String, Polyline>() | |
| 619 | private val markerHandlers = mutableMapOf<String, String>() | |
| 620 | private var onMarkerTap: ((String) -> Unit)? = null | |
| 621 | var onMapPress: ((LatLng) -> Unit)? = null | |
| 622 | var onRegionChangeComplete: ((JSONObject) -> Unit)? = null | |
| 623 | private var userChangingCamera = false | |
| 624 | private var markerBearings = mutableMapOf<String, Float>() | |
| 625 | private var directionIcon: com.google.android.gms.maps.model.BitmapDescriptor? = null | |
| 626 | private var lifecycleStarted = false | |
| 627 | private var destroyed = false | |
| 628 | private var initialCameraKey: String? = null | |
| 629 | ||
| 630 | init { | |
| 631 | addView(mapView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) | |
| 632 | scaleView.addView(scaleLabel) | |
| 633 | scaleView.addView(scaleLine, LinearLayout.LayoutParams(1, 2)) | |
| 634 | addView(scaleView, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.START).apply { | |
| 635 | leftMargin = (8 * resources.displayMetrics.density).roundToInt() | |
| 636 | bottomMargin = (24 * resources.displayMetrics.density).roundToInt() | |
| 637 | }) | |
| 638 | scaleView.visibility = View.GONE | |
| 639 | addView(tileAttribution, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.END).apply { rightMargin = (8 * resources.displayMetrics.density).roundToInt(); bottomMargin = (8 * resources.displayMetrics.density).roundToInt() }) | |
| 640 | mapView.onCreate(null) | |
| 641 | mapView.getMapAsync(this) | |
| 642 | } | |
| 643 | ||
| 644 | override fun onMapReady(map: GoogleMap) { | |
| 645 | if (destroyed) return | |
| 646 | googleMap = map | |
| 647 | map.setOnMapClickListener { point -> onMapPress?.invoke(point) } | |
| 648 | map.setOnCameraMoveStartedListener { reason -> | |
| 649 | if (reason == GoogleMap.OnCameraMoveStartedListener.REASON_GESTURE) userChangingCamera = true | |
| 650 | } | |
| 651 | map.setOnCameraMoveListener { updateScale() } | |
| 652 | map.setOnCameraIdleListener { | |
| 653 | updateScale() | |
| 654 | if (userChangingCamera) { | |
| 655 | userChangingCamera = false | |
| 656 | val bounds = map.projection.visibleRegion.latLngBounds | |
| 657 | val position = map.cameraPosition | |
| 658 | val longitudeDelta = (bounds.northeast.longitude - bounds.southwest.longitude + 360) % 360 | |
| 659 | onRegionChangeComplete?.invoke(JSONObject() | |
| 660 | .put("center", JSONObject().put("latitude", position.target.latitude).put("longitude", position.target.longitude)) | |
| 661 | .put("span", JSONObject().put("latitudeDelta", bounds.northeast.latitude - bounds.southwest.latitude).put("longitudeDelta", longitudeDelta)) | |
| 662 | .put("heading", position.bearing).put("pitch", position.tilt)) | |
| 663 | } | |
| 664 | } | |
| 665 | map.setOnMarkerClickListener { marker -> | |
| 666 | val id = markers.entries.firstOrNull { it.value == marker }?.key ?: return@setOnMarkerClickListener false | |
| 667 | onMarkerTap?.invoke(id) | |
| 668 | // The native contract dispatches the marker event itself. Consume | |
| 669 | // the click so Google Maps does not also show an unsolicited info | |
| 670 | // window for the marker title. | |
| 671 | true | |
| 672 | } | |
| 673 | applyPending() | |
| 674 | } | |
| 675 | ||
| 676 | fun configure(props: JSONObject, children: JSONArray, markerHandler: (String) -> Unit) { | |
| 677 | pendingProps = props | |
| 678 | pendingChildren = children | |
| 679 | onMarkerTap = markerHandler | |
| 680 | applyPending() | |
| 681 | } | |
| 682 | ||
| 683 | fun markerHandler(id: String): String? = markerHandlers[id] | |
| 684 | ||
| 685 | private fun applyPending() { | |
| 686 | val map = googleMap ?: return | |
| 687 | applyMapPadding() | |
| 688 | map.uiSettings.isCompassEnabled = craftNativeBoolean(pendingProps.opt("showsCompass")) | |
| 689 | // The toolbar is replaced by authored actions; the scale uses the native projection. | |
| 690 | map.uiSettings.isMapToolbarEnabled = false | |
| 691 | map.uiSettings.isScrollGesturesEnabled = craftNativeBoolean(pendingProps.opt("scrollEnabled"), true) | |
| 692 | map.uiSettings.isZoomGesturesEnabled = craftNativeBoolean(pendingProps.opt("zoomEnabled"), true) | |
| 693 | map.uiSettings.isRotateGesturesEnabled = craftNativeBoolean(pendingProps.opt("rotateEnabled")) | |
| 694 | map.mapType = when (craftNativeOptionalString(pendingProps.opt("mapType")).orEmpty().lowercase()) { | |
| 695 | "satellite" -> GoogleMap.MAP_TYPE_SATELLITE | |
| 696 | "hybrid" -> GoogleMap.MAP_TYPE_HYBRID | |
| 697 | "terrain" -> GoogleMap.MAP_TYPE_TERRAIN | |
| 698 | else -> GoogleMap.MAP_TYPE_NORMAL | |
| 699 | } | |
| 700 | applyTileOverlay() | |
| 701 | val showUserLocation = craftNativeBoolean(pendingProps.opt("showsUserLocation")) && hasLocationPermission() | |
| 702 | runCatching { map.isMyLocationEnabled = showUserLocation } | |
| 703 | val nextMarkers = mutableMapOf<String, Pair<LatLng, String?>>() | |
| 704 | val nextBearings = mutableMapOf<String, Float>() | |
| 705 | val nextHandlers = mutableMapOf<String, String>() | |
| 706 | val nextPolylines = mutableMapOf<String, Triple<List<LatLng>, Int, Float>>() | |
| 707 | for (index in 0 until pendingChildren.length()) { | |
| 708 | val child = pendingChildren.optJSONObject(index) ?: continue | |
| 709 | val type = child.optString("type").lowercase() | |
| 710 | val props = child.optJSONObject("props") ?: JSONObject() | |
| 711 | val events = child.optJSONObject("events") ?: JSONObject() | |
| 712 | val id = craftNativeOptionalString(props.opt("id")) | |
| 713 | ?: craftNativeOptionalString(child.opt("id")) | |
| 714 | ?: "overlay-$index" | |
| 715 | val points = coordinates(props.optJSONArray("geometry")) | |
| 716 | if (type == "mapmarker" || type == "marker") { | |
| 717 | points.firstOrNull()?.let { point -> | |
| 718 | nextMarkers[id] = point to (props.opt("title") as? String) | |
| 719 | (props.opt("bearing") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.let { nextBearings[id] = (it % 360).toFloat() } | |
| 720 | listOf(events.opt("onPress"), events.opt("onMarkerPress")) | |
| 721 | .firstNotNullOfOrNull(::craftNativeOptionalString) | |
| 722 | ?.let { nextHandlers[id] = it } | |
| 723 | } | |
| 724 | } else if (type == "mappolyline" || type == "polyline") { | |
| 725 | if (points.size > 1) { | |
| 726 | // Keep malformed authored widths inert, matching the iOS | |
| 727 | // host. An omitted or null width still gets the shared | |
| 728 | // default; an attacker-controlled NaN, infinity, zero, | |
| 729 | // or negative value must not become a valid overlay. | |
| 730 | val rawWidth = props.opt("strokeWidth") | |
| 731 | val authoredWidth = when (rawWidth) { | |
| 732 | null, JSONObject.NULL -> 3.0 | |
| 733 | is Number -> rawWidth.toDouble() | |
| 734 | else -> 3.0 | |
| 735 | } | |
| 736 | // MapKit's lineWidth is expressed in points, while the | |
| 737 | // Google Maps SDK consumes screen pixels. Keep the | |
| 738 | // protocol in logical points and convert only at this | |
| 739 | // Android host boundary so dense displays stay in parity. | |
| 740 | val width = (authoredWidth * resources.displayMetrics.density).toFloat() | |
| 741 | if (!authoredWidth.isFinite() || !width.isFinite() || width <= 0f) continue | |
| 742 | nextPolylines[id] = Triple(points, color(craftNativeOptionalString(props.opt("strokeColor")).orEmpty()), width) | |
| 743 | } | |
| 744 | } | |
| 745 | } | |
| 746 | markerBearings = nextBearings | |
| 747 | markers.keys.filter { nextMarkers[it] == null }.forEach { markers.remove(it)?.remove() } | |
| 748 | for ((id, value) in nextMarkers) { | |
| 749 | val marker = markers[id] | |
| 750 | if (marker != null) { | |
| 751 | marker.position = value.first | |
| 752 | marker.title = value.second | |
| 753 | marker.setIcon(if (markerBearings.containsKey(id)) directionMarkerIcon() else BitmapDescriptorFactory.defaultMarker()) | |
| 754 | marker.isFlat = markerBearings.containsKey(id) | |
| 755 | marker.rotation = markerBearings[id] ?: 0f | |
| 756 | marker.setAnchor(0.5f, if (markerBearings.containsKey(id)) 0.5f else 1f) | |
| 757 | } else { | |
| 758 | map.addMarker(MarkerOptions().position(value.first).title(value.second).icon(if (markerBearings.containsKey(id)) directionMarkerIcon() else BitmapDescriptorFactory.defaultMarker()).flat(markerBearings.containsKey(id)).rotation(markerBearings[id] ?: 0f).anchor(0.5f, if (markerBearings.containsKey(id)) 0.5f else 1f))?.let { markers[id] = it } | |
| 759 | } | |
| 760 | } | |
| 761 | markerHandlers.clear() | |
| 762 | markerHandlers.putAll(nextHandlers) | |
| 763 | polylines.keys.filter { nextPolylines[it] == null }.forEach { id -> | |
| 764 | polylines.remove(id)?.remove() | |
| 765 | } | |
| 766 | for ((id, value) in nextPolylines) { | |
| 767 | val polyline = polylines[id] | |
| 768 | if (polyline != null) { | |
| 769 | polyline.points = value.first | |
| 770 | polyline.color = value.second | |
| 771 | polyline.width = value.third | |
| 772 | } else { | |
| 773 | map.addPolyline(PolylineOptions().addAll(value.first).color(value.second).width(value.third))?.let { polylines[id] = it } | |
| 774 | } | |
| 775 | } | |
| 776 | val explicitRegion = region(pendingProps.optJSONObject("region")) | |
| 777 | val initialRegion = region(pendingProps.optJSONObject("initialRegion")) | |
| 778 | if (explicitRegion != null) { | |
| 779 | // A controlled region is an exact camera request. The padding | |
| 780 | // used for fitToBounds is framing chrome, not part of the region | |
| 781 | // contract, and would otherwise zoom every Android screen out. | |
| 782 | moveCamera(explicitRegion, padding = 0) | |
| 783 | initialCameraKey = null | |
| 784 | } else if (initialRegion != null) { | |
| 785 | val key = "region:${pendingProps.optJSONObject("initialRegion")}" | |
| 786 | if (initialCameraKey != key) { | |
| 787 | moveCamera(initialRegion, padding = 0) | |
| 788 | initialCameraKey = key | |
| 789 | } | |
| 790 | } else if (craftNativeBoolean(pendingProps.opt("fitToBounds"))) { | |
| 791 | val key = "fit:${overlayKey(nextMarkers, nextPolylines)}:${pendingProps.optJSONObject("edgePadding")}" | |
| 792 | if (initialCameraKey != key) { | |
| 793 | if (fitToBounds()) initialCameraKey = key | |
| 794 | } | |
| 795 | } else { | |
| 796 | initialCameraKey = null | |
| 797 | } | |
| 798 | applyCameraOrientation() | |
| 799 | updateScale() | |
| 800 | } | |
| 801 | ||
| 802 | private fun applyTileOverlay() { | |
| 803 | val map = googleMap ?: return | |
| 804 | val options = pendingProps.optJSONObject("tileOverlay") | |
| 805 | val template = options?.optString("urlTemplate").orEmpty() | |
| 806 | val credit = options?.optString("attribution").orEmpty() | |
| 807 | val next = template.takeIf { it.startsWith("https://") && listOf("{z}", "{x}", "{y}").all { key -> it.contains(key) } && credit.isNotBlank() } | |
| 808 | if (next != tileTemplate) { | |
| 809 | tileOverlay?.remove(); tileOverlay = null; tileTemplate = next | |
| 810 | if (next != null) { | |
| 811 | val provider = object : com.google.android.gms.maps.model.UrlTileProvider(256, 256) { | |
| 812 | override fun getTileUrl(x: Int, y: Int, zoom: Int): java.net.URL? = runCatching { java.net.URL(next.replace("{x}", x.toString()).replace("{y}", y.toString()).replace("{z}", zoom.toString())) }.getOrNull() | |
| 813 | } | |
| 814 | tileOverlay = map.addTileOverlay(com.google.android.gms.maps.model.TileOverlayOptions().tileProvider(provider).zIndex(-1f)) | |
| 815 | } | |
| 816 | } | |
| 817 | if (next != null) map.mapType = GoogleMap.MAP_TYPE_NONE | |
| 818 | tileAttribution.text = credit | |
| 819 | tileAttribution.visibility = if (next == null) View.GONE else View.VISIBLE | |
| 820 | } | |
| 821 | ||
| 822 | private fun applyMapPadding() { | |
| 823 | val authored = pendingProps.optJSONObject("edgePadding") | |
| 824 | fun inset(name: String): Double = (authored?.opt(name) as? Number)?.toDouble()?.takeIf { it.isFinite() }?.coerceIn(0.0, 10_000.0)?.times(resources.displayMetrics.density) ?: 0.0 | |
| 825 | var left = inset("left"); var top = inset("top"); var right = inset("right"); var bottom = inset("bottom") | |
| 826 | val horizontal = left + right | |
| 827 | val vertical = top + bottom | |
| 828 | val availableWidth = (width - 40 * resources.displayMetrics.density).coerceAtLeast(0f).toDouble() | |
| 829 | val availableHeight = (height - 40 * resources.displayMetrics.density).coerceAtLeast(0f).toDouble() | |
| 830 | if (horizontal > availableWidth && horizontal > 0) { left *= availableWidth / horizontal; right *= availableWidth / horizontal } | |
| 831 | if (vertical > availableHeight && vertical > 0) { top *= availableHeight / vertical; bottom *= availableHeight / vertical } | |
| 832 | googleMap?.setPadding(left.roundToInt(), top.roundToInt(), right.roundToInt(), bottom.roundToInt()) | |
| 833 | } | |
| 834 | ||
| 835 | private fun updateScale() { | |
| 836 | val map = googleMap ?: return | |
| 837 | if (!craftNativeBoolean(pendingProps.opt("showsScale")) || width <= 0 || height <= 0) { scaleView.visibility = View.GONE; return } | |
| 838 | val maximumWidth = (80 * resources.displayMetrics.density).roundToInt() | |
| 839 | val origin = map.projection.fromScreenLocation(android.graphics.Point(width / 2, height / 2)) | |
| 840 | val end = map.projection.fromScreenLocation(android.graphics.Point(width / 2 + maximumWidth, height / 2)) | |
| 841 | val distance = FloatArray(1) | |
| 842 | android.location.Location.distanceBetween(origin.latitude, origin.longitude, end.latitude, end.longitude, distance) | |
| 843 | val metres = distance[0].toDouble() | |
| 844 | if (!metres.isFinite() || metres <= 0) { scaleView.visibility = View.GONE; return } | |
| 845 | val magnitude = Math.pow(10.0, kotlin.math.floor(kotlin.math.log10(metres))) | |
| 846 | val length = listOf(5.0, 2.0, 1.0).firstOrNull { it * magnitude <= metres }?.times(magnitude) ?: magnitude | |
| 847 | scaleLabel.text = if (length >= 1000) "${length / 1000} km" else "${length.roundToInt()} m" | |
| 848 | scaleLine.layoutParams = LinearLayout.LayoutParams((maximumWidth * length / metres).roundToInt().coerceAtLeast(1), 2) | |
| 849 | scaleView.visibility = View.VISIBLE | |
| 850 | } | |
| 851 | ||
| 852 | private fun applyCameraOrientation() { | |
| 853 | val map = googleMap ?: return | |
| 854 | val camera = map.cameraPosition | |
| 855 | val pitch = (pendingProps.opt("cameraPitch") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.coerceIn(0.0, 60.0)?.toFloat() ?: camera.tilt | |
| 856 | val heading = (pendingProps.opt("cameraHeading") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.toFloat() ?: camera.bearing | |
| 857 | if (kotlin.math.abs(camera.tilt - pitch) > 0.01f || kotlin.math.abs(camera.bearing - heading) > 0.01f) { | |
| 858 | map.moveCamera(CameraUpdateFactory.newCameraPosition(com.google.android.gms.maps.model.CameraPosition.Builder(camera).tilt(pitch).bearing(heading).build())) | |
| 859 | } | |
| 860 | } | |
| 861 | ||
| 862 | private fun directionMarkerIcon(): com.google.android.gms.maps.model.BitmapDescriptor { | |
| 863 | directionIcon?.let { return it } | |
| 864 | val size = (26 * resources.displayMetrics.density).roundToInt().coerceAtLeast(24) | |
| 865 | val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) | |
| 866 | val canvas = Canvas(bitmap) | |
| 867 | val paint = Paint(Paint.ANTI_ALIAS_FLAG) | |
| 868 | paint.color = Color.WHITE | |
| 869 | canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint) | |
| 870 | paint.color = Color.rgb(36, 114, 75) | |
| 871 | canvas.drawCircle(size / 2f, size / 2f, size * 0.43f, paint) | |
| 872 | paint.color = Color.WHITE | |
| 873 | paint.textSize = size * 0.76f | |
| 874 | paint.textAlign = Paint.Align.CENTER | |
| 875 | paint.typeface = Typeface.DEFAULT_BOLD | |
| 876 | canvas.drawText("↑", size / 2f, size / 2f - (paint.ascent() + paint.descent()) / 2, paint) | |
| 877 | return BitmapDescriptorFactory.fromBitmap(bitmap).also { directionIcon = it } | |
| 878 | } | |
| 879 | ||
| 880 | override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { | |
| 881 | super.onSizeChanged(width, height, oldWidth, oldHeight) | |
| 882 | applyMapPadding() | |
| 883 | updateScale() | |
| 884 | // MapView can become ready before its host has a usable viewport. Do | |
| 885 | // not mark a fit as complete until the camera update can be issued. | |
| 886 | if (width > 0 && height > 0 && craftNativeBoolean(pendingProps.opt("fitToBounds")) && initialCameraKey == null) { | |
| 887 | applyPending() | |
| 888 | } | |
| 889 | } | |
| 890 | ||
| 891 | private fun moveCamera(bounds: LatLngBounds, padding: Int = 24) { | |
| 892 | post { | |
| 893 | if (width > 0 && height > 0) { | |
| 894 | googleMap?.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, padding)) | |
| 895 | applyCameraOrientation() | |
| 896 | } | |
| 897 | } | |
| 898 | } | |
| 899 | ||
| 900 | private fun overlayKey( | |
| 901 | markers: Map<String, Pair<LatLng, String?>>, | |
| 902 | polylines: Map<String, Triple<List<LatLng>, Int, Float>>, | |
| 903 | ): String { | |
| 904 | val markerKey = markers.toSortedMap().entries.joinToString("|") { (id, value) -> | |
| 905 | "$id:${value.first.latitude},${value.first.longitude}" | |
| 906 | } | |
| 907 | val polylineKey = polylines.toSortedMap().entries.joinToString("|") { (id, value) -> | |
| 908 | "$id:" + value.first.joinToString(";") { point -> "${point.latitude},${point.longitude}" } | |
| 909 | } | |
| 910 | return "m[$markerKey]p[$polylineKey]" | |
| 911 | } | |
| 912 | ||
| 913 | private fun fitToBounds(): Boolean { | |
| 914 | val map = googleMap ?: return false | |
| 915 | if (width <= 0 || height <= 0) return false | |
| 916 | val builder = LatLngBounds.Builder() | |
| 917 | var count = 0 | |
| 918 | markers.values.forEach { builder.include(it.position); count++ } | |
| 919 | polylines.values.flatMap { it.points }.forEach { builder.include(it); count++ } | |
| 920 | if (count == 0) return false | |
| 921 | post { | |
| 922 | if (width > 0 && height > 0) { | |
| 923 | applyMapPadding() | |
| 924 | val padding = if (pendingProps.has("edgePadding")) 0 else (24 * resources.displayMetrics.density).roundToInt() | |
| 925 | map.moveCamera(CameraUpdateFactory.newLatLngBounds(builder.build(), padding)) | |
| 926 | applyCameraOrientation() | |
| 927 | } | |
| 928 | } | |
| 929 | return true | |
| 930 | } | |
| 931 | ||
| 932 | override fun onAttachedToWindow() { | |
| 933 | super.onAttachedToWindow() | |
| 934 | if (destroyed) return | |
| 935 | mapView.onStart() | |
| 936 | mapView.onResume() | |
| 937 | lifecycleStarted = true | |
| 938 | } | |
| 939 | ||
| 940 | override fun onDetachedFromWindow() { | |
| 941 | if (lifecycleStarted) { | |
| 942 | mapView.onPause() | |
| 943 | mapView.onStop() | |
| 944 | lifecycleStarted = false | |
| 945 | } | |
| 946 | super.onDetachedFromWindow() | |
| 947 | } | |
| 948 | ||
| 949 | fun destroy() { | |
| 950 | if (destroyed) return | |
| 951 | destroyed = true | |
| 952 | if (lifecycleStarted) { | |
| 953 | mapView.onPause() | |
| 954 | mapView.onStop() | |
| 955 | lifecycleStarted = false | |
| 956 | } | |
| 957 | onMarkerTap = null | |
| 958 | onMapPress = null | |
| 959 | onRegionChangeComplete = null | |
| 960 | directionIcon = null | |
| 961 | tileOverlay?.remove(); tileOverlay = null | |
| 962 | markerHandlers.clear() | |
| 963 | markers.values.forEach { it.remove() } | |
| 964 | markers.clear() | |
| 965 | polylines.values.forEach { it.remove() } | |
| 966 | polylines.clear() | |
| 967 | mapView.onDestroy() | |
| 968 | googleMap = null | |
| 969 | } | |
| 970 | ||
| 971 | fun lowMemory() { | |
| 972 | if (!destroyed) mapView.onLowMemory() | |
| 973 | } | |
| 974 | ||
| 975 | private fun hasLocationPermission(): Boolean = | |
| 976 | ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || | |
| 977 | ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED | |
| 978 | ||
| 979 | private fun coordinates(geometry: JSONArray?): List<LatLng> { | |
| 980 | if (geometry == null) return emptyList() | |
| 981 | return (0 until geometry.length() - 1 step 2).mapNotNull { index -> | |
| 982 | val latitude = finiteNumber(geometry.opt(index)) ?: return@mapNotNull null | |
| 983 | val longitude = finiteNumber(geometry.opt(index + 1)) ?: return@mapNotNull null | |
| 984 | if (latitude.isFinite() && longitude.isFinite() && | |
| 985 | latitude >= -90.0 && latitude <= 90.0 && | |
| 986 | longitude >= -180.0 && longitude <= 180.0 | |
| 987 | ) LatLng(latitude, longitude) else null | |
| 988 | } | |
| 989 | } | |
| 990 | ||
| 991 | private fun region(value: JSONObject?): LatLngBounds? { | |
| 992 | val center = value?.optJSONObject("center") ?: return null | |
| 993 | val span = value.optJSONObject("span") ?: return null | |
| 994 | val latitude = finiteNumber(center.opt("latitude")) ?: return null | |
| 995 | val longitude = finiteNumber(center.opt("longitude")) ?: return null | |
| 996 | val latitudeDelta = finiteNumber(span.opt("latitudeDelta")) | |
| 997 | ?: finiteNumber(span.opt("latitude_delta")) | |
| 998 | ?: return null | |
| 999 | val longitudeDelta = finiteNumber(span.opt("longitudeDelta")) | |
| 1000 | ?: finiteNumber(span.opt("longitude_delta")) | |
| 1001 | ?: return null | |
| 1002 | if (!latitude.isFinite() || !longitude.isFinite() || !latitudeDelta.isFinite() || !longitudeDelta.isFinite() || | |
| 1003 | latitude < -90.0 || latitude > 90.0 || longitude < -180.0 || longitude > 180.0 || | |
| 1004 | latitudeDelta <= 0.0 || latitudeDelta > 180.0 || | |
| 1005 | longitudeDelta <= 0.0 || longitudeDelta > 360.0 || | |
| 1006 | latitude - latitudeDelta / 2.0 < -90.0 || latitude + latitudeDelta / 2.0 > 90.0 | |
| 1007 | ) return null | |
| 1008 | return LatLngBounds(LatLng(latitude - latitudeDelta / 2, longitude - longitudeDelta / 2), LatLng(latitude + latitudeDelta / 2, longitude + longitudeDelta / 2)) | |
| 1009 | } | |
| 1010 | ||
| 1011 | private fun finiteNumber(value: Any?): Double? = | |
| 1012 | (value as? Number)?.toDouble()?.takeIf { it.isFinite() } | |
| 1013 | ||
| 1014 | private fun color(value: String): Int = runCatching { craftNativeColor(value) }.getOrDefault(Color.BLUE) | |
| 1015 | } | |
| 590 | ||
| 1016 | 591 | |
| 1017 | 592 | /** A finite celebration owned by a View, never by the JavaScript frame loop. */ |
| 1018 | 593 | internal class CraftNativeCelebration(private val view: View) { |
| @@ -7000,6 +6575,7 @@ class MainActivity : AppCompatActivity() { | ||
| 7000 | 6575 | val label = craftNativeOptionalString(props.opt("accessibilityLabel")) |
| 7001 | 6576 | ?: craftNativeOptionalString(props.opt("alt"))?.takeIf { type == "Image" || type == "ImageBackground" } |
| 7002 | 6577 | val value = craftNativeOptionalString(props.opt("accessibilityValue")) |
| 6578 | val testId = craftNativeOptionalString(props.opt("testID")) | |
| 7003 | 6579 | view.contentDescription = when { |
| 7004 | 6580 | label != null && value != null -> "$label, $value" |
| 7005 | 6581 | label != null -> label |
| @@ -7024,6 +6600,9 @@ class MainActivity : AppCompatActivity() { | ||
| 7024 | 6600 | ViewCompat.setAccessibilityDelegate(view, object : AccessibilityDelegateCompat() { |
| 7025 | 6601 | override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) { |
| 7026 | 6602 | super.onInitializeAccessibilityNodeInfo(host, info) |
| 6603 | // Expose STX test identities to Android's native automation | |
| 6604 | // tree without replacing the label read by TalkBack. | |
| 6605 | info.viewIdResourceName = testId | |
| 7027 | 6606 | info.className = when (role) { |
| 7028 | 6607 | "button" -> Button::class.java.name |
| 7029 | 6608 | "image" -> ImageView::class.java.name |
| @@ -99,7 +99,7 @@ dependencies { | ||
| 99 | 99 | // Google Play Location Services (for geolocation) |
| 100 | 100 | implementation("com.google.android.gms:play-services-location:21.1.0") |
| 101 | 101 | |
| 102 | ||
| 102 | ||
| 103 | 103 | |
| 104 | 104 | // Google Play In-App Review |
| 105 | 105 | implementation("com.google.android.play:review:2.0.1") |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. |
| 2 | 2 | plugins { |
| 3 | 3 | id("com.android.application") version "8.9.1" apply false |
| 4 | id("org.jetbrains.kotlin.android") version "2.0.21" apply false | |
| 4 | id("org.jetbrains.kotlin.android") version "2.2.10" apply false | |
| 5 | 5 | |
| 6 | 6 | } |
| @@ -601,6 +601,37 @@ final class NativeLayoutUnitTests: XCTestCase { | ||
| 601 | 601 | XCTAssertEqual(after.maxX, before.maxX, accuracy: 1, "absolute sibling changed horizontal position") |
| 602 | 602 | } |
| 603 | 603 | |
| 604 | func testAbsoluteChildrenHonorSizeBoundsAndKeepTheirInsetAnchors() async throws { | |
| 605 | let controller = screen(box("root", style: ["height": 240], [ | |
| 606 | ["id": "expand", "type": "Button", "props": ["testID": "expand", "title": "+"], | |
| 607 | "style": ["position": "absolute", "top": 12, "right": 12, "minWidth": 44, "minHeight": 44]], | |
| 608 | box("bounded", style: ["position": "absolute", "right": 12, "bottom": 12, "width": 300, "height": 120, "maxWidth": "50%", "maxHeight": 60]), | |
| 609 | box("wide-ratio", style: ["position": "absolute", "left": 0, "top": 0, "width": 300, "aspectRatio": 2, "maxWidth": 100]), | |
| 610 | box("tall-ratio", style: ["position": "absolute", "left": 110, "top": 0, "height": 120, "aspectRatio": 2, "maxHeight": 60]), | |
| 611 | text("wrapped", "An absolutely positioned label still wraps at its authored maximum width", style: ["position": "absolute", "left": 12, "top": 12, "maxWidth": 80, "fontSize": 17]), | |
| 612 | ])) | |
| 613 | await settle(controller) | |
| 614 | let root = try frame("root", in: controller) | |
| 615 | let expand = try frame("expand", in: controller) | |
| 616 | XCTAssertGreaterThanOrEqual(expand.width, 44) | |
| 617 | XCTAssertEqual(expand.height, 44, accuracy: 0.5, "Floating buttons retain their minimum touch target") | |
| 618 | XCTAssertEqual(expand.maxX, root.maxX - 12, accuracy: 0.5) | |
| 619 | let bounded = try frame("bounded", in: controller) | |
| 620 | XCTAssertEqual(bounded.width, root.width / 2, accuracy: 0.5) | |
| 621 | XCTAssertEqual(bounded.height, 60, accuracy: 0.5) | |
| 622 | XCTAssertEqual(bounded.maxX, root.maxX - 12, accuracy: 0.5) | |
| 623 | XCTAssertEqual(bounded.maxY, root.maxY - 12, accuracy: 0.5) | |
| 624 | let wideRatio = try frame("wide-ratio", in: controller) | |
| 625 | XCTAssertEqual(wideRatio.width, 100, accuracy: 0.5) | |
| 626 | XCTAssertEqual(wideRatio.height, 50, accuracy: 0.5, "An automatic height follows the constrained width") | |
| 627 | let tallRatio = try frame("tall-ratio", in: controller) | |
| 628 | XCTAssertEqual(tallRatio.width, 120, accuracy: 0.5, "An automatic width follows the constrained height") | |
| 629 | XCTAssertEqual(tallRatio.height, 60, accuracy: 0.5) | |
| 630 | let wrapped = try frame("wrapped", in: controller) | |
| 631 | XCTAssertEqual(wrapped.width, 80, accuracy: 0.5) | |
| 632 | XCTAssertGreaterThan(wrapped.height, 44, "Width bounds participate in intrinsic text measurement") | |
| 633 | } | |
| 634 | ||
| 604 | 635 | func testAStyleUpdateIsAppliedInPlaceLayoutIncluded() throws { |
| 605 | 636 | let controller = screen(box("root", style: ["padding": 10], [ |
| 606 | 637 | box("grows", style: ["height": 24, "marginHorizontal": 48]), |
| @@ -1,11 +1,106 @@ | ||
| 1 | 1 | import Combine |
| 2 | 2 | import UIKit |
| 3 | 3 | import MapKit |
| 4 | import CoreLocation | |
| 4 | 5 | import XCTest |
| 5 | 6 | @testable import NativeRender |
| 6 | 7 | |
| 8 | private final class UnitLocationManager: CLLocationManager { | |
| 9 | var state: CLAuthorizationStatus = .notDetermined | |
| 10 | var whenInUseRequests = 0 | |
| 11 | var alwaysRequests = 0 | |
| 12 | var fixRequests = 0 | |
| 13 | var onAuthorizationRequest: (() -> Void)? | |
| 14 | override var authorizationStatus: CLAuthorizationStatus { state } | |
| 15 | override func requestWhenInUseAuthorization() { whenInUseRequests += 1; onAuthorizationRequest?() } | |
| 16 | override func requestAlwaysAuthorization() { alwaysRequests += 1; onAuthorizationRequest?() } | |
| 17 | override func requestLocation() { fixRequests += 1 } | |
| 18 | } | |
| 19 | ||
| 7 | 20 | @MainActor |
| 8 | 21 | final class NativeRenderUnitTests: XCTestCase { |
| 22 | func testOneShotLocationWaitsForPermissionBeforeStartingItsFixAndTimeout() throws { | |
| 23 | var config = CraftConfig() | |
| 24 | config.enableGeolocation = true | |
| 25 | config.enableBackgroundLocation = true | |
| 26 | let manager = UnitLocationManager() | |
| 27 | let coordinator = CraftWebView.Coordinator(config: config, locationManager: manager) | |
| 28 | let previous = CraftSwiftShim.coordinator | |
| 29 | CraftSwiftShim.coordinator = coordinator | |
| 30 | defer { CraftSwiftShim.coordinator = previous } | |
| 31 | let permission = expectation(description: "foreground location permission requested") | |
| 32 | manager.onAuthorizationRequest = { permission.fulfill() } | |
| 33 | let completed = expectation(description: "authorized native fix delivered") | |
| 34 | var answer: Result<Any, CraftNativeActionError>? | |
| 35 | CraftWebServices.perform(action: "getCurrentPosition", body: ["timeout": 20]) { result in | |
| 36 | answer = result | |
| 37 | completed.fulfill() | |
| 38 | } | |
| 39 | wait(for: [permission], timeout: 2) | |
| 40 | XCTAssertEqual(manager.whenInUseRequests, 1) | |
| 41 | XCTAssertEqual(manager.alwaysRequests, 0, "One foreground fix does not ask for background access") | |
| 42 | XCTAssertEqual(manager.fixRequests, 0) | |
| 43 | coordinator.locationManager(manager, didFailWithError: NSError(domain: kCLErrorDomain, code: CLError.Code.denied.rawValue)) | |
| 44 | let deciding = expectation(description: "person is still reading the permission sheet") | |
| 45 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { deciding.fulfill() } | |
| 46 | wait(for: [deciding], timeout: 2) | |
| 47 | XCTAssertNil(answer, "The acquisition timeout excludes time spent deciding permission") | |
| 48 | manager.state = .authorizedWhenInUse | |
| 49 | coordinator.locationManagerDidChangeAuthorization(manager) | |
| 50 | XCTAssertEqual(manager.fixRequests, 1) | |
| 51 | coordinator.locationManagerDidChangeAuthorization(manager) | |
| 52 | XCTAssertEqual(manager.fixRequests, 1, "Repeated authorization notifications do not restart acquisition") | |
| 53 | coordinator.locationManager(manager, didUpdateLocations: [CLLocation(latitude: 34, longitude: -118)]) | |
| 54 | wait(for: [completed], timeout: 2) | |
| 55 | guard case .success(let value)? = answer else { return XCTFail("Expected an authorized native fix") } | |
| 56 | XCTAssertEqual((value as? [String: Any])?["latitude"] as? Double, 34) | |
| 57 | } | |
| 58 | ||
| 59 | func testOneShotLocationRejectsDeniedPermissionWithoutRequestingAFix() { | |
| 60 | for status in [CLAuthorizationStatus.denied, .restricted] { | |
| 61 | var config = CraftConfig() | |
| 62 | config.enableGeolocation = true | |
| 63 | let manager = UnitLocationManager() | |
| 64 | manager.state = status | |
| 65 | let coordinator = CraftWebView.Coordinator(config: config, locationManager: manager) | |
| 66 | let previous = CraftSwiftShim.coordinator | |
| 67 | CraftSwiftShim.coordinator = coordinator | |
| 68 | let completed = expectation(description: "denied native request settled") | |
| 69 | CraftWebServices.perform(action: "getCurrentPosition", body: [:]) { result in | |
| 70 | guard case .failure(let error) = result else { completed.fulfill(); return XCTFail("Denied permission returned a fix") } | |
| 71 | XCTAssertEqual(error.code, "PERMISSION_DENIED") | |
| 72 | completed.fulfill() | |
| 73 | } | |
| 74 | wait(for: [completed], timeout: 2) | |
| 75 | XCTAssertEqual(manager.fixRequests, 0) | |
| 76 | XCTAssertEqual(manager.whenInUseRequests, 0) | |
| 77 | CraftSwiftShim.coordinator = previous | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | func testOneShotLocationRejectsWhenPermissionPromptIsDeclined() { | |
| 82 | var config = CraftConfig() | |
| 83 | config.enableGeolocation = true | |
| 84 | let manager = UnitLocationManager() | |
| 85 | let coordinator = CraftWebView.Coordinator(config: config, locationManager: manager) | |
| 86 | let previous = CraftSwiftShim.coordinator | |
| 87 | CraftSwiftShim.coordinator = coordinator | |
| 88 | defer { CraftSwiftShim.coordinator = previous } | |
| 89 | let permission = expectation(description: "foreground permission requested") | |
| 90 | manager.onAuthorizationRequest = { permission.fulfill() } | |
| 91 | let completed = expectation(description: "declined native request settled") | |
| 92 | CraftWebServices.perform(action: "getCurrentPosition", body: [:]) { result in | |
| 93 | guard case .failure(let error) = result else { completed.fulfill(); return XCTFail("Declined permission returned a fix") } | |
| 94 | XCTAssertEqual(error.code, "PERMISSION_DENIED") | |
| 95 | completed.fulfill() | |
| 96 | } | |
| 97 | wait(for: [permission], timeout: 2) | |
| 98 | manager.state = .denied | |
| 99 | coordinator.locationManagerDidChangeAuthorization(manager) | |
| 100 | wait(for: [completed], timeout: 2) | |
| 101 | XCTAssertEqual(manager.fixRequests, 0) | |
| 102 | } | |
| 103 | ||
| 9 | 104 | func testPanProgressDoesNotInvalidateTheWholeChrome() { |
| 10 | 105 | let chrome = CraftChrome.shared |
| 11 | 106 | let tabs = chrome.tabs |
| @@ -799,16 +894,88 @@ final class NativeRenderUnitTests: XCTestCase { | ||
| 799 | 894 | XCTAssertEqual(map.camera.pitch, 30, accuracy: 1) |
| 800 | 895 | let annotation = try XCTUnwrap(map.annotations.first) |
| 801 | 896 | let marker = try XCTUnwrap(map.delegate?.mapView?(map, viewFor: annotation)) |
| 802 | XCTAssertNotNil(marker.image) | |
| 897 | let image = try XCTUnwrap(marker.image) | |
| 898 | XCTAssertEqual(image.renderingMode, .alwaysOriginal, "MapKit must preserve the arrow's contrasting palette") | |
| 899 | XCTAssertLessThanOrEqual(image.size.width, 24, "Direction signs must leave the route visible in compact maps") | |
| 803 | 900 | XCTAssertEqual(marker.accessibilityIdentifier, "direction") |
| 804 | 901 | XCTAssertEqual(marker.transform.b, 1, accuracy: 0.02) |
| 805 | 902 | } |
| 806 | 903 | |
| 904 | func testMapPreservesAuthoredPolylineOrderAcrossUpdatesAndMoves() throws { | |
| 905 | let controller = CraftNativeScreenController(config: CraftConfig()) | |
| 906 | let window = try hostWindow() | |
| 907 | window.rootViewController = controller | |
| 908 | window.makeKeyAndVisible() | |
| 909 | defer { window.isHidden = true } | |
| 910 | controller.loadViewIfNeeded() | |
| 911 | func document(_ casingWidth: Double, reversed: Bool = false) -> [String: Any] { | |
| 912 | let casing: [String: Any] = ["type": "MapPolyline", "props": ["id": "casing", "geometry": [37.7, -119.5, 37.71, -119.51], "strokeColor": "#365b2d", "strokeWidth": casingWidth]] | |
| 913 | let route: [String: Any] = ["type": "MapPolyline", "props": ["id": "route", "geometry": [37.7, -119.5, 37.71, -119.51], "strokeColor": "#91e86b", "strokeWidth": 5]] | |
| 914 | return ["type": "Map", "props": ["key": "ordered-map"], | |
| 915 | "style": ["width": 320, "height": 320], | |
| 916 | "children": reversed ? [route, casing] : [casing, route]] | |
| 917 | } | |
| 918 | controller.render(document(9)) | |
| 919 | controller.view.layoutIfNeeded() | |
| 920 | let map = try XCTUnwrap(find(MKMapView.self, key: "ordered-map", below: controller.view)) | |
| 921 | func widths() throws -> [CGFloat] { | |
| 922 | try map.overlays.compactMap { $0 as? MKPolyline }.map { line in | |
| 923 | let renderer = try XCTUnwrap(map.delegate?.mapView?(map, rendererFor: line) as? MKPolylineRenderer) | |
| 924 | return renderer.lineWidth | |
| 925 | } | |
| 926 | } | |
| 927 | XCTAssertEqual(try widths(), [9, 5], "the bright route draws above its wider casing") | |
| 928 | let foreground = try XCTUnwrap(map.overlays.compactMap { $0 as? MKPolyline }.last) | |
| 929 | ||
| 930 | // Replacing a lower overlay must not move it above an unchanged line. | |
| 931 | controller.render(document(11)) | |
| 932 | controller.view.layoutIfNeeded() | |
| 933 | XCTAssertTrue(map === find(MKMapView.self, key: "ordered-map", below: controller.view)) | |
| 934 | XCTAssertEqual(try widths(), [11, 5]) | |
| 935 | XCTAssertTrue(map.overlays.compactMap { $0 as? MKPolyline }.last === foreground) | |
| 936 | ||
| 937 | // Child moves are draw-order changes even when geometry is unchanged. | |
| 938 | controller.render(document(11, reversed: true)) | |
| 939 | controller.view.layoutIfNeeded() | |
| 940 | XCTAssertEqual(try widths(), [5, 11]) | |
| 941 | controller.render(document(11)) | |
| 942 | controller.view.layoutIfNeeded() | |
| 943 | XCTAssertEqual(try widths(), [11, 5]) | |
| 944 | } | |
| 945 | ||
| 946 | func testMapUpdatesNativePinColorAndTiltGestureWithoutReplacingTheMarker() throws { | |
| 947 | let controller = CraftNativeScreenController(config: CraftConfig()) | |
| 948 | let window = try hostWindow() | |
| 949 | window.rootViewController = controller | |
| 950 | window.makeKeyAndVisible() | |
| 951 | defer { window.isHidden = true } | |
| 952 | controller.loadViewIfNeeded() | |
| 953 | func document(_ color: String, _ pitch: Bool) -> [String: Any] { | |
| 954 | ["type": "Map", "props": ["key": "colored-map", "pitchEnabled": pitch], | |
| 955 | "style": ["width": 320, "height": 320], | |
| 956 | "children": [["type": "MapMarker", "props": ["id": "start", "geometry": [37.7, -119.5], "pinColor": color]]]] | |
| 957 | } | |
| 958 | controller.render(document("#24724b", false)) | |
| 959 | controller.view.layoutIfNeeded() | |
| 960 | let map = try XCTUnwrap(find(MKMapView.self, key: "colored-map", below: controller.view)) | |
| 961 | let annotation = try XCTUnwrap(map.annotations.first) | |
| 962 | let marker = try XCTUnwrap(map.delegate?.mapView?(map, viewFor: annotation)) | |
| 963 | XCTAssertFalse(map.isPitchEnabled) | |
| 964 | XCTAssertEqual(marker.tintColor, UIColor(red: 36 / 255, green: 114 / 255, blue: 75 / 255, alpha: 1)) | |
| 965 | XCTAssertEqual(marker.image?.renderingMode, .alwaysOriginal) | |
| 966 | controller.render(document("#2878c8", true)) | |
| 967 | controller.view.layoutIfNeeded() | |
| 968 | XCTAssertTrue(map.isPitchEnabled) | |
| 969 | XCTAssertTrue(map.annotations.first === annotation) | |
| 970 | let updated = try XCTUnwrap(map.delegate?.mapView?(map, viewFor: annotation)) | |
| 971 | XCTAssertEqual(updated.tintColor, UIColor(red: 40 / 255, green: 120 / 255, blue: 200 / 255, alpha: 1)) | |
| 972 | } | |
| 973 | ||
| 807 | 974 | func testMapUsesNativeTopographicTilesWithAttribution() throws { |
| 808 | 975 | let controller = CraftNativeScreenController(config: CraftConfig()) |
| 809 | 976 | controller.loadViewIfNeeded() |
| 810 | 977 | controller.render([ |
| 811 | "type": "Map", "props": ["key": "topo-map", "tileOverlay": ["urlTemplate": "https://example.org/tiles/{z}/{y}/{x}.png", "attribution": "Test map data"]], | |
| 978 | "type": "Map", "props": ["key": "topo-map", "edgePadding": ["bottom": 160], "tileOverlay": ["urlTemplate": "https://example.org/tiles/{z}/{y}/{x}.png", "attribution": "Test map data"]], | |
| 812 | 979 | "style": ["width": 320, "height": 480], |
| 813 | 980 | "children": [["type": "MapPolyline", "props": ["geometry": [37.7, -119.5, 37.72, -119.49]]]], |
| 814 | 981 | ]) |
| @@ -817,7 +984,10 @@ final class NativeRenderUnitTests: XCTestCase { | ||
| 817 | 984 | XCTAssertTrue(tiles.canReplaceMapContent) |
| 818 | 985 | XCTAssertTrue(map.delegate?.mapView?(map, rendererFor: tiles) is MKTileOverlayRenderer) |
| 819 | 986 | XCTAssertTrue(map.overlays.last is MKPolyline, "The route must remain above the basemap") |
| 820 | XCTAssertTrue(map.subviews.compactMap { $0 as? UILabel }.contains { $0.text == "Test map data" && !$0.isHidden }) | |
| 987 | controller.view.layoutIfNeeded() | |
| 988 | map.layoutIfNeeded() | |
| 989 | let credit = try XCTUnwrap(map.subviews.compactMap { $0 as? UILabel }.first { $0.text == "Test map data" && !$0.isHidden }) | |
| 990 | XCTAssertLessThanOrEqual(credit.frame.maxY, map.bounds.height - 160, "The floating profile must not cover map attribution") | |
| 821 | 991 | } |
| 822 | 992 | |
| 823 | 993 | func testMapReservesRoomForFloatingControls() throws { |
| @@ -1701,6 +1871,43 @@ final class NativeRenderUnitTests: XCTestCase { | ||
| 1701 | 1871 | XCTAssertTrue(disabled.accessibilityTraits.contains(.notEnabled)) |
| 1702 | 1872 | } |
| 1703 | 1873 | |
| 1874 | func testHidingAModalDismissesItsKeyboardWithoutRestoringHiddenInputFocus() throws { | |
| 1875 | let controller = CraftNativeScreenController(config: CraftConfig()) | |
| 1876 | let window = try hostWindow() | |
| 1877 | window.rootViewController = controller | |
| 1878 | window.makeKeyAndVisible() | |
| 1879 | defer { window.isHidden = true } | |
| 1880 | controller.render(document([ | |
| 1881 | ["type": "TextInput", "props": ["key": "outside"]], | |
| 1882 | ["id": "review-modal", "type": "Modal", "props": ["key": "modal", "visible": true, "animationType": "none"], | |
| 1883 | "children": [["type": "TextInput", "props": ["key": "review", "multiline": true], "events": ["onChangeText": "reviewChanged"]]]], | |
| 1884 | ])) | |
| 1885 | controller.view.layoutIfNeeded() | |
| 1886 | let input = try XCTUnwrap(find(UITextView.self, key: "review", below: controller.view)) | |
| 1887 | XCTAssertTrue(input.becomeFirstResponder()) | |
| 1888 | input.text = "A trail review" | |
| 1889 | controller.textViewDidChange(input) | |
| 1890 | _ = try controller.applyMutation(batch(1, [ | |
| 1891 | ["op": "updateNode", "id": "review-modal", "patch": ["props": ["visible": false]]], | |
| 1892 | ])) | |
| 1893 | XCTAssertFalse(input.isFirstResponder, "Saving or cancelling a modal must dismiss its keyboard") | |
| 1894 | let settled = expectation(description: "hidden modal input acknowledgement settled") | |
| 1895 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { settled.fulfill() } | |
| 1896 | wait(for: [settled], timeout: 2) | |
| 1897 | XCTAssertFalse(input.isFirstResponder, "A delayed input acknowledgement cannot reopen the keyboard") | |
| 1898 | _ = try controller.applyMutation(batch(2, [ | |
| 1899 | ["op": "updateNode", "id": "review-modal", "patch": ["props": ["visible": true]]], | |
| 1900 | ])) | |
| 1901 | XCTAssertFalse(input.isFirstResponder, "Reopening the modal waits for an explicit focus") | |
| 1902 | XCTAssertTrue(input.becomeFirstResponder()) | |
| 1903 | let outside = try XCTUnwrap(find(UITextField.self, key: "outside", below: controller.view)) | |
| 1904 | XCTAssertTrue(outside.becomeFirstResponder()) | |
| 1905 | _ = try controller.applyMutation(batch(3, [ | |
| 1906 | ["op": "updateNode", "id": "review-modal", "patch": ["props": ["visible": false]]], | |
| 1907 | ])) | |
| 1908 | XCTAssertTrue(outside.isFirstResponder, "Hiding a modal must not dismiss another field's keyboard") | |
| 1909 | } | |
| 1910 | ||
| 1704 | 1911 | func testAShownModalInAScrollViewCoversTheScreenAndTakesItsTouches() throws { |
| 1705 | 1912 | let controller = CraftNativeScreenController(config: CraftConfig()) |
| 1706 | 1913 | let window = try hostWindow() |
| @@ -2250,7 +2250,7 @@ describe('Craft iOS builder', () => { | ||
| 2250 | 2250 | expect(noFixYet).toBeLessThan(body.indexOf('rejectCallback(')) |
| 2251 | 2251 | expect(noFixYet).toBeLessThan(body.indexOf('sendToWeb("craftLocationError"')) |
| 2252 | 2252 | // Asks again only for the caller that was told to wait. |
| 2253 | expect(body).toContain('guard let self, self.singleLocationCallbackId == waiting else { return }') | |
| 2253 | expect(body).toContain('guard let self, self.singleLocationCallbackId == waiting, self.singleLocationAcquisitionStarted else { return }') | |
| 2254 | 2254 | expect(body).toContain('self.locationManager?.requestLocation()') |
| 2255 | 2255 | }) |
| 2256 | 2256 | |
| @@ -97,6 +97,39 @@ describe('package-owned iOS shells', () => { | ||
| 97 | 97 | } |
| 98 | 98 | }) |
| 99 | 99 | |
| 100 | it('serializes native slider interactions and verifies app handoffs without launching another app', () => { | |
| 101 | expect(serializeSimulatorSteps([ | |
| 102 | { command: 'sliderid', id: 'elevation', position: 0.5 }, | |
| 103 | { command: 'expectapp', text: 'com.apple.Maps', seconds: 10 }, | |
| 104 | ])).toBe('sliderid elevation 0.5\nexpectapp com.apple.Maps | 10\n') | |
| 105 | for (const position of [-1, 1.1, NaN, Infinity]) | |
| 106 | expect(() => serializeSimulatorSteps([{ command: 'sliderid', id: 'elevation', position }])).toThrow() | |
| 107 | expect(() => serializeSimulatorSteps([{ command: 'sliderid', id: 'two sliders', position: 0.5 }])).toThrow() | |
| 108 | expect(() => serializeSimulatorSteps([{ command: 'expectapp', text: 'Maps\nlaunch' }])).toThrow() | |
| 109 | const output = folder() | |
| 110 | initSimulatorDriver({ output, bundleId: 'org.example.app' }) | |
| 111 | const source = readFileSync(join(output, 'UITests/DriverTests.swift'), 'utf8') | |
| 112 | expect(source).toContain('slider.adjust(toNormalizedSliderPosition: CGFloat(position))') | |
| 113 | const handoff = source.slice(source.indexOf('case "expectapp":'), source.indexOf('case "field":')) | |
| 114 | expect(handoff).toContain('target.wait(for: .runningForeground') | |
| 115 | expect(handoff).not.toContain('target.launch()') | |
| 116 | }) | |
| 117 | ||
| 118 | it('checks actual native field values after a real edit-menu gesture', () => { | |
| 119 | expect(serializeSimulatorSteps([ | |
| 120 | { command: 'longpressid', text: 'route-name' }, | |
| 121 | { command: 'tapexact', text: 'Paste' }, | |
| 122 | { command: 'valueid', id: 'route-name', text: 'https://example.org/trail/7' }, | |
| 123 | ])).toBe('longpressid route-name\ntapexact Paste\nvalueid route-name https://example.org/trail/7\n') | |
| 124 | expect(() => serializeSimulatorSteps([{ command: 'longpressid', text: 'two fields' }])).toThrow() | |
| 125 | expect(() => serializeSimulatorSteps([{ command: 'valueid', id: 'route-name', text: 'x\nlaunch' }])).toThrow() | |
| 126 | const output = folder() | |
| 127 | initSimulatorDriver({ output, bundleId: 'org.example.app' }) | |
| 128 | const source = readFileSync(join(output, 'UITests/DriverTests.swift'), 'utf8') | |
| 129 | expect(source).toContain('target.press(forDuration: 1.2)') | |
| 130 | expect(source).toContain('(target.value as? String) == want') | |
| 131 | }) | |
| 132 | ||
| 100 | 133 | it('bundles an on-device journey with native XCTest evidence and removes it when returning to server mode', () => { |
| 101 | 134 | const output = folder() |
| 102 | 135 | const options = { output, bundleId: 'org.example.app', script: 'activate\nshot current\ntree current\n' } |
| @@ -158,7 +191,7 @@ describe('package-owned iOS shells', () => { | ||
| 158 | 191 | initSimulatorDriver({ output, bundleId: 'org.example.app' }) |
| 159 | 192 | const source = readFileSync(join(output, 'UITests/DriverTests.swift'), 'utf8') |
| 160 | 193 | const handled = new Set([...source.matchAll(/case "([a-z]+)"/g)].map(match => match[1])) |
| 161 | const commands: SimulatorStep['command'][] = ['launch', 'activate', 'terminate', 'home', 'back', 'return', 'unlabelled', 'background', 'wait', 'delay', 'net', 'swipe', 'drag', 'edge', 'tabbar', 'tapxy', 'touches', 'field', 'typeid', 'labelid', 'expect', 'expectexact', 'expectid', 'tap', 'tapif', 'tapid', 'tapexact', 'type', 'absent', 'absentid', 'selected', 'tab', 'scrollto', 'dismiss', 'log', 'shot', 'tree', 'audit', 'exec', 'openurl', 'universallink', 'ifid', 'notifications', 'systemexpect', 'systemabsent', 'systemtap', 'systemtree'] | |
| 194 | const commands: SimulatorStep['command'][] = ['launch', 'activate', 'terminate', 'home', 'back', 'return', 'unlabelled', 'background', 'wait', 'delay', 'net', 'swipe', 'drag', 'edge', 'tabbar', 'tapxy', 'touches', 'field', 'typeid', 'labelid', 'valueid', 'sliderid', 'expectapp', 'expect', 'expectexact', 'expectid', 'tap', 'tapif', 'tapid', 'longpressid', 'tapexact', 'type', 'absent', 'absentid', 'selected', 'tab', 'scrollto', 'dismiss', 'log', 'shot', 'tree', 'audit', 'exec', 'openurl', 'universallink', 'ifid', 'notifications', 'systemexpect', 'systemabsent', 'systemtap', 'systemtree'] | |
| 162 | 195 | expect(commands.filter(command => !handled.has(command))).toEqual([]) |
| 163 | 196 | // ifid re-enters the same dispatcher, so a guarded command is any command. |
| 164 | 197 | expect(source).toContain('func run(_ line: String)') |
| @@ -97,9 +97,13 @@ export type SimulatorStep = | ||
| 97 | 97 | /** One native event record, without quiescence between paths. Times are seconds. */ |
| 98 | 98 | | { command: 'touches', paths: TimedTouchPoint[][] } |
| 99 | 99 | | { command: 'field', index: number, text: string } |
| 100 | | { command: 'typeid' | 'labelid', id: string, text: string } | |
| 100 | | { command: 'typeid' | 'labelid' | 'valueid', id: string, text: string } | |
| 101 | /** Adjust an actual native UISlider by identifier, using its normalized range. */ | |
| 102 | | { command: 'sliderid', id: string, position: number } | |
| 103 | /** Assert a handoff reached the foreground application without launching it. */ | |
| 104 | | { command: 'expectapp', text: string, seconds?: number } | |
| 101 | 105 | | { command: 'expect' | 'expectexact' | 'expectid' | 'systemexpect', text: string, seconds?: number } |
| 102 | | { command: 'tap' | 'tapif' | 'tapid' | 'tapexact' | 'type' | 'absent' | 'absentid' | 'selected' | 'tab' | 'scrollto' | 'dismiss' | 'log' | 'systemtap' | 'systemabsent', text: string } | |
| 106 | | { command: 'tap' | 'tapif' | 'tapid' | 'longpressid' | 'tapexact' | 'type' | 'absent' | 'absentid' | 'selected' | 'tab' | 'scrollto' | 'dismiss' | 'log' | 'systemtap' | 'systemabsent', text: string } | |
| 103 | 107 | | { command: 'shot' | 'tree' | 'audit' | 'exec' | 'systemtree', name: string } |
| 104 | 108 | | { command: 'openurl', url: string } |
| 105 | 109 | /** Use Apple's universalLinksOnly API, allowing association downloads up to 60 seconds. */ |
| @@ -152,9 +156,16 @@ export function serializeSimulatorSteps(steps: readonly SimulatorStep[]): string | ||
| 152 | 156 | if (!Number.isInteger(number(step.index))) throw new Error('field index must be an integer') |
| 153 | 157 | arg = `${step.index} ${step.text}` |
| 154 | 158 | } |
| 159 | else if ('position' in step) { | |
| 160 | if (number(step.position) > 1) throw new Error('Slider position must be between 0 and 1') | |
| 161 | arg = `${token(step.id)} ${step.position}` | |
| 162 | } | |
| 155 | 163 | else if ('id' in step) arg = `${token(step.id)} ${step.text}` |
| 156 | 164 | else if ('text' in step) { |
| 157 | 165 | arg = step.text |
| 166 | if (step.command === 'longpressid') token(arg) | |
| 167 | if (step.command === 'expectapp' && !/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(arg)) | |
| 168 | throw new Error('Expected an application bundle identifier') | |
| 158 | 169 | if ('seconds' in step && step.seconds !== undefined) { |
| 159 | 170 | if (arg.includes('|')) throw new Error('A timed expectation cannot contain |') |
| 160 | 171 | arg += ` | ${number(step.seconds)}` |
| @@ -1991,6 +1991,9 @@ struct CraftWebView: UIViewRepresentable { | ||
| 1991 | 1991 | private var locationManager: CLLocationManager? |
| 1992 | 1992 | private var singleLocationCallbackId: String? |
| 1993 | 1993 | private var singleLocationTimeoutWorkItem: DispatchWorkItem? |
| 1994 | private var singleLocationTimeoutMilliseconds: Double = 30_000 | |
| 1995 | private var singleLocationAcquisitionStarted = false | |
| 1996 | private var singleLocationAuthorizationRequested = false | |
| 1994 | 1997 | private var locationPermissionCallbackId: String? |
| 1995 | 1998 | private var locationPermissionRequiresAlways = false |
| 1996 | 1999 | private var isWatchingLocation = false |
| @@ -2065,7 +2068,7 @@ struct CraftWebView: UIViewRepresentable { | ||
| 2065 | 2068 | // SQLite database |
| 2066 | 2069 | private var db: OpaquePointer? |
| 2067 | 2070 | |
| 2068 | init(config: CraftConfig, recordingDirectory: URL? = nil) { | |
| 2071 | init(config: CraftConfig, locationManager suppliedLocationManager: CLLocationManager? = nil, recordingDirectory: URL? = nil) { | |
| 2069 | 2072 | self.recordingDirectory = recordingDirectory |
| 2070 | 2073 | self.config = config |
| 2071 | 2074 | super.init() |
| @@ -2073,7 +2076,7 @@ struct CraftWebView: UIViewRepresentable { | ||
| 2073 | 2076 | speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US")) |
| 2074 | 2077 | } |
| 2075 | 2078 | if config.enableGeolocation { |
| 2076 | locationManager = CLLocationManager() | |
| 2079 | locationManager = suppliedLocationManager ?? CLLocationManager() | |
| 2077 | 2080 | locationManager?.desiredAccuracy = kCLLocationAccuracyBest |
| 2078 | 2081 | locationManager?.activityType = .fitness |
| 2079 | 2082 | locationManager?.pausesLocationUpdatesAutomatically = false |
| @@ -6400,7 +6403,7 @@ struct CraftWebView: UIViewRepresentable { | ||
| 6400 | 6403 | } |
| 6401 | 6404 | singleLocationCallbackId = callbackId |
| 6402 | 6405 | let maximumAge = max(0, (body["maximumAge"] as? NSNumber)?.doubleValue ?? 0) |
| 6403 | if maximumAge > 0, | |
| 6406 | if (manager.authorizationStatus == .authorizedAlways || manager.authorizationStatus == .authorizedWhenInUse), maximumAge > 0, | |
| 6404 | 6407 | let cachedLocation = manager.location, |
| 6405 | 6408 | max(0, Date().timeIntervalSince(cachedLocation.timestamp) * 1000) <= maximumAge { |
| 6406 | 6409 | finishSingleLocationRequest() |
| @@ -6408,7 +6411,35 @@ struct CraftWebView: UIViewRepresentable { | ||
| 6408 | 6411 | return |
| 6409 | 6412 | } |
| 6410 | 6413 | |
| 6411 | let timeoutMs = max(0, (body["timeout"] as? NSNumber)?.doubleValue ?? 30_000) | |
| 6414 | singleLocationTimeoutMilliseconds = max(0, (body["timeout"] as? NSNumber)?.doubleValue ?? 30_000) | |
| 6415 | continueSingleLocationRequest(manager) | |
| 6416 | } | |
| 6417 | ||
| 6418 | private func continueSingleLocationRequest(_ manager: CLLocationManager) { | |
| 6419 | guard let callbackId = singleLocationCallbackId else { return } | |
| 6420 | switch manager.authorizationStatus { | |
| 6421 | case .notDetermined: | |
| 6422 | if !singleLocationAuthorizationRequested { | |
| 6423 | singleLocationAuthorizationRequested = true | |
| 6424 | manager.requestWhenInUseAuthorization() | |
| 6425 | } | |
| 6426 | return | |
| 6427 | case .denied, .restricted: | |
| 6428 | finishSingleLocationRequest() | |
| 6429 | rejectCallback(callbackId, error: "Location permission was denied.", code: "PERMISSION_DENIED") | |
| 6430 | return | |
| 6431 | case .authorizedAlways, .authorizedWhenInUse: | |
| 6432 | break | |
| 6433 | @unknown default: | |
| 6434 | finishSingleLocationRequest() | |
| 6435 | rejectCallback(callbackId, error: "Location authorization is unavailable.", code: "PERMISSION_DENIED") | |
| 6436 | return | |
| 6437 | } | |
| 6438 | guard !singleLocationAcquisitionStarted else { return } | |
| 6439 | singleLocationAcquisitionStarted = true | |
| 6440 | // The acquisition timeout starts after the person decides. A | |
| 6441 | // permission sheet is not a failed or timed-out GPS fix. | |
| 6442 | let timeoutMs = singleLocationTimeoutMilliseconds | |
| 6412 | 6443 | let timeoutWorkItem = DispatchWorkItem { [weak self] in |
| 6413 | 6444 | guard let self, self.singleLocationCallbackId == callbackId else { return } |
| 6414 | 6445 | self.finishSingleLocationRequest() |
| @@ -6416,7 +6447,6 @@ struct CraftWebView: UIViewRepresentable { | ||
| 6416 | 6447 | } |
| 6417 | 6448 | singleLocationTimeoutWorkItem = timeoutWorkItem |
| 6418 | 6449 | DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(min(timeoutMs + 100, Double(Int.max)))), execute: timeoutWorkItem) |
| 6419 | requestLocationAuthorization() | |
| 6420 | 6450 | manager.requestLocation() |
| 6421 | 6451 | } |
| 6422 | 6452 | |
| @@ -6424,6 +6454,8 @@ struct CraftWebView: UIViewRepresentable { | ||
| 6424 | 6454 | singleLocationTimeoutWorkItem?.cancel() |
| 6425 | 6455 | singleLocationTimeoutWorkItem = nil |
| 6426 | 6456 | singleLocationCallbackId = nil |
| 6457 | singleLocationAcquisitionStarted = false | |
| 6458 | singleLocationAuthorizationRequested = false | |
| 6427 | 6459 | locationManager?.desiredAccuracy = kCLLocationAccuracyBest |
| 6428 | 6460 | } |
| 6429 | 6461 | |
| @@ -6753,6 +6785,10 @@ struct CraftWebView: UIViewRepresentable { | ||
| 6753 | 6785 | |
| 6754 | 6786 | func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { |
| 6755 | 6787 | let nativeError = error as NSError |
| 6788 | if nativeError.domain == kCLErrorDomain, nativeError.code == CLError.Code.denied.rawValue, | |
| 6789 | manager.authorizationStatus == .notDetermined, singleLocationCallbackId != nil, !singleLocationAcquisitionStarted { | |
| 6790 | return | |
| 6791 | } | |
| 6756 | 6792 | // kCLErrorLocationUnknown is Core Location saying it has no fix |
| 6757 | 6793 | // *yet*, not that it failed. requestLocation gives up on it, so a |
| 6758 | 6794 | // one-shot asks again shortly instead of failing a caller whose |
| @@ -6761,7 +6797,7 @@ struct CraftWebView: UIViewRepresentable { | ||
| 6761 | 6797 | if nativeError.domain == kCLErrorDomain && nativeError.code == CLError.Code.locationUnknown.rawValue { |
| 6762 | 6798 | if let waiting = singleLocationCallbackId { |
| 6763 | 6799 | DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [weak self] in |
| 6764 | guard let self, self.singleLocationCallbackId == waiting else { return } | |
| 6800 | guard let self, self.singleLocationCallbackId == waiting, self.singleLocationAcquisitionStarted else { return } | |
| 6765 | 6801 | self.locationManager?.requestLocation() |
| 6766 | 6802 | } |
| 6767 | 6803 | } |
| @@ -6777,6 +6813,7 @@ struct CraftWebView: UIViewRepresentable { | ||
| 6777 | 6813 | } |
| 6778 | 6814 | |
| 6779 | 6815 | func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { |
| 6816 | if singleLocationCallbackId != nil { continueSingleLocationRequest(manager) } | |
| 6780 | 6817 | guard let callbackId = locationPermissionCallbackId else { return } |
| 6781 | 6818 | let status = manager.authorizationStatus |
| 6782 | 6819 | if status == .notDetermined { return } |
| @@ -1171,8 +1171,10 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate { | ||
| 1171 | 1171 | private var authoredHeading: Double? |
| 1172 | 1172 | private var edgePadding = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24) |
| 1173 | 1173 | private var markerBearings: [String: Double] = [:] |
| 1174 | private var markerPinColors: [String: UIColor] = [:] | |
| 1174 | 1175 | private var tileOverlay: MKTileOverlay? |
| 1175 | 1176 | private var tileTemplate: String? |
| 1177 | private var tileAttributionBottom: NSLayoutConstraint? | |
| 1176 | 1178 | private lazy var tileAttribution: UILabel = { |
| 1177 | 1179 | let label = UILabel() |
| 1178 | 1180 | label.font = .systemFont(ofSize: 9) |
| @@ -1181,7 +1183,9 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate { | ||
| 1181 | 1183 | label.translatesAutoresizingMaskIntoConstraints = false |
| 1182 | 1184 | label.numberOfLines = 2 |
| 1183 | 1185 | addSubview(label) |
| 1184 | NSLayoutConstraint.activate([label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 48)]) | |
| 1186 | let bottom = label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8) | |
| 1187 | tileAttributionBottom = bottom | |
| 1188 | NSLayoutConstraint.activate([label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), bottom, label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 48)]) | |
| 1185 | 1189 | return label |
| 1186 | 1190 | }() |
| 1187 | 1191 | |
| @@ -1206,6 +1210,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate { | ||
| 1206 | 1210 | isScrollEnabled = props["scrollEnabled"] as? Bool ?? true |
| 1207 | 1211 | isZoomEnabled = props["zoomEnabled"] as? Bool ?? true |
| 1208 | 1212 | isRotateEnabled = props["rotateEnabled"] as? Bool ?? false |
| 1213 | isPitchEnabled = props["pitchEnabled"] as? Bool ?? true | |
| 1209 | 1214 | mapType = Self.mapType(props["mapType"] as? String) |
| 1210 | 1215 | configureTiles(props["tileOverlay"] as? [String: Any]) |
| 1211 | 1216 | showsCompass = props["showsCompass"] as? Bool ?? false |
| @@ -1219,6 +1224,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate { | ||
| 1219 | 1224 | } |
| 1220 | 1225 | edgePadding = UIEdgeInsets(top: inset("top"), left: inset("left"), bottom: inset("bottom"), right: inset("right")) |
| 1221 | 1226 | var nextBearings: [String: Double] = [:] |
| 1227 | var nextPinColors: [String: UIColor] = [:] | |
| 1222 | 1228 | // Showing the user dot must not trigger a permission prompt from a |
| 1223 | 1229 | // renderer pass. The app's existing location capability owns that |
| 1224 | 1230 | // prompt; a map only opts in after authorization is already granted. |
| @@ -1227,6 +1233,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate { | ||
| 1227 | 1233 | var nextMarkers: [String: (coordinate: CLLocationCoordinate2D, title: String?)] = [:] |
| 1228 | 1234 | var nextHandlers: [String: String] = [:] |
| 1229 | 1235 | var nextPolylines: [String: (coordinates: [CLLocationCoordinate2D], color: UIColor, width: CGFloat)] = [:] |
| 1236 | var nextPolylineIDs: [String] = [] | |
| 1230 | 1237 | for (index, child) in children.enumerated() { |
| 1231 | 1238 | guard let node = child as? [String: Any], let rawType = node["type"] as? String else { continue } |
| 1232 | 1239 | let type = rawType.lowercased() |
| @@ -1239,6 +1246,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate { | ||
| 1239 | 1246 | if type == "mapmarker" || type == "marker" { |
| 1240 | 1247 | guard let point = points.first else { continue } |
| 1241 | 1248 | nextMarkers[id] = (point, childProps["title"] as? String) |
| 1249 | if let color = childProps["pinColor"] as? String { nextPinColors[id] = Self.color(color) } | |
| 1242 | 1250 | if let bearing = Self.numeric(childProps["bearing"]), bearing.isFinite { |
| 1243 | 1251 | nextBearings[id] = bearing.truncatingRemainder(dividingBy: 360) |
| 1244 | 1252 | } |
| @@ -1251,11 +1259,13 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate { | ||
| 1251 | 1259 | let authoredWidth = Self.numeric(childProps["strokeWidth"]) ?? 3 |
| 1252 | 1260 | let width = CGFloat(authoredWidth) |
| 1253 | 1261 | guard authoredWidth.isFinite, width.isFinite, width > 0 else { continue } |
| 1262 | if nextPolylines[id] == nil { nextPolylineIDs.append(id) } | |
| 1254 | 1263 | nextPolylines[id] = (points, Self.color(childProps["strokeColor"] as? String), width) |
| 1255 | 1264 | } |
| 1256 | 1265 | } |
| 1257 | 1266 | |
| 1258 | 1267 | markerBearings = nextBearings |
| 1268 | markerPinColors = nextPinColors | |
| 1259 | 1269 | let staleMarkers = annotationsByID.keys.filter { nextMarkers[$0] == nil } |
| 1260 | 1270 | removeAnnotations(staleMarkers.compactMap { annotationsByID.removeValue(forKey: $0) }) |
| 1261 | 1271 | for (id, value) in nextMarkers { |
| @@ -1282,7 +1292,8 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate { | ||
| 1282 | 1292 | } |
| 1283 | 1293 | overlayCoordinatesByID.removeValue(forKey: id) |
| 1284 | 1294 | } |
| 1285 | for (id, value) in nextPolylines { | |
| 1295 | for id in nextPolylineIDs { | |
| 1296 | guard let value = nextPolylines[id] else { continue } | |
| 1286 | 1297 | if let overlay = overlaysByID[id], |
| 1287 | 1298 | let coordinates = overlayCoordinatesByID[id], |
| 1288 | 1299 | Self.coordinatesEqual(coordinates, value.coordinates), |
| @@ -1302,6 +1313,19 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate { | ||
| 1302 | 1313 | overlayWidths[ObjectIdentifier(overlay)] = value.width |
| 1303 | 1314 | addOverlay(overlay) |
| 1304 | 1315 | } |
| 1316 | // Keep authored casing/foreground order when an earlier line changes | |
| 1317 | // or retained children move. Exchanging native overlays preserves the | |
| 1318 | // unchanged objects, tile levels and current camera. | |
| 1319 | let ownedOverlayIDs = Set(overlaysByID.values.map { ObjectIdentifier($0) }) | |
| 1320 | var orderedOverlays = overlays.compactMap { $0 as? MKPolyline } | |
| 1321 | .filter { ownedOverlayIDs.contains(ObjectIdentifier($0)) } | |
| 1322 | for (index, id) in nextPolylineIDs.enumerated() { | |
| 1323 | guard let overlay = overlaysByID[id], index < orderedOverlays.count, | |
| 1324 | orderedOverlays[index] !== overlay, | |
| 1325 | let previousIndex = orderedOverlays.firstIndex(where: { $0 === overlay }) else { continue } | |
| 1326 | exchangeOverlay(orderedOverlays[index], with: orderedOverlays[previousIndex]) | |
| 1327 | orderedOverlays.swapAt(index, previousIndex) | |
| 1328 | } | |
| 1305 | 1329 | |
| 1306 | 1330 | if let region = Self.region(props["region"]) { |
| 1307 | 1331 | // `region` is controlled: an authored change is allowed to move |
| @@ -1411,20 +1435,27 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate { | ||
| 1411 | 1435 | |
| 1412 | 1436 | private func configureMarkerView(_ view: MKAnnotationView, id: String) { |
| 1413 | 1437 | if let bearing = markerBearings[id] { |
| 1414 | let configuration = UIImage.SymbolConfiguration(pointSize: 24, weight: .semibold) | |
| 1438 | let configuration = UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold) | |
| 1415 | 1439 | .applying(UIImage.SymbolConfiguration(paletteColors: [.white, .systemGreen])) |
| 1416 | view.image = UIImage(systemName: "arrow.up.circle.fill", withConfiguration: configuration) | |
| 1440 | // MapKit otherwise tints this as a monochrome template and loses | |
| 1441 | // the contrasting route arrow when displayed over map content. | |
| 1442 | view.image = UIImage(systemName: "arrow.up.circle.fill", withConfiguration: configuration)? | |
| 1443 | .withRenderingMode(.alwaysOriginal) | |
| 1417 | 1444 | view.transform = CGAffineTransform(rotationAngle: CGFloat((bearing - camera.heading) * .pi / 180)) |
| 1418 | 1445 | } else { |
| 1446 | view.tintColor = markerPinColors[id] ?? .systemRed | |
| 1419 | 1447 | view.image = UIImage(systemName: "mappin.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28))? |
| 1420 | .withTintColor(.systemRed, renderingMode: .alwaysOriginal) | |
| 1448 | .withTintColor(view.tintColor, renderingMode: .alwaysOriginal) | |
| 1421 | 1449 | view.transform = .identity |
| 1422 | 1450 | } |
| 1423 | 1451 | } |
| 1424 | 1452 | |
| 1425 | 1453 | override func layoutSubviews() { |
| 1426 | 1454 | super.layoutSubviews() |
| 1427 | if tileTemplate != nil { bringSubviewToFront(tileAttribution) } | |
| 1455 | if tileTemplate != nil { | |
| 1456 | tileAttributionBottom?.constant = -min(edgePadding.bottom, max(0, bounds.height - 40)) - 8 | |
| 1457 | bringSubviewToFront(tileAttribution) | |
| 1458 | } | |
| 1428 | 1459 | if let region = pendingRegion, bounds.width > 0, bounds.height > 0 { |
| 1429 | 1460 | pendingRegion = nil |
| 1430 | 1461 | setRegion(region, animated: false) |
| @@ -2681,11 +2712,24 @@ private class CraftNativeFlowView: UIStackView { | ||
| 2681 | 2712 | var fixedHeight = style.top != nil && style.bottom != nil |
| 2682 | 2713 | ? max(0, content.height - (style.top ?? 0) - (style.bottom ?? 0)) |
| 2683 | 2714 | : style.height?.resolve(content.height) |
| 2715 | let authoredWidth = fixedWidth != nil | |
| 2716 | let authoredHeight = fixedHeight != nil | |
| 2684 | 2717 | (fixedWidth, fixedHeight) = aspectDimensions(style, width: fixedWidth, height: fixedHeight) |
| 2685 | let measured = CraftNativeFlexLayout.measure(child, width: fixedWidth, height: fixedHeight, maxWidth: content.width, maxHeight: content.height) | |
| 2686 | let x = style.left ?? (style.right.map { content.width - $0 - measured.width } ?? 0) | |
| 2687 | let y = style.top ?? (style.bottom.map { content.height - $0 - measured.height } ?? 0) | |
| 2688 | frames.append((child, CGRect(x: content.minX + x, y: content.minY + y, width: measured.width, height: measured.height))) | |
| 2718 | var measured = CraftNativeFlexLayout.measure(child, width: fixedWidth, height: fixedHeight, maxWidth: content.width, maxHeight: content.height) | |
| 2719 | var boundedWidth = clamp(measured.width, style.minWidth?.resolve(content.width), style.maxWidth?.resolve(content.width)) | |
| 2720 | let proposedHeight = !authoredHeight && style.aspectRatio != nil ? boundedWidth / style.aspectRatio! : fixedHeight | |
| 2721 | if abs(boundedWidth - measured.width) > 0.0001 || proposedHeight != fixedHeight { | |
| 2722 | // A narrower label or container must wrap before its | |
| 2723 | // height and bottom anchor are resolved. | |
| 2724 | measured = CraftNativeFlexLayout.measure(child, width: boundedWidth, height: proposedHeight, maxWidth: content.width, maxHeight: content.height) | |
| 2725 | } | |
| 2726 | let boundedHeight = clamp(measured.height, style.minHeight?.resolve(content.height), style.maxHeight?.resolve(content.height)) | |
| 2727 | if !authoredWidth, authoredHeight, let ratio = style.aspectRatio { | |
| 2728 | boundedWidth = clamp(boundedHeight * ratio, style.minWidth?.resolve(content.width), style.maxWidth?.resolve(content.width)) | |
| 2729 | } | |
| 2730 | let x = style.left ?? (style.right.map { content.width - $0 - boundedWidth } ?? 0) | |
| 2731 | let y = style.top ?? (style.bottom.map { content.height - $0 - boundedHeight } ?? 0) | |
| 2732 | frames.append((child, CGRect(x: content.minX + x, y: content.minY + y, width: boundedWidth, height: boundedHeight))) | |
| 2689 | 2733 | } |
| 2690 | 2734 | } |
| 2691 | 2735 | return (size, frames) |
| @@ -3370,6 +3414,8 @@ private final class CraftNativeModalView: UIView { | ||
| 3370 | 3414 | let generation = animationGeneration |
| 3371 | 3415 | layer.removeAllAnimations() |
| 3372 | 3416 | guard visible else { |
| 3417 | craftScreenController?.forgetFocusedInput(in: self) | |
| 3418 | endEditing(true) | |
| 3373 | 3419 | if animationType == "slide" && wasVisible { |
| 3374 | 3420 | let distance = max(bounds.height, superview?.bounds.height ?? UIScreen.main.bounds.height) |
| 3375 | 3421 | UIView.animate(withDuration: 0.24, animations: { |
| @@ -3409,6 +3455,8 @@ private final class CraftNativeModalView: UIView { | ||
| 3409 | 3455 | } |
| 3410 | 3456 | |
| 3411 | 3457 | func resetForTeardown() { |
| 3458 | craftScreenController?.forgetFocusedInput(in: self) | |
| 3459 | endEditing(true) | |
| 3412 | 3460 | animationGeneration += 1 |
| 3413 | 3461 | layer.removeAllAnimations() |
| 3414 | 3462 | isHidden = true |
| @@ -4326,6 +4374,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, | ||
| 4326 | 4374 | func forgetFocusedInput(_ input: UIView? = nil) { |
| 4327 | 4375 | if input == nil || lastFocusedInput === input { lastFocusedInput = nil } |
| 4328 | 4376 | } |
| 4377 | func forgetFocusedInput(in container: UIView) { | |
| 4378 | if let input = lastFocusedInput, input === container || input.isDescendant(of: container) { | |
| 4379 | lastFocusedInput = nil | |
| 4380 | } | |
| 4381 | } | |
| 4329 | 4382 | private var flatListRows: [ObjectIdentifier: [String: RenderedNode]] = [:] |
| 4330 | 4383 | private var flatListOwners: [String: String] = [:] |
| 4331 | 4384 | /// A drawing's shapes are not views: a change to one redraws its `Svg`. |
| @@ -6054,6 +6107,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, | ||
| 6054 | 6107 | private func restoreFocus(_ focused: UIView?) { |
| 6055 | 6108 | guard let focused, focused.window != nil, !focused.isFirstResponder, |
| 6056 | 6109 | lastFocusedInput === focused, firstResponder(in: rootStack) == nil else { return } |
| 6110 | var ancestor: UIView? = focused | |
| 6111 | while let view = ancestor { | |
| 6112 | if view.isHidden { return } | |
| 6113 | ancestor = view.superview | |
| 6114 | } | |
| 6057 | 6115 | // A delayed acknowledgement belongs to the input that scheduled it. |
| 6058 | 6116 | // A newer field or an explicit dismissal owns focus now. |
| 6059 | 6117 | _ = focused.becomeFirstResponder() |
| @@ -21,6 +21,7 @@ import Darwin | ||
| 21 | 21 | /// tap <text> first hittable element whose label contains text |
| 22 | 22 | /// tapif <text> tap it when it is on screen, else carry on (the big timer's "Show the controls") |
| 23 | 23 | /// tapid <identifier> |
| 24 | /// longpressid <identifier> holds a hittable native element to open its edit menu | |
| 24 | 25 | /// touches <JSON> timed normalized touch paths in one native dispatch |
| 25 | 26 | /// tapxy <x> <y> normalised 0…1 of the screen |
| 26 | 27 | /// tapexact <label> a button whose label is exactly this |
| @@ -29,11 +30,14 @@ import Darwin | ||
| 29 | 30 | /// return the keyboard's return key (Go on a sign-in form) |
| 30 | 31 | /// field <n> <text> tap the n-th text field (0-based, secure fields after plain) and type |
| 31 | 32 | /// typeid <identifier> <text> tap the element with this identifier and type |
| 33 | /// sliderid <identifier> <0…1> adjusts a hittable native slider | |
| 34 | /// expectapp <bundle-id> [| secs] asserts a native app handoff reached the foreground | |
| 32 | 35 | /// expect <text> [| secs] waits for an element containing text; logs PASS/FAIL |
| 33 | 36 | /// expectexact <label> [| secs] a button or text whose label is exactly this |
| 34 | 37 | /// absent <text> logs PASS when no element contains text |
| 35 | 38 | /// expectid <id> [| secs] / absentid <id> the same by accessibility identifier (testID) |
| 36 | 39 | /// labelid <id> <text> that element's label is exactly text (a count, a status) |
| 40 | /// valueid <id> <text> that element's actual string value is exactly text | |
| 37 | 41 | /// tabbar shown|hidden the native tab bar (Today, Calendar, …) can be tapped, or not |
| 38 | 42 | /// selected <tab> the tab bar's selected button is this one |
| 39 | 43 | /// tab <name> tap a tab bar button |
| @@ -297,6 +301,14 @@ final class DriverTests: XCTestCase { | ||
| 297 | 301 | let target = identified(arg) |
| 298 | 302 | if target.waitForExistence(timeout: 10) { press(target); note("tapid \(arg): ok") } |
| 299 | 303 | else { XCTFail("Element not found"); note("FAIL tapid \(arg): NOT FOUND") } |
| 304 | case "longpressid": | |
| 305 | let target = identified(arg) | |
| 306 | guard target.waitForExistence(timeout: 10), target.isHittable else { | |
| 307 | check(false, "longpressid \(arg) is hittable") | |
| 308 | return | |
| 309 | } | |
| 310 | target.press(forDuration: 1.2) | |
| 311 | note("longpressid \(arg): ok") | |
| 300 | 312 | case "touches": |
| 301 | 313 | do { |
| 302 | 314 | guard let data = arg.data(using: .utf8), |
| @@ -367,6 +379,23 @@ final class DriverTests: XCTestCase { | ||
| 367 | 379 | app.typeText(idParts.count > 1 ? idParts[1] : "") |
| 368 | 380 | note("typeid \(idParts[0]): ok") |
| 369 | 381 | } else { XCTFail("Element not found"); note("FAIL typeid \(idParts[0]): NOT FOUND") } |
| 382 | case "sliderid": | |
| 383 | let parts = arg.split(separator: " ", maxSplits: 1).map(String.init) | |
| 384 | guard parts.count == 2, let position = Double(parts[1]), position.isFinite, (0...1).contains(position) else { | |
| 385 | check(false, "sliderid requires an identifier and normalized position") | |
| 386 | return | |
| 387 | } | |
| 388 | let slider = app.sliders.matching(NSPredicate(format: "identifier == %@", parts[0])).firstMatch | |
| 389 | guard slider.waitForExistence(timeout: 10), slider.isHittable else { | |
| 390 | check(false, "sliderid \(parts[0]) is hittable") | |
| 391 | return | |
| 392 | } | |
| 393 | slider.adjust(toNormalizedSliderPosition: CGFloat(position)) | |
| 394 | note("sliderid \(parts[0]): \(position)") | |
| 395 | case "expectapp": | |
| 396 | let (bundle, seconds) = split(arg) | |
| 397 | let target = XCUIApplication(bundleIdentifier: bundle) | |
| 398 | check(target.wait(for: .runningForeground, timeout: seconds), "expectapp \(bundle)") | |
| 370 | 399 | case "field": |
| 371 | 400 | let fieldParts = arg.split(separator: " ", maxSplits: 1).map(String.init) |
| 372 | 401 | let index = Int(fieldParts[0]) ?? 0 |
| @@ -392,6 +421,14 @@ final class DriverTests: XCTestCase { | ||
| 392 | 421 | let want = idParts.count > 1 ? idParts[1] : "" |
| 393 | 422 | let seen = target.waitForExistence(timeout: 5) ? target.label : "(missing)" |
| 394 | 423 | check(seen == want, "labelid \(idParts[0]) \(want) (saw \(seen))") |
| 424 | case "valueid": | |
| 425 | let idParts = arg.split(separator: " ", maxSplits: 1).map(String.init) | |
| 426 | let target = identified(idParts[0]) | |
| 427 | let want = idParts.count > 1 ? idParts[1] : "" | |
| 428 | let matched = poll(5) { | |
| 429 | target.exists && (target.value as? String) == want ? target : nil | |
| 430 | } | |
| 431 | check(matched != nil, "valueid \(idParts[0]) \(want)") | |
| 395 | 432 | case "absentid": |
| 396 | 433 | check(!identified(arg).exists, "absentid \(arg)") |
| 397 | 434 | case "absent": |
| @@ -2,7 +2,14 @@ | ||
| 2 | 2 | |
| 3 | 3 | Craft SDK bindings for the [ts-maps](https://github.com/stacksjs/ts-maps) interactive map runtime. |
| 4 | 4 | |
| 5 | This package is the TypeScript side of `MapProvider.ts_maps` in Craft's Zig core. It ships a `MapView` component you can drop into any Craft app (desktop, mobile, WebView) to get a cross-platform interactive map with zero API keys. | |
| 5 | This package is the TypeScript side of `MapProvider.ts_maps` in Craft's Zig core. | |
| 6 | Its `MapView` owns a DOM container and renders ts-maps inside a browser or | |
| 7 | WebView, with zero API keys. It is not the STX native `Map` component. | |
| 8 | ||
| 9 | For a fully native STX screen, use `Map`, `MapMarker`, and `MapPolyline`. | |
| 10 | Craft renders them with MapKit on iOS and the configured native Android map | |
| 11 | provider. Android's `mapProvider: 'maplibre'` selects a key-free native GL view; | |
| 12 | it does not mount this package's DOM adapter. | |
| 6 | 13 | |
| 7 | 14 | ## Install |
| 8 | 15 | |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 2 | * `MapView` — Craft-native interactive map component backed by the | |
| 3 | * ts-maps runtime. Instances are WebView-hosted; in native Craft apps | |
| 2 | * `MapView` — Craft WebView map component backed by the | |
| 3 | * ts-maps runtime. Instances own a DOM container; in Craft apps | |
| 4 | 4 | * the Zig core drives them via the `ts_maps` bridge namespace. |
| 5 | 5 | * |
| 6 | 6 | * @module @craft-native/ts-maps/MapView |
| @@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs' | ||
| 2 | 2 | import { join } from 'node:path' |
| 3 | 3 | import { runInNewContext } from 'node:vm' |
| 4 | 4 | import { describe, expect, it } from 'bun:test' |
| 5 | import { androidMapHost } from '../packages/android/src/native-map' | |
| 5 | 6 | |
| 6 | 7 | const root = join(import.meta.dir, '..') |
| 7 | 8 | const ios = readFileSync(join(root, 'packages/ios/templates/CraftNativeScreen.swift'), 'utf8') |
| @@ -11,6 +12,8 @@ const iosHealth = readFileSync(join(root, 'packages/ios/templates/CraftHealth.sw | ||
| 11 | 12 | const iosWebAudio = readFileSync(join(root, 'packages/ios/templates/CraftWebAudio.swift'), 'utf8') |
| 12 | 13 | const iosFlatList = readFileSync(join(root, 'packages/ios/templates/CraftNativeFlatList.swift'), 'utf8') |
| 13 | 14 | const android = readFileSync(join(root, 'packages/android/templates/MainActivityNative.kt.template'), 'utf8') |
| 15 | .replace('', () => readFileSync(join(root, 'packages/android/templates', androidMapHost('google').template), 'utf8')) | |
| 16 | .replace('', () => androidMapHost('google').imports) | |
| 14 | 17 | const androidMutation = readFileSync(join(root, 'packages/android/templates/CraftNativeMutation.kt.template'), 'utf8') |
| 15 | 18 | const androidBuild = readFileSync(join(root, 'packages/android/templates/build.gradle.kts.app.template'), 'utf8') |
| 16 | 19 | const androidBuilder = readFileSync(join(root, 'packages/android/src/index.ts'), 'utf8') |
renamed throughout · 11 hidden lines
| @@ -568,9 +571,9 @@ describe('native renderer component contract', () => { renamed throughout | ||
| 568 | 571 | expect(android).toContain('override fun onLowMemory()') |
| 569 | 572 | expect(android).toContain('mapViews().forEach { it.lowMemory() }') |
| 570 | 573 | expect(android).toContain('released.filterIsInstance<CraftNativeMapView>().forEach { it.destroy() }') |
| 571 | expect(androidBuild).toContain('') | |
| 574 | expect(androidBuild).toContain('') | |
| 572 | 575 | expect(androidBuilder).toContain('mapsApiKey?: string') |
| 573 | expect(androidBuilder).toContain(".replace(/\\{\\{GOOGLE_MAPS_DEPENDENCY\\}\\}/g, config.renderer === 'native'") | |
| 576 | expect(androidBuilder).toContain(".replace(/\\{\\{NATIVE_MAP_DEPENDENCY\\}\\}/g, config.renderer === 'native'") | |
| 574 | 577 | expect(androidBuilder).toContain('com.google.android.geo.API_KEY') |
| 575 | 578 | expect(androidManifest).toContain('') |
| 576 | 579 | expect(guide).toContain('| `Map` | `CraftNativeMapView` (`MKMapView`) |') |