From 53273687810a8a880544b6819ec109a5b51e00b5 Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Fri, 11 Sep 2026 15:30:04 +0200 Subject: [PATCH 1/9] chore: set the LICENSE copyright holder The MIT notice still carried the YOUR_NAME placeholder from the template. --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index ddcd15b..b37ca5e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 YOUR_NAME +Copyright (c) 2026 gmi.software Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 4af93a36837d9f979f12044c5f2f5dc6647a62c7 Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Fri, 11 Sep 2026 15:30:11 +0200 Subject: [PATCH 2/9] chore: remove the changelog and the domain glossary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CHANGELOG.md was hand-written and enforced by a release gate; CONTEXT.md was a domain glossary nothing linked to. Removing the changelog alone would have broken every release: the workflow failed when CHANGELOG.md had no `## ` section for the tag being pushed. That gate is gone, and RELEASING.md no longer tells maintainers to write notes that have nowhere to land. Release notes now come solely from the conventional commits, generated into the GitHub Release body — so when a release needs more than commit subjects can carry, that body has to be edited by hand. --- .github/workflows/release.yml | 11 +---- CHANGELOG.md | 76 ----------------------------------- CONTEXT.md | 33 --------------- RELEASING.md | 41 ++++++++++--------- 4 files changed, 22 insertions(+), 139 deletions(-) delete mode 100644 CHANGELOG.md delete mode 100644 CONTEXT.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ccb9d0d..aa80b3e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ on: - 'v[0-9]+.[0-9]+.[0-9]+' - 'v[0-9]+.[0-9]+.[0-9]+-*' # Rehearsal against the current branch. Always a dry run: it validates the - # version, changelog and full gate without tagging or publishing anything. + # version and the full gate without tagging or publishing anything. workflow_dispatch: concurrency: @@ -81,15 +81,6 @@ jobs: exit 1 fi - - name: Verify CHANGELOG entry exists - env: - VERSION: ${{ steps.version.outputs.version }} - run: | - if ! grep -qE "^## +${VERSION//./\\.}( |$)" CHANGELOG.md; then - echo "::error::CHANGELOG.md has no '## $VERSION' section. Add the release notes before publishing." - exit 1 - fi - - name: Verify version is not already published env: VERSION: ${{ steps.version.outputs.version }} diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 09bd535..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,76 +0,0 @@ -# Changelog - -All notable changes to this project are documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## 1.1.0 - -### Behavior changes - -Two changes alter runtime behavior without changing any type signatures, so your -code keeps compiling but may behave differently after upgrading. - -**`onRegionChange` and `onRegionChangeComplete` now fire once per gesture** - -In 1.0.0 these fired repeatedly while the map was moving, and also fired for -programmatic camera updates. Now: - -- `onRegionChange` fires **once** when a user-initiated region change **begins** -- `onRegionChangeComplete` fires **once** when the user gesture **ends** -- Programmatic updates (`setCamera`, `animateCamera`, `fitToCoordinates`) no - longer emit either callback - -If you relied on a continuous stream of region updates — a live coordinate -readout, or a "search this area" button that re-renders while panning — move that -work to `onRegionChangeComplete`, which now marks the end of the gesture: - -```tsx -// Before: fired continuously during the gesture - setSearchArea(region)} /> - -// After: fires once when the user stops moving the map - setSearchArea(region)} /> -``` - -**`MapViewRef` camera methods now return `Promise`** - -`setCamera`, `animateCamera`, and `fitToCoordinates` previously returned `void`. -Existing call sites still compile, but linters configured with -`@typescript-eslint/no-floating-promises` will now flag them, and any custom -implementation or test mock of `MapViewRef` must be updated to match. - -```tsx -// Await the call, or explicitly ignore the promise -await mapRef.current?.animateCamera(camera, 300); -``` - -### Features - -- Add native POI press events with provider-specific payloads - (`onPoiPress`, `PoiPressEvent`, `ApplePoiPressEvent`, `GooglePoiPressEvent`) - ([#36](https://github.com/gmi-software/react-native-better-maps/pull/36)) -- Add Expo SDK 57 support - ([#49](https://github.com/gmi-software/react-native-better-maps/pull/49)) -- Rework map region change handling and camera update logic; programmatic - updates now skip no-op native calls - ([#48](https://github.com/gmi-software/react-native-better-maps/pull/48)) - -### Bug Fixes - -- **ios:** Remove `main.sync` from `HybridMapView` and make camera APIs async, - fixing main-thread deadlocks - ([#45](https://github.com/gmi-software/react-native-better-maps/pull/45)) -- **ios:** Fix threading issues in map view ownership - ([#43](https://github.com/gmi-software/react-native-better-maps/pull/43)) -- **android:** Align SDK versions with the nitro-modules prefab - ([#41](https://github.com/gmi-software/react-native-better-maps/pull/41)) -- Fix failure on first-time build - ([#39](https://github.com/gmi-software/react-native-better-maps/pull/39)) - -## 1.0.0 - -Initial public release: high-performance maps for React Native built on Nitro -Modules and the New Architecture, with Apple Maps and Google Maps providers on -iOS and Android. diff --git a/CONTEXT.md b/CONTEXT.md deleted file mode 100644 index 10ad9e1..0000000 --- a/CONTEXT.md +++ /dev/null @@ -1,33 +0,0 @@ -# React Native Nitro Maps - -Shared language for the map rendering domain in `react-native-better-maps`. - -## Language - -**Map Provider**: -A native rendering backend for the map view, such as Apple MapKit, Google Maps SDK, Mapbox SDK, or an OpenStreetMap-backed renderer. -_Avoid_: Tile source, geocoding provider - -**Google Map ID**: -A Google Cloud Map ID used by the Google Maps SDK to apply cloud-based map styling. It is supported only by the `google` map provider and is distinct from the Google Maps API key required to load the SDK. -_Avoid_: API key, style JSON - -**Marker**: -A point annotation created and owned by the app at a geographic coordinate on the map. -_Avoid_: Pin - -**Point of Interest (POI)**: -A provider-owned map feature rendered by the base map, such as a business, park, school, or public place. It is not a `Marker` because the app does not create, own, or update it as an overlay. -_Avoid_: Marker, app marker, custom marker - -**Native POI Detail Presentation**: -A provider-owned native UI surface that presents details for a selected point of interest. -_Avoid_: React Native callout, custom POI card - -**Marker Cluster**: -A grouped marker representation shown when nearby clusterable markers collapse into one map annotation. -_Avoid_: Cluster pin, marker group - -**Entering Animation**: -The visual transition used when a marker or marker cluster first appears on the map. -_Avoid_: Appear animation, spawn animation diff --git a/RELEASING.md b/RELEASING.md index 6c2c3ff..e4144ef 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -8,24 +8,22 @@ developer machine, and no long-lived npm token exists anywhere. Publishing is triggered by pushing a version tag. CI never writes to git: `main` requires pull request reviews with `enforce_admins` enabled, so nothing — not even `github-actions[bot]` — can push a release commit to it. The version bump -goes through a normal reviewed pull request instead, which has the useful side -effect of putting the changelog in front of a reviewer. +goes through a normal reviewed pull request instead. ## Cutting a release -**1. Open a release pull request.** Bump the version and write the notes: +**1. Open a release pull request.** Bump the version: ```bash cd package npm version 1.1.0 --no-git-tag-version ``` -Then add a matching `## 1.1.0` section to `CHANGELOG.md`, and open a pull request -with both changes. Review and merge it as usual. +Open a pull request with that change, then review and merge it as usual. **2. Rehearse (optional).** Run the **Release** workflow manually from the -Actions tab. A manual run is always a dry run: it validates the version, the -changelog and the full gate without publishing. +Actions tab. A manual run is always a dry run: it validates the version and the +full gate without publishing. **3. Push the tag.** @@ -35,10 +33,10 @@ git tag -a v1.1.0 -m 'v1.1.0' git push origin v1.1.0 ``` -The workflow then verifies the tag matches `package/package.json`, that -`CHANGELOG.md` has a section for it, and that the version is not already on npm; -runs the full gate; publishes to npm with provenance; and creates the GitHub -Release with notes generated from the conventional commits since the last tag. +The workflow then verifies the tag matches `package/package.json` and that the +version is not already on npm; runs the full gate; publishes to npm with +provenance; and creates the GitHub Release with notes generated from the +conventional commits since the last tag. The iOS podspec reads its version from `package.json`, so there is no second version to keep in sync. @@ -50,11 +48,13 @@ usual rules apply — `fix:` is a patch, `feat:` a minor, an incompatible API change a major — but two cases are easy to get wrong: - A commit that is not conventional (for example `Fix threading issues on ios`) - is invisible to the generated release notes. Add it to the changelog by hand. + is invisible to the generated release notes. Edit the GitHub Release body by + hand after the workflow creates it. - A change in runtime behavior that keeps the same types — such as a callback that starts firing once per gesture instead of continuously — breaks consumers even though their code still compiles. Either take the major, or ship it as a - minor with a prominent **Behavior changes** section, as 1.1.0 did. + minor and add a prominent **Behavior changes** section to the GitHub Release + body, as 1.1.0 did. ## Pre-releases @@ -69,15 +69,16 @@ Consumers opt in explicitly: npm install react-native-better-maps@rc ``` -## Changelog +## Release notes -`CHANGELOG.md` is written by hand, not generated. This is deliberate: the parts -of a release that matter most — behavior changes, migration snippets, the reason -a fix exists — cannot be derived from commit subjects. The workflow **fails** if -there is no `## ` section, so the notes cannot be forgotten. +There is no `CHANGELOG.md`. The release notes are the GitHub Release body, +generated from the conventional commits since the previous tag by +`@release-it/conventional-changelog`. -Notes generated from conventional commits still go into the GitHub Release body, -so commit-level detail is not lost. +Commit subjects cannot carry the parts of a release that matter most — behavior +changes, migration snippets, the reason a fix exists. Nothing enforces those any +more, so when a release needs them, edit the GitHub Release body by hand once the +workflow has created it. ## One-time setup: npm trusted publishing From e73a40e6d73414ccf67e2fb867672ae7aaf77c27 Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Fri, 11 Sep 2026 15:30:21 +0200 Subject: [PATCH 3/9] chore: add issue, pull request and Dependabot templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issue forms in the shape react-native-better-maps actually needs: a runtime bug report, a build error, and an enhancement. Each asks for the map provider and the platform, since those are what decide where a problem lives, and — following nitro — whether the reporter could reproduce it in the example app. config.yml turns off blank issues and routes usage questions to Discussions, which are enabled but were advertised nowhere, plus a direct link to the private security advisory form. Dependabot covers two ecosystems. `bun` is its own ecosystem, not part of `npm`: it reads bun.lock, and GitHub supports version updates for it but not security updates, so alerts on those dependencies still have to be acted on by hand. github-actions matters more than usual here because the workflows pin every action by commit SHA, which never resolves to a newer release on its own. React, React Native and the Nitro packages are on the ignore list — they have native counterparts, so bumping them is a deliberate, tested change. --- .github/ISSUE_TEMPLATE/BUG_REPORT.yml | 120 +++++++++++++++++++++++++ .github/ISSUE_TEMPLATE/BUILD_ERROR.yml | 117 ++++++++++++++++++++++++ .github/ISSUE_TEMPLATE/ENHANCEMENT.yml | 42 +++++++++ .github/ISSUE_TEMPLATE/config.yml | 11 +++ .github/PULL_REQUEST_TEMPLATE.md | 24 +++++ .github/dependabot.yml | 43 +++++++++ 6 files changed, 357 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/BUG_REPORT.yml create mode 100644 .github/ISSUE_TEMPLATE/BUILD_ERROR.yml create mode 100644 .github/ISSUE_TEMPLATE/ENHANCEMENT.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.yml b/.github/ISSUE_TEMPLATE/BUG_REPORT.yml new file mode 100644 index 0000000..8eda52e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/BUG_REPORT.yml @@ -0,0 +1,120 @@ +name: 🐛 Bug Report +description: Something in the map renders or behaves incorrectly at runtime +labels: [bug] +body: + - type: textarea + attributes: + label: What happened? + description: Explain what you were trying to do and what happened instead. Be as precise as possible — an issue that cannot be understood cannot be fixed. + placeholder: I rendered a MapView with 2000 markers and the `onMarkerPress` callback fired with the wrong marker id after clustering kicked in. + validations: + required: true + - type: textarea + attributes: + label: Reproduceable code + description: > + Share a small reproduceable snippet — ideally the whole component. + Include the `MapView` props you set, the marker/overlay data shape, and any imperative `MapViewRef` calls. + render: tsx + placeholder: | + console.log(id)} + /> + validations: + required: true + - type: textarea + attributes: + label: Relevant log output + description: > + Paste any relevant **native log output** here. This is automatically formatted as code, so no backticks are needed. + + * For iOS, run the app from Xcode and copy the console output. + + * For Android, use the Android Studio Logcat window or run `adb logcat` in a terminal. + render: shell + validations: + required: false + - type: dropdown + attributes: + label: Map provider + description: Which provider does this happen with? Select every provider you reproduced it on. + multiple: true + options: + - apple + - google + validations: + required: true + - type: dropdown + attributes: + label: Platforms + description: Select every platform you reproduced this on. + multiple: true + options: + - iOS + - Android + validations: + required: true + - type: input + attributes: + label: Device + description: > + Which device shows the problem? Give the full name plus the OS version, and say whether it is a simulator/emulator or a physical device. + If you tested several, list them comma separated. + placeholder: ex. iPhone 15 Pro (iOS 18.2, simulator), Pixel 7 (Android 15, physical) + validations: + required: true + - type: input + attributes: + label: react-native-better-maps version + placeholder: ex. 1.1.0 + validations: + required: true + - type: input + attributes: + label: React Native version + description: The library requires 0.78+ with the New Architecture enabled. + placeholder: ex. 0.81.4 + validations: + required: true + - type: input + attributes: + label: react-native-nitro-modules version + placeholder: ex. 0.35.10 + validations: + required: true + - type: input + attributes: + label: Expo SDK version + description: Leave empty if this is a bare React Native app. + placeholder: ex. 57.0.0 + validations: + required: false + - type: dropdown + attributes: + label: Can you reproduce this in the example app? + description: > + Run the example app (`example/`, see [Example app](https://github.com/gmi-software/react-native-better-maps#example-app)) and check whether the problem shows up there too. + **Note:** an issue that does not reproduce in the example app and ships no reproduction is much harder to act on. + options: + - I didn't try (⚠️ your issue may take much longer to get looked at) + - Yes, I can reproduce it in the example app + - 'No, the example app works fine' + default: 0 + validations: + required: true + - type: checkboxes + attributes: + label: Additional information + description: Please check all the boxes that apply + options: + - label: I am using Expo with a development build + - label: I am using clustering + - label: I am using GeoJSON overlays + - label: I have the New Architecture enabled + - label: I checked [Common problems](https://github.com/gmi-software/react-native-better-maps#common-problems) and my issue is not listed there. + required: true + - label: I searched for [similar issues in this repository](https://github.com/gmi-software/react-native-better-maps/issues) and found none. + required: true diff --git a/.github/ISSUE_TEMPLATE/BUILD_ERROR.yml b/.github/ISSUE_TEMPLATE/BUILD_ERROR.yml new file mode 100644 index 0000000..4e928e1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/BUILD_ERROR.yml @@ -0,0 +1,117 @@ +name: 🔧 Build Error +description: The native app fails to build, prebuild, or install pods +labels: [build/setup] +body: + - type: textarea + attributes: + label: How were you trying to build the app? + description: Explain how you built — Xcode, `expo run:ios`, `pod install`, a Gradle task, EAS Build, CI, or something else. Be as precise as possible. + placeholder: I added react-native-better-maps to an Expo SDK 57 app, ran `expo prebuild --clean` and then `expo run:ios`, and the build failed while compiling the Google adapter files. + validations: + required: true + - type: textarea + attributes: + label: Full build logs + description: > + Share the full build output, from the first command to the last line. + Do not paste only the final few lines — the real cause is usually much earlier. + render: shell + validations: + required: true + - type: textarea + attributes: + label: Project dependencies + description: Share the `dependencies` block from your app's `package.json`, so conflicting libraries are visible. + render: json + placeholder: | + "dependencies": { + "expo": "~57.0.0", + "react-native": "0.81.4", + "react-native-better-maps": "^1.1.0", + "react-native-nitro-modules": "^0.35.10" + } + validations: + required: true + - type: textarea + attributes: + label: Expo config plugin setup + description: > + If you use the config plugin, share the `react-native-better-maps` entry from your `app.json` / `app.config.js`. + For a bare app, share the relevant parts of `Info.plist`, `AndroidManifest.xml` and `Podfile.properties.json` instead. + See [Expo setup](https://github.com/gmi-software/react-native-better-maps/blob/main/docs/expo-setup.md). + render: json + validations: + required: false + - type: dropdown + attributes: + label: Target platforms + description: Select every platform the build fails on. + multiple: true + options: + - iOS + - Android + validations: + required: true + - type: dropdown + attributes: + label: Operating system + description: Select the operating system you are building on. + multiple: true + options: + - macOS + - Windows + - Linux + validations: + required: true + - type: input + attributes: + label: react-native-better-maps version + placeholder: ex. 1.1.0 + validations: + required: true + - type: input + attributes: + label: React Native version + description: The library requires 0.78+ with the New Architecture enabled. + placeholder: ex. 0.81.4 + validations: + required: true + - type: input + attributes: + label: react-native-nitro-modules version + placeholder: ex. 0.35.10 + validations: + required: true + - type: input + attributes: + label: Expo SDK version + description: Leave empty if this is a bare React Native app. + placeholder: ex. 57.0.0 + validations: + required: false + - type: dropdown + attributes: + label: Can you build the example app? + description: > + Try to build the example app (`example/`, see [Example app](https://github.com/gmi-software/react-native-better-maps#example-app)). + **Note:** this separates a problem in the library from a problem in your app's native setup, and is the single most useful thing you can report here. + options: + - I didn't try (⚠️ your issue may take much longer to get looked at) + - Yes, the example app builds fine + - 'No, the example app fails to build too' + default: 0 + validations: + required: true + - type: checkboxes + attributes: + label: Additional information + description: Please check all the boxes that apply + options: + - label: I am using Expo with a development build + - label: I am using the Expo config plugin + - label: I am using Google Maps on iOS (`betterMaps.iosGoogleProvider`) + - label: I retried after a clean build (`expo prebuild --clean`, deleted `ios/Pods` and `android/build`) + - label: I checked [Common problems](https://github.com/gmi-software/react-native-better-maps#common-problems) and my issue is not listed there. + required: true + - label: I searched for [similar issues in this repository](https://github.com/gmi-software/react-native-better-maps/issues) and found none. + required: true diff --git a/.github/ISSUE_TEMPLATE/ENHANCEMENT.yml b/.github/ISSUE_TEMPLATE/ENHANCEMENT.yml new file mode 100644 index 0000000..7275f24 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/ENHANCEMENT.yml @@ -0,0 +1,42 @@ +name: ✨ Enhancement +description: Request a feature or an enhancement to react-native-better-maps +labels: [enhancement] +body: + - type: textarea + attributes: + label: Feature request / enhancement + description: Describe the feature in detail, and explain the motivation that led you to open this issue — what are you building, and what is blocked without it? + placeholder: I need heatmap overlays so I can show density of my delivery data. react-native-maps has this, and both MapKit and the Google Maps SDK support it natively. + validations: + required: true + - type: dropdown + attributes: + label: Which providers should this cover? + description: > + Providers differ in what their native SDKs support, so a feature may land on one before the other. + See the [capability matrix](https://github.com/gmi-software/react-native-better-maps#capability-matrix). + multiple: true + options: + - apple (iOS) + - google (iOS) + - google (Android) + validations: + required: true + - type: textarea + attributes: + label: Are there any existing workarounds? + description: List anything you do today to work around the missing feature, so others reading this issue are not blocked by it. + placeholder: I render an absolutely positioned overlay on top of the map and sync it with `onRegionChangeComplete`, but it drifts during the gesture. + validations: + required: false + - type: checkboxes + attributes: + label: Additional information + description: Please check all the boxes that apply + options: + - label: I checked the [README](https://github.com/gmi-software/react-native-better-maps#readme) and the [docs](https://github.com/gmi-software/react-native-better-maps/tree/main/docs) and this feature does not exist yet. + required: true + - label: I checked the [roadmap](https://github.com/gmi-software/react-native-better-maps/blob/main/docs/roadmap.md) and it is not already planned there. + - label: I can open a pull request to implement this. (See [Contributing](https://github.com/gmi-software/react-native-better-maps/blob/main/CONTRIBUTING.md)) + - label: I searched for [similar issues in this repository](https://github.com/gmi-software/react-native-better-maps/issues) and found none. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..d3a8851 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: 💬 Question or usage help + url: https://github.com/gmi-software/react-native-better-maps/discussions + about: Ask how to do something, or float an idea before opening an enhancement. + - name: 📖 Documentation + url: https://github.com/gmi-software/react-native-better-maps#readme + about: Installation, providers, clustering, GeoJSON, Google Maps keys and the capability matrix. + - name: 🔐 Report a security vulnerability + url: https://github.com/gmi-software/react-native-better-maps/security/advisories/new + about: Do not open a public issue. Report it privately through a GitHub security advisory. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..0249668 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,24 @@ +## What does this change? + + + +## How was it verified? + + + +## Scope + +- **Providers:** +- **Platforms:** + +## Checklist + +- [ ] `bun run lint`, `bun run typecheck` and `bun run build` pass +- [ ] Tests pass, and new behavior is covered by a test +- [ ] Nitro specs changed? `bun run nitrogen` was re-run and the generated code is committed +- [ ] Public API changed? The README and the capability matrix are updated +- [ ] Commits follow [Conventional Commits](https://www.conventionalcommits.org/) +- [ ] Behavior changed without a type change? Say so explicitly above — it breaks consumers whose code still compiles diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5a22eec --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,43 @@ +version: 2 + +updates: + # The workflows pin every action by commit SHA, which never resolves to a newer + # release on its own. Without this, those pins silently rot. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + github-actions: + patterns: + - '*' + # Keep the subject conventional — the commitlint job runs on every pull + # request, Dependabot's included. + commit-message: + prefix: chore + + # Bun is its own Dependabot ecosystem, not part of `npm`: it reads `bun.lock`. + # Version updates are supported; security updates are not, so Dependabot + # security alerts for these dependencies have to be acted on by hand. + - package-ecosystem: bun + directories: + - / + - /package + - /example + schedule: + interval: weekly + groups: + dev-dependencies: + dependency-type: development + patterns: + - '*' + # React Native, React and Nitro are peer dependencies with native + # counterparts; upgrading them is a deliberate, tested change, never an + # automated bump. + ignore: + - dependency-name: react + - dependency-name: react-native + - dependency-name: react-native-nitro-modules + - dependency-name: nitrogen + commit-message: + prefix: chore From 8c5e0c06e51797677801a84f9df64451e9293adc Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Fri, 11 Sep 2026 15:30:40 +0200 Subject: [PATCH 4/9] docs: add a security policy and a code of conduct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SECURITY.md documents the private reporting channel — GitHub security advisories, now enabled on the repository — and two things specific to a maps library. The first is the report we should expect most: that a Google Maps API key can be extracted from a shipped app. That is how the Google Maps SDKs work. The key has to reach the native SDK in-process, so it lives in Info.plist and AndroidManifest.xml and travels in the binary; this library only hands it to the provider SDK and never transmits it. The protection is key restriction in Google Cloud, and the policy says so, while drawing the line at the real issue: this library leaking a key into a log, a request or a crash payload. The second is the supply chain, which is worth stating because it is unusually tight already: OIDC trusted publishing with no long-lived token, npm provenance verifiable with `npm audit signatures`, actions pinned by SHA, and CI that never writes to git. The code of conduct is the canonical Contributor Covenant with security@gmi.software as the contact. Both are linked from the README and CONTRIBUTING.md, along with RELEASING.md, which the documentation list had been missing. --- CODE_OF_CONDUCT.md | 128 +++++++++++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 8 +++ README.md | 3 ++ SECURITY.md | 79 ++++++++++++++++++++++++++++ 4 files changed, 218 insertions(+) create mode 100644 CODE_OF_CONDUCT.md create mode 100644 SECURITY.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..6867b7c --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or + advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email + address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +security@gmi.software. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e57e65e..cbfa8c7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,6 +57,14 @@ React Doctor runs in a separate GitHub Actions workflow (`.github/workflows/reac After the baseline is documented and critical findings are addressed, CI will switch to blocking new errors on changed files only. +## Code of conduct + +This project follows the [Contributor Covenant](CODE_OF_CONDUCT.md). By taking part you are expected to uphold it; report unacceptable behavior to security@gmi.software. + +## Security + +Do not report vulnerabilities through issues or pull requests. See [SECURITY.md](SECURITY.md) for the private reporting channel. + ## Commit messages This project uses [Conventional Commits](https://www.conventionalcommits.org/). Commit messages are validated locally via Husky and on pull requests in CI. diff --git a/README.md b/README.md index 2026089..a73cfae 100644 --- a/README.md +++ b/README.md @@ -612,6 +612,9 @@ See [example/.env.example](example/.env.example) for the supported environment v - [GeoJSON overlays](docs/geojson.md) - [Roadmap](docs/roadmap.md) - [Contributing](CONTRIBUTING.md) +- [Code of conduct](CODE_OF_CONDUCT.md) +- [Security policy](SECURITY.md) +- [Releasing](RELEASING.md) - [ADRs](docs/adr) ## Common problems diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..11d2536 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,79 @@ +# Security Policy + +## Supported versions + +Fixes land on the latest minor release. Older minors do not receive backports — +upgrading is the supported path. + +| Version | Supported | +| ------- | ------------------- | +| 1.1.x | ✅ | +| 1.0.x | ❌ upgrade to 1.1.x | +| < 1.0 | ❌ | + +## Reporting a vulnerability + +**Do not open a public issue.** + +Report it privately through +[GitHub Security Advisories](https://github.com/gmi-software/react-native-better-maps/security/advisories/new). +If that is not possible, email . + +Please include: + +- the version of `react-native-better-maps`, React Native and + `react-native-nitro-modules` +- the affected map provider (`apple` / `google`) and platform +- what an attacker can do with it, and a reproduction if you have one + +We aim to acknowledge a report within five working days. If a fix is warranted +we will agree a disclosure timeline with you, credit you in the advisory unless +you prefer otherwise, and publish the advisory alongside the release that fixes +it. + +## Map provider API keys are not a vulnerability in this library + +The most common report we expect is _"I extracted the Google Maps API key from +an app that uses this library."_ That is expected behavior of the Google Maps +SDKs, not a flaw here. + +The key has to reach the native SDK inside the app process, so it lives in the +shipped binary: + +- **iOS** — `GoogleMapsIosApiKey` in `Info.plist`, read via `Bundle.main` and + handed to `GMSServices.provideAPIKey` +- **Android** — `com.google.android.geo.API_KEY` in `AndroidManifest.xml`, read + by the Google Maps SDK itself + +Anyone with the `.ipa` or `.apk` can read it. This library does not transmit the +key anywhere: it only passes it to the provider SDK in-process. + +The actual protection is server-side, in Google Cloud: + +- restrict the key to your iOS bundle ID and your Android package name plus the + SHA-1 fingerprint of your signing certificate +- restrict it to only the APIs you use (Maps SDK for iOS / Android) +- use separate keys per platform and per build variant, and set quotas + +A report that a key can be extracted from a binary will be closed with a link to +this section. A report that this library leaks a key somewhere it should not — +a log line, a network request, a crash report payload — is a real issue and we +want to hear about it. + +## Supply chain + +Releases are built and published only by the +[Release workflow](.github/workflows/release.yml), never from a developer +machine: + +- npm publishing uses OIDC trusted publishing, so no long-lived npm token exists + in the repository or on any laptop +- every published version carries + [npm provenance](https://docs.npmjs.com/generating-provenance-statements), + which you can verify with `npm audit signatures` +- every GitHub Action is pinned by commit SHA, not by tag +- workflows check out with `persist-credentials: false`, and CI never writes to + git + +If you believe a published artifact does not match this repository, treat it as +a vulnerability and report it through the channel above. From bba38a09260b0487658cbd7f4cd7f9cf6c94421b Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Fri, 11 Sep 2026 15:30:51 +0200 Subject: [PATCH 5/9] chore: move shared tool config into config/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows the layout nitro uses: one directory holding the configuration every formatter and compiler reads, instead of scattering dotfiles across the root. tsconfig.base.json moves to config/tsconfig.json with its contents unchanged, and the two tsconfigs that extend it are repointed; typecheck, the provider-type check and the build all still pass. .clang-format is nitro's, unchanged. .editorconfig is nitro's plus a comment explaining why ktlint_standard_filename must stay disabled here: Kotlin sources are named after the extension they add — Camera+CameraPosition.kt, MapType+GoogleMap.kt — which the default rule rejects. .swift-format has no equivalent in nitro, and exists for a reason specific to this repository. swift-format indents the body of conditional compilation blocks by default, and eight files are whole Google Maps adapters wrapped in `#if canImport(GoogleMaps)`. Leaving that on re-indented all of them and turned a formatting pass into a 3000-line diff. lineLength matches the ColumnLimit in .clang-format so both native formatters agree on one width. --- config/.clang-format | 26 ++++++++++++++++++++++ config/.editorconfig | 9 ++++++++ config/.swift-format | 6 +++++ tsconfig.base.json => config/tsconfig.json | 0 package/tsconfig.json | 2 +- package/tsconfig.plugin.json | 2 +- 6 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 config/.clang-format create mode 100644 config/.editorconfig create mode 100644 config/.swift-format rename tsconfig.base.json => config/tsconfig.json (100%) diff --git a/config/.clang-format b/config/.clang-format new file mode 100644 index 0000000..ecdd3f3 --- /dev/null +++ b/config/.clang-format @@ -0,0 +1,26 @@ +# Config for clang-format version 16+ + +# Standard +BasedOnStyle: llvm +Standard: c++20 + +# Indentation +IndentWidth: 2 +ColumnLimit: 140 + +# Includes +SortIncludes: CaseSensitive +SortUsingDeclarations: true + +# Pointer and reference alignment +PointerAlignment: Left +ReferenceAlignment: Left +ReflowComments: true + +# Line breaking options +BreakBeforeBraces: Attach +BreakConstructorInitializers: BeforeColon +AlwaysBreakTemplateDeclarations: true +AllowShortFunctionsOnASingleLine: Empty +IndentCaseLabels: true +NamespaceIndentation: Inner diff --git a/config/.editorconfig b/config/.editorconfig new file mode 100644 index 0000000..0f0e725 --- /dev/null +++ b/config/.editorconfig @@ -0,0 +1,9 @@ +[*.{kt,kts}] +# Sources are named after the extension they add, not after a single top-level +# declaration — Camera+CameraPosition.kt, MapType+GoogleMap.kt, String+ColorInt.kt. +ktlint_standard_filename = disabled +ktlint_standard_function-expression-body = disabled + +[*] +indent_style = space +indent_size = 2 diff --git a/config/.swift-format b/config/.swift-format new file mode 100644 index 0000000..0a07407 --- /dev/null +++ b/config/.swift-format @@ -0,0 +1,6 @@ +{ + "version": 1, + "//": "Everything not listed here is swift-format's default. Two deviations: the Google Maps adapters are whole files wrapped in `#if canImport(GoogleMaps)` and indenting that block would re-indent every one of them for no benefit; and the line length matches config/.clang-format so both native formatters agree on one width.", + "indentConditionalCompilationBlocks": false, + "lineLength": 140 +} diff --git a/tsconfig.base.json b/config/tsconfig.json similarity index 100% rename from tsconfig.base.json rename to config/tsconfig.json diff --git a/package/tsconfig.json b/package/tsconfig.json index b0190d2..d744904 100644 --- a/package/tsconfig.json +++ b/package/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../tsconfig.base.json", + "extends": "../config/tsconfig.json", "compilerOptions": { "rootDir": "src", "outDir": "lib/typescript", diff --git a/package/tsconfig.plugin.json b/package/tsconfig.plugin.json index fa11563..17d5538 100644 --- a/package/tsconfig.plugin.json +++ b/package/tsconfig.plugin.json @@ -1,5 +1,5 @@ { - "extends": "../tsconfig.base.json", + "extends": "../config/tsconfig.json", "compilerOptions": { "module": "commonjs", "moduleResolution": "node", From 685ab012c13dacd55dac9ff79962e95b557ff157 Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Fri, 11 Sep 2026 15:31:06 +0200 Subject: [PATCH 6/9] chore: add native formatter scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports nitro's three formatter scripts: clang-format for C++, ktlint for Kotlin, swift-format for Swift, each reading its configuration from config/ and each failing with an install hint when the tool is missing. Two deliberate differences from nitro. Every script starts by changing to the repository root, so the relative config paths hold no matter where it is invoked from. And package/nitrogen is left out on purpose — it is generated and git-ignored, so formatting it would be undone by the next `bun run nitrogen`. Exposed as format:cpp, format:kotlin, format:swift and format:native. The existing `format` script stays what it was, Prettier over everything else. --- CONTRIBUTING.md | 4 ++++ package.json | 4 ++++ scripts/clang-format.sh | 30 ++++++++++++++++++++++++++++++ scripts/kotlin-format.sh | 18 ++++++++++++++++++ scripts/swift-format.sh | 21 +++++++++++++++++++++ 5 files changed, 77 insertions(+) create mode 100755 scripts/clang-format.sh create mode 100755 scripts/kotlin-format.sh create mode 100755 scripts/swift-format.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cbfa8c7..07551c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,6 +32,10 @@ Thank you for your interest in contributing! | `bun run build` | Build the library with react-native-builder-bob | | `bun run nitrogen` | Run Nitrogen codegen (when specs are ready) | | `bun run format` | Format all files with Prettier | +| `bun run format:cpp` | Format C++ with clang-format (`config/.clang-format`) | +| `bun run format:kotlin` | Format Kotlin with ktlint (`config/.editorconfig`) | +| `bun run format:swift` | Format Swift with swift-format (`config/.swift-format`) | +| `bun run format:native` | Run all three native formatters | | `bun run doctor` | Run React Doctor locally | ## React Doctor diff --git a/package.json b/package.json index 13670b7..46f3275 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,10 @@ "nitrogen": "bun run --filter react-native-better-maps nitrogen", "example": "bun run --filter react-native-better-maps-example", "format": "prettier --write .", + "format:cpp": "bash scripts/clang-format.sh", + "format:kotlin": "bash scripts/kotlin-format.sh", + "format:swift": "bash scripts/swift-format.sh", + "format:native": "bun run format:cpp && bun run format:kotlin && bun run format:swift", "doctor": "npx react-doctor@latest", "commitlint": "commitlint", "prepare": "husky" diff --git a/scripts/clang-format.sh b/scripts/clang-format.sh new file mode 100755 index 0000000..2c7c8a1 --- /dev/null +++ b/scripts/clang-format.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +set -e + +# Run from the repository root, whatever the caller's working directory is, so +# the config path below always resolves. +cd "$(dirname "$0")/.." + +CPP_DIRS=( + # shared C++ + "package/cpp" + # Android JNI adapter + "package/android/src/main/cpp" + # iOS — Objective-C / Objective-C++ bridging files, if any + "package/ios" +) + +# Note: package/nitrogen is generated by nitrogen and git-ignored. It is never +# formatted here — regenerating it would undo the changes anyway. + +if which clang-format >/dev/null; then + DIRS=$(printf "%s " "${CPP_DIRS[@]}") + find $DIRS -type f \( -name "*.h" -o -name "*.hpp" -o -name "*.cpp" -o -name "*.m" -o -name "*.mm" -o -name "*.c" \) -print0 | while read -d $'\0' file; do + clang-format -style=file:./config/.clang-format -i "$file" + done + echo "C++ Format done!" +else + echo "error: clang-format not installed, install with 'brew install clang-format' (or manually from https://clang.llvm.org/docs/ClangFormat.html )" + exit 1 +fi diff --git a/scripts/kotlin-format.sh b/scripts/kotlin-format.sh new file mode 100755 index 0000000..8f51436 --- /dev/null +++ b/scripts/kotlin-format.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +set -e + +cd "$(dirname "$0")/.." + +KOTLIN_PATTERNS=( + "package/android/src/main/java/**/*.kt" + "package/android/src/test/java/**/*.kt" +) + +if which ktlint >/dev/null; then + ktlint --editorconfig=./config/.editorconfig --format "${KOTLIN_PATTERNS[@]}" + echo "Kotlin Format done!" +else + echo "error: ktlint not installed, install with 'brew install ktlint' (see https://github.com/pinterest/ktlint )" + exit 1 +fi diff --git a/scripts/swift-format.sh b/scripts/swift-format.sh new file mode 100755 index 0000000..d88531e --- /dev/null +++ b/scripts/swift-format.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +set -e + +cd "$(dirname "$0")/.." + +SWIFT_DIRS=( + "package/ios" + "package/iosTests" +) + +if which swift >/dev/null; then + DIRS=$(printf "%s " "${SWIFT_DIRS[@]}") + find $DIRS -type f \( -name "*.swift" \) -print0 | while read -d $'\0' file; do + swift format --configuration ./config/.swift-format --in-place "$file" + done + echo "Swift Format done!" +else + echo "error: swift not installed, install the toolchain with Xcode." + exit 1 +fi From ed81e50f36680b0d0129c8a240f0c54e4b9cde8f Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Fri, 11 Sep 2026 15:31:28 +0200 Subject: [PATCH 7/9] style: format the native sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First run of the three formatters over the existing code. No behavior changes: the diff is line breaks, trailing commas, sorted includes, and swift-format rewriting `case let .foo(x)` as `case .foo(let x)`. No import was removed. Verified rather than assumed, because nothing in CI compiles this code: - Kotlin: `./gradlew :react-native-better-maps:compileDebugKotlin --rerun` passes. - C++: `buildCMakeDebug[arm64-v8a]` passes. This was the one real risk — clang-format sorts includes, and cpp-adapter.cpp now includes its own header before jni.h and fbjni.h. The header is self-contained, so it builds. - The JavaScript gate is unaffected: typecheck, build and 80 tests pass. Swift is NOT compile-verified. `pod install` fails on this repository's podspec under Ruby 4.0.6 + CocoaPods 1.17.0 — the Podfile.properties helpers are declared with top-level `def`, which is not visible from inside the `Pod::Spec.new` block — so the example workspace cannot be refreshed to build it. That is a pre-existing problem, unrelated to this change. Running the formatters again produces no further diff. --- package/android/src/main/cpp/cpp-adapter.cpp | 8 +- .../nitro/nitromaps/Camera+CameraPosition.kt | 13 +- .../CircleDescriptor+CircleOptions.kt | 11 +- .../nitro/nitromaps/ClusterBadgeMetrics.kt | 28 +-- .../nitro/nitromaps/ClusterIconFactory.kt | 59 ++++--- .../nitromaps/GoogleMapProviderAdapter.kt | 125 ++++++++------ .../margelo/nitro/nitromaps/HybridMapView.kt | 15 +- .../nitro/nitromaps/MapOverlayController.kt | 151 ++++++++++------- .../nitro/nitromaps/MapProviderAdapter.kt | 15 +- .../nitro/nitromaps/MapType+GoogleMap.kt | 13 +- .../nitro/nitromaps/MapViewLifecycleOwner.kt | 12 +- .../nitro/nitromaps/MarkerClusterEngine.kt | 104 +++++++----- .../nitro/nitromaps/MarkerIconFactory.kt | 160 ++++++++++++------ .../nitro/nitromaps/MarkerSpatialIndex.kt | 26 ++- .../nitro/nitromaps/MarkerViewportFilter.kt | 11 +- .../nitro/nitromaps/NitroMapsPackage.kt | 5 +- .../nitromaps/OverlayEnteringAnimation.kt | 25 ++- .../PolygonDescriptor+PolygonOptions.kt | 11 +- .../PolylineDescriptor+PolylineOptions.kt | 11 +- .../nitro/nitromaps/String+ColorInt.kt | 11 +- .../nitromaps/MarkerDisplayedIdentityTest.kt | 125 +++++++------- .../nitro/nitromaps/MarkerRenderDiffTest.kt | 27 +-- .../CustomMapStyle+MKMapConfiguration.swift | 5 +- package/ios/GoogleMapOverlayController.swift | 42 ++--- package/ios/GoogleMapProviderAdapter.swift | 15 +- package/ios/GoogleMapsAPIKey.swift | 10 +- package/ios/GoogleMarkerVisualApplier.swift | 11 +- package/ios/HybridMapView.swift | 9 +- package/ios/HybridMapViewDelegate.swift | 42 +++-- package/ios/MapMarkerAnnotation.swift | 16 +- package/ios/MapOverlayController.swift | 4 +- package/ios/MarkerClusterEngine.swift | 64 +++---- package/ios/MarkerViewportFilter.swift | 4 +- package/ios/NitroImageAnnotationView.swift | 15 +- package/ios/NitroPinAnnotationView.swift | 10 +- package/ios/OverlayEnteringAnimation.swift | 9 +- 36 files changed, 718 insertions(+), 504 deletions(-) diff --git a/package/android/src/main/cpp/cpp-adapter.cpp b/package/android/src/main/cpp/cpp-adapter.cpp index a273b3e..83e2492 100644 --- a/package/android/src/main/cpp/cpp-adapter.cpp +++ b/package/android/src/main/cpp/cpp-adapter.cpp @@ -1,9 +1,7 @@ -#include -#include #include "NitroMapsOnLoad.hpp" +#include +#include JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) { - return facebook::jni::initialize(vm, []() { - margelo::nitro::nitromaps::registerAllNatives(); - }); + return facebook::jni::initialize(vm, []() { margelo::nitro::nitromaps::registerAllNatives(); }); } diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/Camera+CameraPosition.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/Camera+CameraPosition.kt index 65dec5a..55c271d 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/Camera+CameraPosition.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/Camera+CameraPosition.kt @@ -5,7 +5,8 @@ import com.google.android.gms.maps.model.LatLng import kotlin.math.abs fun Camera.toCameraPosition(current: CameraPosition? = null): CameraPosition { - return CameraPosition.Builder() + return CameraPosition + .Builder() .target(LatLng(center.latitude, center.longitude)) .zoom((zoom ?: current?.zoom?.toDouble() ?: 10.0).toFloat()) .bearing((heading ?: current?.bearing?.toDouble() ?: 0.0).toFloat()) @@ -29,9 +30,9 @@ fun CameraPosition.approximatelyEquals( zoomEpsilon: Float = MapApproximateEquality.ZOOM_EPSILON, angleEpsilon: Float = MapApproximateEquality.ANGLE_EPSILON, ): Boolean { - return abs(target.latitude - other.target.latitude) < coordinateEpsilon - && abs(target.longitude - other.target.longitude) < coordinateEpsilon - && abs(zoom - other.zoom) < zoomEpsilon - && abs(bearing - other.bearing) < angleEpsilon - && abs(tilt - other.tilt) < angleEpsilon + return abs(target.latitude - other.target.latitude) < coordinateEpsilon && + abs(target.longitude - other.target.longitude) < coordinateEpsilon && + abs(zoom - other.zoom) < zoomEpsilon && + abs(bearing - other.bearing) < angleEpsilon && + abs(tilt - other.tilt) < angleEpsilon } diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/CircleDescriptor+CircleOptions.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/CircleDescriptor+CircleOptions.kt index 161033f..269d8db 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/CircleDescriptor+CircleOptions.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/CircleDescriptor+CircleOptions.kt @@ -4,11 +4,12 @@ import com.google.android.gms.maps.model.CircleOptions import com.google.android.gms.maps.model.LatLng fun CircleDescriptor.toCircleOptions(): CircleOptions { - val options = CircleOptions() - .center(LatLng(center.latitude, center.longitude)) - .radius(radius) - .strokeWidth((strokeWidth ?: 2.0).toFloat()) - .clickable(tappable != false) + val options = + CircleOptions() + .center(LatLng(center.latitude, center.longitude)) + .radius(radius) + .strokeWidth((strokeWidth ?: 2.0).toFloat()) + .clickable(tappable != false) strokeColor?.let { options.strokeColor(it.toColorInt()) } fillColor?.let { options.fillColor(it.toColorInt()) } diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/ClusterBadgeMetrics.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/ClusterBadgeMetrics.kt index a80d51e..a165f12 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/ClusterBadgeMetrics.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/ClusterBadgeMetrics.kt @@ -4,18 +4,20 @@ package com.margelo.nitro.nitromaps internal object ClusterBadgeMetrics { const val MERGE_GAP_DP = 6.0 - fun badgeRadiusDp(count: Int): Double = when { - count < 2 -> 14.0 - count < 10 -> 17.0 - count < 100 -> 20.0 - count < 1000 -> 24.0 - else -> 28.0 - } + fun badgeRadiusDp(count: Int): Double = + when { + count < 2 -> 14.0 + count < 10 -> 17.0 + count < 100 -> 20.0 + count < 1000 -> 24.0 + else -> 28.0 + } - fun diameterDp(count: Int): Float = when { - count < 10 -> 34f - count < 100 -> 40f - count < 1000 -> 48f - else -> 56f - } + fun diameterDp(count: Int): Float = + when { + count < 10 -> 34f + count < 100 -> 40f + count < 1000 -> 48f + else -> 56f + } } diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/ClusterIconFactory.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/ClusterIconFactory.kt index a32fcd1..f223f32 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/ClusterIconFactory.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/ClusterIconFactory.kt @@ -11,7 +11,9 @@ import com.google.android.gms.maps.model.BitmapDescriptorFactory import java.util.Locale /** Builds and caches circular cluster badge icons (gradient + soft shadow). */ -internal class ClusterIconFactory(private val density: Float) { +internal class ClusterIconFactory( + private val density: Float, +) { private val cache = HashMap() fun icon(count: Int): BitmapDescriptor { @@ -26,37 +28,42 @@ internal class ClusterIconFactory(private val density: Float) { val center = size / 2f val radius = diameter / 2f - val shadow = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.argb(70, 0, 0, 0) - maskFilter = android.graphics.BlurMaskFilter(2.5f * density, android.graphics.BlurMaskFilter.Blur.NORMAL) - } + val shadow = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.argb(70, 0, 0, 0) + maskFilter = android.graphics.BlurMaskFilter(2.5f * density, android.graphics.BlurMaskFilter.Blur.NORMAL) + } canvas.drawCircle(center, center + 1f * density, radius, shadow) - val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { - shader = RadialGradient( - center, - center - radius * 0.3f, - radius, - intArrayOf(Color.parseColor("#4D9EFF"), Color.parseColor("#0A84FF")), - null, - Shader.TileMode.CLAMP, - ) - } + val fill = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + shader = + RadialGradient( + center, + center - radius * 0.3f, + radius, + intArrayOf(Color.parseColor("#4D9EFF"), Color.parseColor("#0A84FF")), + null, + Shader.TileMode.CLAMP, + ) + } canvas.drawCircle(center, center, radius, fill) - val border = Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.STROKE - strokeWidth = 2f * density - color = Color.WHITE - } + val border = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 2f * density + color = Color.WHITE + } canvas.drawCircle(center, center, radius - density, border) - val text = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.WHITE - textAlign = Paint.Align.CENTER - textSize = 13f * density - isFakeBoldText = true - } + val text = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + textAlign = Paint.Align.CENTER + textSize = 13f * density + isFakeBoldText = true + } val baseline = center - (text.descent() + text.ascent()) / 2 canvas.drawText(label, center, baseline, text) diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt index 06f555f..3cf9ac4 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt @@ -27,10 +27,8 @@ import com.margelo.nitro.core.Promise class GoogleMapProviderAdapter( private val context: ThemedReactContext, initialGoogleMapId: String?, -) : - MapProviderAdapter, +) : MapProviderAdapter, LifecycleEventListener { - private var googleMap: GoogleMap? = null private var isUserGesture = false private var hasFiredMapReady = false @@ -43,14 +41,15 @@ class GoogleMapProviderAdapter( private val googleMapIdAtCreation: String? = normalizeGoogleMapId(initialGoogleMapId) - override val view: MapView = MapView( - context, - GoogleMapOptions().apply { - googleMapIdAtCreation?.let { mapId -> - mapId(mapId) - } - }, - ) + override val view: MapView = + MapView( + context, + GoogleMapOptions().apply { + googleMapIdAtCreation?.let { mapId -> + mapId(mapId) + } + }, + ) private val lifecycle = MapViewLifecycleOwner(view) @@ -59,25 +58,27 @@ class GoogleMapProviderAdapter( /** React only mounts views while the host runs; [onHostPause] corrects this. */ private var isHostResumed = true - private val attachStateListener = object : View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(v: View) { - isAttachedToWindow = true - syncLifecycleState() - } + private val attachStateListener = + object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + isAttachedToWindow = true + syncLifecycleState() + } - override fun onViewDetachedFromWindow(v: View) { - isAttachedToWindow = false - syncLifecycleState() + override fun onViewDetachedFromWindow(v: View) { + isAttachedToWindow = false + syncLifecycleState() + } } - } - private val memoryCallbacks = object : ComponentCallbacks { - override fun onConfigurationChanged(newConfig: Configuration) = Unit + private val memoryCallbacks = + object : ComponentCallbacks { + override fun onConfigurationChanged(newConfig: Configuration) = Unit - override fun onLowMemory() { - lifecycle.onLowMemory() + override fun onLowMemory() { + lifecycle.onLowMemory() + } } - } init { context.addLifecycleEventListener(this) @@ -313,9 +314,10 @@ class GoogleMapProviderAdapter( syncMarkerPressHandlers() } - override fun fetchCamera(): Promise = promiseOnMain { - googleMap?.cameraPosition?.toCamera() ?: fallbackCamera() - } + override fun fetchCamera(): Promise = + promiseOnMain { + googleMap?.cameraPosition?.toCamera() ?: fallbackCamera() + } /** The camera the caller last asked for, used until the map itself can answer. */ private fun fallbackCamera(): Camera { @@ -325,10 +327,11 @@ class GoogleMapProviderAdapter( } return Camera( - center = Coordinate( - latitude = _region?.latitude ?: 0.0, - longitude = _region?.longitude ?: 0.0, - ), + center = + Coordinate( + latitude = _region?.latitude ?: 0.0, + longitude = _region?.longitude ?: 0.0, + ), zoom = 10.0, heading = null, pitch = null, @@ -340,14 +343,18 @@ class GoogleMapProviderAdapter( updateMapCamera(camera, animated = false) } - override fun animateCamera(camera: Camera, duration: Double?) { + override fun animateCamera( + camera: Camera, + duration: Double?, + ) { val animationDuration = duration ?: 0.25 updateMapCamera(camera, animated = true, durationMs = (animationDuration * 1000).toInt()) } - override fun getVisibleRegion(): Promise = promiseOnMain { - googleMap?.projection?.toNitroVisibleRegion() ?: emptyVisibleRegion() - } + override fun getVisibleRegion(): Promise = + promiseOnMain { + googleMap?.projection?.toNitroVisibleRegion() ?: emptyVisibleRegion() + } override fun fitToCoordinates( coordinates: Array, @@ -399,11 +406,12 @@ class GoogleMapProviderAdapter( * host is in the foreground. Leaving the window stops the map, never destroys it. */ private fun syncLifecycleState() { - val target = when { - !isAttachedToWindow -> MapViewLifecycleState.CREATED - isHostResumed -> MapViewLifecycleState.RESUMED - else -> MapViewLifecycleState.STARTED - } + val target = + when { + !isAttachedToWindow -> MapViewLifecycleState.CREATED + isHostResumed -> MapViewLifecycleState.RESUMED + else -> MapViewLifecycleState.STARTED + } lifecycle.moveTo(target) } @@ -522,9 +530,10 @@ class GoogleMapProviderAdapter( private fun syncMarkerPressHandlers() { overlayController.setMarkerPressHandlers( onMarkerPress = onMarkerPress, - onClusterPress = onClusterPress?.let { callback -> - { ids, coordinate -> callback(ids.toTypedArray(), coordinate) } - }, + onClusterPress = + onClusterPress?.let { callback -> + { ids, coordinate -> callback(ids.toTypedArray(), coordinate) } + }, ) } @@ -545,14 +554,16 @@ class GoogleMapProviderAdapter( return } - val hasFineLocationPermission = ContextCompat.checkSelfPermission( - context, - Manifest.permission.ACCESS_FINE_LOCATION, - ) == PackageManager.PERMISSION_GRANTED - val hasCoarseLocationPermission = ContextCompat.checkSelfPermission( - context, - Manifest.permission.ACCESS_COARSE_LOCATION, - ) == PackageManager.PERMISSION_GRANTED + val hasFineLocationPermission = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION, + ) == PackageManager.PERMISSION_GRANTED + val hasCoarseLocationPermission = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_COARSE_LOCATION, + ) == PackageManager.PERMISSION_GRANTED if (hasFineLocationPermission || hasCoarseLocationPermission) { map?.isMyLocationEnabled = true @@ -587,7 +598,10 @@ class GoogleMapProviderAdapter( map?.setMapStyle(MapStyleOptions(styleJson)) } - private fun applyRegion(region: Region, animated: Boolean = false) { + private fun applyRegion( + region: Region, + animated: Boolean = false, + ) { val map = googleMap ?: return val bounds = region.toLatLngBounds() val paddingPx = _mapPadding.toPaddingPixels() @@ -646,7 +660,10 @@ class GoogleMapProviderAdapter( runWhenViewLaidOut(view, block) } - private fun runWhenViewLaidOut(target: View, block: () -> Unit) { + private fun runWhenViewLaidOut( + target: View, + block: () -> Unit, + ) { if (target.width > 0 && target.height > 0) { updateOverlayViewportSize() block() diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt index 8eab2bf..ef7f568 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt @@ -12,10 +12,10 @@ private const val MAP_VIEW_NOT_MOUNTED_MESSAGE = "MapView is not mounted" @Keep @DoNotStrip -class HybridMapView(private val context: ThemedReactContext) : - HybridMapViewSpec(), +class HybridMapView( + private val context: ThemedReactContext, +) : HybridMapViewSpec(), RecyclableView { - /** Written on the UI thread, read from the JS thread by the imperative methods. */ @Volatile private var adapter: MapProviderAdapter? = null @@ -284,7 +284,10 @@ class HybridMapView(private val context: ThemedReactContext) : return Promise.resolved(Unit) } - override fun animateCamera(camera: Camera, duration: Double?): Promise { + override fun animateCamera( + camera: Camera, + duration: Double?, + ): Promise { val mounted = adapter ?: return notMountedRejection() mounted.animateCamera(camera, duration) return Promise.resolved(Unit) @@ -347,8 +350,7 @@ class HybridMapView(private val context: ThemedReactContext) : onClusterPress = null } - private fun notMountedRejection(): Promise = - Promise.rejected(IllegalStateException(MAP_VIEW_NOT_MOUNTED_MESSAGE)) + private fun notMountedRejection(): Promise = Promise.rejected(IllegalStateException(MAP_VIEW_NOT_MOUNTED_MESSAGE)) /** * Detaches and destroys the installed adapter. Both teardown paths land here: @@ -374,6 +376,7 @@ class HybridMapView(private val context: ThemedReactContext) : private fun makeAdapter(provider: MapProvider): MapProviderAdapter { return when (provider) { MapProvider.GOOGLE -> GoogleMapProviderAdapter(context, _googleMapId) + MapProvider.APPLE, MapProvider.OPENSTREETMAP, MapProvider.MAPBOX, diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapOverlayController.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapOverlayController.kt index ed08196..d2c4d9d 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapOverlayController.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapOverlayController.kt @@ -58,7 +58,10 @@ class MapOverlayController( } /** Updates the cached map viewport size used to size the clustering grid. */ - fun setViewportSize(widthPx: Int, heightPx: Int) { + fun setViewportSize( + widthPx: Int, + heightPx: Int, + ) { if (viewWidthPx == widthPx && viewHeightPx == heightPx) { return } @@ -164,12 +167,14 @@ class MapOverlayController( computeExecutor.execute { val candidates = index.candidates(bounds) - val elements: List = if (clustering) { - MarkerClusterEngine.clusters(candidates, bounds, widthPx, heightPx, density) - } else { - MarkerViewportFilter.displaySubset(candidates, bounds, latitudeSpan) - .map { ClusterElement.Single(it) } - } + val elements: List = + if (clustering) { + MarkerClusterEngine.clusters(candidates, bounds, widthPx, heightPx, density) + } else { + MarkerViewportFilter + .displaySubset(candidates, bounds, latitudeSpan) + .map { ClusterElement.Single(it) } + } val diff = computeMarkerRenderDiff(elements, displayedVersions) @@ -220,9 +225,10 @@ class MapOverlayController( when (element) { is ClusterElement.Single -> { val animation = enteringAnimation(element) - val shouldAnimate = animateEntering && - remainingAnimationBudget > 0 && - OverlayEnteringAnimationResolver.shouldRun(animation) + val shouldAnimate = + animateEntering && + remainingAnimationBudget > 0 && + OverlayEnteringAnimationResolver.shouldRun(animation) val options = element.descriptor.toMarkerOptions() if (shouldAnimate) { options.alpha(0f) @@ -239,15 +245,18 @@ class MapOverlayController( } } } + is ClusterElement.Cluster -> { val animation = enteringAnimation(element) - val shouldAnimate = animateEntering && - remainingAnimationBudget > 0 && - OverlayEnteringAnimationResolver.shouldRun(animation) - val options = MarkerOptions() - .position(element.position) - .icon(iconFactory.icon(element.count)) - .anchor(0.5f, 0.5f) + val shouldAnimate = + animateEntering && + remainingAnimationBudget > 0 && + OverlayEnteringAnimationResolver.shouldRun(animation) + val options = + MarkerOptions() + .position(element.position) + .icon(iconFactory.icon(element.count)) + .anchor(0.5f, 0.5f) if (shouldAnimate) { options.alpha(0f) } @@ -272,16 +281,18 @@ class MapOverlayController( when (element) { is ClusterElement.Single -> { marker.tag = element.descriptor.id - marker.position = LatLng( - element.descriptor.coordinate.latitude, - element.descriptor.coordinate.longitude, - ) + marker.position = + LatLng( + element.descriptor.coordinate.latitude, + element.descriptor.coordinate.longitude, + ) marker.title = element.descriptor.title marker.snippet = element.descriptor.subtitle marker.isDraggable = element.descriptor.draggable == true markerIconFactory.applyVisualProps(element.descriptor, marker, key) clusterByKey.remove(key) } + is ClusterElement.Cluster -> { marker.alpha = 1f marker.position = element.position @@ -301,23 +312,25 @@ class MapOverlayController( return } - val animated = added.mapNotNull { addedMarker -> - if (!OverlayEnteringAnimationResolver.shouldRun(addedMarker.animation)) { - return@mapNotNull null + val animated = + added.mapNotNull { addedMarker -> + if (!OverlayEnteringAnimationResolver.shouldRun(addedMarker.animation)) { + return@mapNotNull null + } + cancelEnteringAnimation(addedMarker.key) + addedMarker.marker.alpha = 0f + addedMarker } - cancelEnteringAnimation(addedMarker.key) - addedMarker.marker.alpha = 0f - addedMarker - } if (animated.isEmpty()) { return } val startDelayMs = animated.minOf { it.animation.delayMs } - val totalDurationMs = animated.maxOf { - it.animation.delayMs + it.animation.durationMs - } - startDelayMs + val totalDurationMs = + animated.maxOf { + it.animation.delayMs + it.animation.durationMs + } - startDelayMs val animator = ValueAnimator.ofFloat(0f, 1f) animator.duration = totalDurationMs @@ -327,22 +340,25 @@ class MapOverlayController( val elapsed = (animator.animatedFraction * duration).toLong() animated.forEach { animatedMarker -> val localElapsed = elapsed - (animatedMarker.animation.delayMs - startDelay) - val progress = (localElapsed.toFloat() / animatedMarker.animation.durationMs.toFloat()) - .coerceIn(0f, 1f) + val progress = + (localElapsed.toFloat() / animatedMarker.animation.durationMs.toFloat()) + .coerceIn(0f, 1f) animatedMarker.marker.alpha = progress * animatedMarker.targetAlpha } } - addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - revealAnimatedMarkers(animated) - clearCompletedAnimator(animation, animated) - } + addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + revealAnimatedMarkers(animated) + clearCompletedAnimator(animation, animated) + } - override fun onAnimationCancel(animation: Animator) { - revealAnimatedMarkers(animated) - clearCompletedAnimator(animation, animated) - } - }) + override fun onAnimationCancel(animation: Animator) { + revealAnimatedMarkers(animated) + clearCompletedAnimator(animation, animated) + } + }, + ) } animated.forEach { markerEnterAnimators[it.key] = animator } animator.start() @@ -358,7 +374,10 @@ class MapOverlayController( markerEnterAnimators.remove(key)?.cancel() } - private fun clearCompletedAnimator(animator: Animator, animated: List) { + private fun clearCompletedAnimator( + animator: Animator, + animated: List, + ) { animated.forEach { animatedMarker -> if (markerEnterAnimators[animatedMarker.key] === animator) { markerEnterAnimators.remove(animatedMarker.key) @@ -368,11 +387,16 @@ class MapOverlayController( private fun enteringAnimation(element: ClusterElement): ResolvedOverlayEnteringAnimation { return when (element) { - is ClusterElement.Single -> OverlayEnteringAnimationResolver.resolve( - element.descriptor.enteringAnimation, - markerEnteringAnimation, - ) - is ClusterElement.Cluster -> OverlayEnteringAnimationResolver.resolve(clusterEnteringAnimation) + is ClusterElement.Single -> { + OverlayEnteringAnimationResolver.resolve( + element.descriptor.enteringAnimation, + markerEnteringAnimation, + ) + } + + is ClusterElement.Cluster -> { + OverlayEnteringAnimationResolver.resolve(clusterEnteringAnimation) + } } } @@ -412,10 +436,11 @@ class MapOverlayController( val key = "s:" + descriptor.id (marker.tag as? String)?.let { cancelEnteringAnimation(it) } marker.tag = descriptor.id - marker.position = LatLng( - descriptor.coordinate.latitude, - descriptor.coordinate.longitude, - ) + marker.position = + LatLng( + descriptor.coordinate.latitude, + descriptor.coordinate.longitude, + ) marker.title = descriptor.title marker.snippet = descriptor.subtitle marker.isDraggable = descriptor.draggable == true @@ -441,12 +466,13 @@ class MapOverlayController( private fun scheduleIdleRefresh() { cancelLiveRefresh() cancelIdleRefresh() - val runnable = Runnable { - idleRefreshRunnable = null - if (usesViewportPipeline()) { - refreshViewportMarkers() + val runnable = + Runnable { + idleRefreshRunnable = null + if (usesViewportPipeline()) { + refreshViewportMarkers() + } } - } idleRefreshRunnable = runnable mainHandler.postDelayed(runnable, IDLE_REFRESH_DEBOUNCE_MS) } @@ -463,10 +489,11 @@ class MapOverlayController( return } - val runnable = Runnable { - liveRefreshRunnable = null - runLiveRefresh() - } + val runnable = + Runnable { + liveRefreshRunnable = null + runLiveRefresh() + } liveRefreshRunnable = runnable mainHandler.postDelayed(runnable, LIVE_REFRESH_THROTTLE_MS - elapsed) } diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt index 06f7e14..e9916c9 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt @@ -44,10 +44,21 @@ interface MapProviderAdapter { var onClusterPress: ((markerIds: Array, coordinate: Coordinate) -> Unit)? fun fetchCamera(): Promise + fun applyCamera(camera: Camera) - fun animateCamera(camera: Camera, duration: Double?) + + fun animateCamera( + camera: Camera, + duration: Double?, + ) + fun getVisibleRegion(): Promise - fun fitToCoordinates(coordinates: Array, padding: EdgePadding?, animated: Boolean?) + + fun fitToCoordinates( + coordinates: Array, + padding: EdgePadding?, + animated: Boolean?, + ) /** * Destroys the underlying native map and unregisters everything the adapter owns. diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapType+GoogleMap.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapType+GoogleMap.kt index 9a363b3..80cf732 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapType+GoogleMap.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapType+GoogleMap.kt @@ -3,9 +3,10 @@ package com.margelo.nitro.nitromaps import com.google.android.gms.maps.GoogleMap /** Converts the cross-platform map type to Google Maps' native type. */ -fun MapType.toGoogleMapType(): Int = when (this) { - MapType.STANDARD -> GoogleMap.MAP_TYPE_NORMAL - MapType.SATELLITE -> GoogleMap.MAP_TYPE_SATELLITE - MapType.HYBRID -> GoogleMap.MAP_TYPE_HYBRID - MapType.TERRAIN -> GoogleMap.MAP_TYPE_TERRAIN -} +fun MapType.toGoogleMapType(): Int = + when (this) { + MapType.STANDARD -> GoogleMap.MAP_TYPE_NORMAL + MapType.SATELLITE -> GoogleMap.MAP_TYPE_SATELLITE + MapType.HYBRID -> GoogleMap.MAP_TYPE_HYBRID + MapType.TERRAIN -> GoogleMap.MAP_TYPE_TERRAIN + } diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapViewLifecycleOwner.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapViewLifecycleOwner.kt index a7503a9..007ebb0 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapViewLifecycleOwner.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapViewLifecycleOwner.kt @@ -9,7 +9,9 @@ import com.google.android.gms.maps.MapView * Detaching never destroys the map — only an explicit move to * [MapViewLifecycleState.DESTROYED] does — because a destroyed map cannot be resumed. */ -internal class MapViewLifecycleOwner(private val mapView: MapView) { +internal class MapViewLifecycleOwner( + private val mapView: MapView, +) { private var state = MapViewLifecycleState.CREATED val isDestroyed: Boolean @@ -70,7 +72,9 @@ internal class MapViewLifecycleOwner(private val mapView: MapView) { MapViewLifecycleState.RESUMED, MapViewLifecycleState.DESTROYED, - -> Unit + -> { + Unit + } } } @@ -88,7 +92,9 @@ internal class MapViewLifecycleOwner(private val mapView: MapView) { MapViewLifecycleState.CREATED, MapViewLifecycleState.DESTROYED, - -> Unit + -> { + Unit + } } } } diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerClusterEngine.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerClusterEngine.kt index 71f4c56..0bf5d45 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerClusterEngine.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerClusterEngine.kt @@ -12,7 +12,9 @@ internal sealed interface ClusterElement { val diffKey: String val renderVersion: Long - data class Single(val descriptor: MarkerDescriptor) : ClusterElement { + data class Single( + val descriptor: MarkerDescriptor, + ) : ClusterElement { override val diffKey: String get() = "s:" + descriptor.id override val renderVersion: Long = descriptor.displayedIdentityVersion() } @@ -25,18 +27,19 @@ internal sealed interface ClusterElement { val bounds: LatLngBounds, ) : ClusterElement { override val diffKey: String get() = "c:$key" - override val renderVersion: Long = renderSignature( - "cluster", - key, - position.latitude, - position.longitude, - count, - memberIds.sorted(), - bounds.southwest.latitude, - bounds.southwest.longitude, - bounds.northeast.latitude, - bounds.northeast.longitude, - ) + override val renderVersion: Long = + renderSignature( + "cluster", + key, + position.latitude, + position.longitude, + count, + memberIds.sorted(), + bounds.southwest.latitude, + bounds.southwest.longitude, + bounds.northeast.latitude, + bounds.northeast.longitude, + ) } } @@ -50,16 +53,25 @@ internal sealed interface ClusterElement { internal object MarkerClusterEngine { private const val CELL_DP = 64.0 - private fun wrapsLongitude(sw: LatLng, ne: LatLng): Boolean { + private fun wrapsLongitude( + sw: LatLng, + ne: LatLng, + ): Boolean { return ne.longitude < sw.longitude } - private fun longitudeSpan(sw: LatLng, ne: LatLng): Double { + private fun longitudeSpan( + sw: LatLng, + ne: LatLng, + ): Double { val raw = ne.longitude - sw.longitude return if (raw < 0) raw + 360.0 else raw } - private fun normalizeLongitude(lon: Double, reference: Double): Double { + private fun normalizeLongitude( + lon: Double, + reference: Double, + ): Double { var normalized = lon while (normalized - reference > 180.0) { normalized -= 360.0 @@ -132,11 +144,12 @@ internal object MarkerClusterEngine { val buckets = HashMap() for (descriptor in clusterableCandidates) { val lat = descriptor.coordinate.latitude - val lon = if (wraps) { - normalizeLongitude(descriptor.coordinate.longitude, sw.longitude) - } else { - descriptor.coordinate.longitude - } + val lon = + if (wraps) { + normalizeLongitude(descriptor.coordinate.longitude, sw.longitude) + } else { + descriptor.coordinate.longitude + } val row = floor(lat / cellLat).toInt() val col = floor(lon / cellLon).toInt() val key = "$row:$col" @@ -155,14 +168,15 @@ internal object MarkerClusterEngine { bucket.memberIds.add(descriptor.id) } - val merged = mergeOverlapping( - ArrayList(buckets.values), - bounds, - wraps, - viewWidthPx, - viewHeightPx, - density, - ) + val merged = + mergeOverlapping( + ArrayList(buckets.values), + bounds, + wraps, + viewWidthPx, + viewHeightPx, + density, + ) val result = ArrayList(merged.size + singles.size) result.addAll(singles) @@ -177,10 +191,11 @@ internal object MarkerClusterEngine { position = LatLng(bucket.sumLat / bucket.count, bucket.sumLon / bucket.count), count = bucket.count, memberIds = bucket.memberIds, - bounds = LatLngBounds( - LatLng(bucket.minLat, wrapTo180(bucket.minLon)), - LatLng(bucket.maxLat, wrapTo180(bucket.maxLon)), - ), + bounds = + LatLngBounds( + LatLng(bucket.minLat, wrapTo180(bucket.minLon)), + LatLng(bucket.maxLat, wrapTo180(bucket.maxLon)), + ), ), ) } @@ -220,27 +235,30 @@ internal object MarkerClusterEngine { val centerLat = (sw.latitude + ne.latitude) / 2 val spanLat = maxOf(ne.latitude - sw.latitude, 1e-9) val spanLon = maxOf(longitudeSpan(sw, ne), 1e-9) - val centerLon = if (wraps) { - normalizeLongitude(sw.longitude + spanLon / 2, sw.longitude) - } else { - (sw.longitude + ne.longitude) / 2 - } + val centerLon = + if (wraps) { + normalizeLongitude(sw.longitude + spanLon / 2, sw.longitude) + } else { + (sw.longitude + ne.longitude) / 2 + } val px = DoubleArray(n) val py = DoubleArray(n) for (i in 0 until n) { val bucket = buckets[i] val lat = bucket.sumLat / bucket.count - val lon = if (wraps) { - normalizeLongitude(bucket.sumLon / bucket.count, sw.longitude) - } else { - bucket.sumLon / bucket.count - } + val lon = + if (wraps) { + normalizeLongitude(bucket.sumLon / bucket.count, sw.longitude) + } else { + bucket.sumLon / bucket.count + } px[i] = (lon - centerLon) / spanLon * width py[i] = (centerLat - lat) / spanLat * height } val parent = IntArray(n) { it } + fun find(value: Int): Int { var root = value while (parent[root] != root) { diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerIconFactory.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerIconFactory.kt index 6249df6..c44d27d 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerIconFactory.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerIconFactory.kt @@ -60,18 +60,24 @@ internal class MarkerIconFactory( ) } - private fun applyAnchor(descriptor: MarkerDescriptor, marker: Marker) { - val size = if (descriptor.image != null) { - displaySizePx(descriptor.image) ?: (0f to 0f) - } else { - defaultMarkerDisplaySizePx() - } + private fun applyAnchor( + descriptor: MarkerDescriptor, + marker: Marker, + ) { + val size = + if (descriptor.image != null) { + displaySizePx(descriptor.image) ?: (0f to 0f) + } else { + defaultMarkerDisplaySizePx() + } val (anchorX, anchorY) = descriptor.effectiveGoogleMapsAnchor(size.first, size.second, density) marker.setAnchor(anchorX, anchorY) } - private fun isMarkerCurrent(key: String, marker: Marker): Boolean = - markerRegistry()[key] === marker + private fun isMarkerCurrent( + key: String, + marker: Marker, + ): Boolean = markerRegistry()[key] === marker private fun applyIcon( marker: Marker, @@ -85,13 +91,14 @@ internal class MarkerIconFactory( if (isIconApplied(marker, iconKey)) { return } - val icon = if (markerColor == null) { - BitmapDescriptorFactory.defaultMarker() - } else { - val hsv = FloatArray(3) - Color.colorToHSV(markerColor.toColorInt(Color.RED), hsv) - BitmapDescriptorFactory.defaultMarker(hsv[0]) - } + val icon = + if (markerColor == null) { + BitmapDescriptorFactory.defaultMarker() + } else { + val hsv = FloatArray(3) + Color.colorToHSV(markerColor.toColorInt(Color.RED), hsv) + BitmapDescriptorFactory.defaultMarker(hsv[0]) + } marker.setIcon(icon) setApplied(marker, iconKey) onIconApplied() @@ -153,14 +160,16 @@ internal class MarkerIconFactory( return null } - private fun defaultMarkerDisplaySizePx(): Pair = - (DEFAULT_MARKER_WIDTH_DP * density) to (DEFAULT_MARKER_HEIGHT_DP * density) + private fun defaultMarkerDisplaySizePx(): Pair = (DEFAULT_MARKER_WIDTH_DP * density) to (DEFAULT_MARKER_HEIGHT_DP * density) private fun cacheKey(image: MarkerImage): String { return "${image.uri}|${image.width ?: ""}|${image.height ?: ""}|${image.scale ?: ""}" } - private fun loadLocalIcon(image: MarkerImage, key: String): BitmapDescriptor? { + private fun loadLocalIcon( + image: MarkerImage, + key: String, + ): BitmapDescriptor? { val bitmap = loadLocalBitmap(image) ?: return null return cacheBitmap(key, bitmap) } @@ -184,19 +193,25 @@ internal class MarkerIconFactory( } loadExecutor.execute { - val descriptor = if (isRemoteMarkerUri(image.uri)) { - loadRemoteIcon(image, key) - } else { - loadLocalIcon(image, key) - } + val descriptor = + if (isRemoteMarkerUri(image.uri)) { + loadRemoteIcon(image, key) + } else { + loadLocalIcon(image, key) + } deliverOnMainThread { onLoaded(descriptor) } } } - private fun isIconApplied(marker: Marker, key: String): Boolean = - appliedIconKeys[marker] == key + private fun isIconApplied( + marker: Marker, + key: String, + ): Boolean = appliedIconKeys[marker] == key - private fun setApplied(marker: Marker, key: String) { + private fun setApplied( + marker: Marker, + key: String, + ) { appliedIconKeys[marker] = key invalidatePendingLoad(marker) } @@ -225,7 +240,10 @@ internal class MarkerIconFactory( } } - private fun loadRemoteIcon(image: MarkerImage, key: String): BitmapDescriptor? { + private fun loadRemoteIcon( + image: MarkerImage, + key: String, + ): BitmapDescriptor? { remoteMarkerUriRejectReason(image.uri, resolveHostAddress = true)?.let { reason -> logRejectedRemoteMarkerUri(image.uri, reason) return null @@ -276,7 +294,10 @@ internal class MarkerIconFactory( } } - private fun decodeFile(path: String, image: MarkerImage): Bitmap? { + private fun decodeFile( + path: String, + image: MarkerImage, + ): Bitmap? { val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeFile(path, bounds) val options = buildDecodeOptions(bounds.outWidth, bounds.outHeight, image) ?: return null @@ -284,7 +305,10 @@ internal class MarkerIconFactory( return resizeBitmap(decoded, image) } - private fun decodeResource(resourceId: Int, image: MarkerImage): Bitmap? { + private fun decodeResource( + resourceId: Int, + image: MarkerImage, + ): Bitmap? { val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeResource(context.resources, resourceId, bounds) val options = buildDecodeOptions(bounds.outWidth, bounds.outHeight, image) ?: return null @@ -292,7 +316,10 @@ internal class MarkerIconFactory( return resizeBitmap(decoded, image) } - private fun decodeByteArray(bytes: ByteArray, image: MarkerImage): Bitmap? { + private fun decodeByteArray( + bytes: ByteArray, + image: MarkerImage, + ): Bitmap? { if (bytes.isEmpty()) { return null } @@ -317,12 +344,13 @@ internal class MarkerIconFactory( return null } val target = targetDecodeSizePx(image) - val sampleSize = computeInSampleSize( - sourceWidth = sourceWidth, - sourceHeight = sourceHeight, - reqWidth = target?.first, - reqHeight = target?.second, - ) + val sampleSize = + computeInSampleSize( + sourceWidth = sourceWidth, + sourceHeight = sourceHeight, + reqWidth = target?.first, + reqHeight = target?.second, + ) return BitmapFactory.Options().apply { inSampleSize = sampleSize inScaled = false @@ -356,13 +384,20 @@ internal class MarkerIconFactory( return sampleSize } - private fun decodedPixelCount(sourceWidth: Int, sourceHeight: Int, sampleSize: Int): Long { + private fun decodedPixelCount( + sourceWidth: Int, + sourceHeight: Int, + sampleSize: Int, + ): Long { val width = sourceWidth / sampleSize val height = sourceHeight / sampleSize return width.toLong() * height } - private fun cacheBitmap(key: String, bitmap: Bitmap): BitmapDescriptor { + private fun cacheBitmap( + key: String, + bitmap: Bitmap, + ): BitmapDescriptor { val descriptor = BitmapDescriptorFactory.fromBitmap(bitmap) cache.put(key, descriptor) sizeCache.put(key, bitmap.width.toFloat() to bitmap.height.toFloat()) @@ -381,7 +416,10 @@ internal class MarkerIconFactory( null } - private fun remoteMarkerUriRejectReason(uriString: String, resolveHostAddress: Boolean): String? { + private fun remoteMarkerUriRejectReason( + uriString: String, + resolveHostAddress: Boolean, + ): String? { val uri = parseRemoteMarkerUri(uriString) ?: return "invalid URI" when (uri.scheme?.lowercase(Locale.US)) { @@ -402,7 +440,10 @@ internal class MarkerIconFactory( return null } - private fun isAllowlistedRemoteHost(host: String, resolveHostAddress: Boolean): Boolean { + private fun isAllowlistedRemoteHost( + host: String, + resolveHostAddress: Boolean, + ): Boolean { if (host == "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) { return false } @@ -414,11 +455,12 @@ internal class MarkerIconFactory( return true } - val address = try { - InetAddress.getByName(host) - } catch (_: UnknownHostException) { - return !resolveHostAddress - } + val address = + try { + InetAddress.getByName(host) + } catch (_: UnknownHostException) { + return !resolveHostAddress + } return isAllowlistedRemoteAddress(address) } @@ -429,10 +471,11 @@ internal class MarkerIconFactory( } val parts = host.split('.') - return parts.size == 4 && parts.all { part -> - val value = part.toIntOrNull() ?: return@all false - value in 0..255 - } + return parts.size == 4 && + parts.all { part -> + val value = part.toIntOrNull() ?: return@all false + value in 0..255 + } } private fun isAllowlistedRemoteAddress(address: InetAddress): Boolean { @@ -456,11 +499,17 @@ internal class MarkerIconFactory( return true } - private fun logRejectedRemoteMarkerUri(uri: String, reason: String) { + private fun logRejectedRemoteMarkerUri( + uri: String, + reason: String, + ) { Log.w(TAG, "Rejected remote marker image URI ($reason): $uri") } - private fun resizeBitmap(source: Bitmap, image: MarkerImage): Bitmap { + private fun resizeBitmap( + source: Bitmap, + image: MarkerImage, + ): Bitmap { val width = image.width ?: return source val height = image.height ?: return source val targetWidth = (width * density).toInt().coerceAtLeast(1) @@ -483,9 +532,10 @@ internal class MarkerIconFactory( private val loadExecutor: ExecutorService = Executors.newSingleThreadExecutor() - private val BLOCKED_REMOTE_HOSTS = setOf( - "metadata.google.internal", - "metadata.goog", - ) + private val BLOCKED_REMOTE_HOSTS = + setOf( + "metadata.google.internal", + "metadata.goog", + ) } } diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerSpatialIndex.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerSpatialIndex.kt index 0c53335..409187b 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerSpatialIndex.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerSpatialIndex.kt @@ -56,17 +56,21 @@ internal class MarkerSpatialIndex( } /** Markers whose grid cells overlap the padded bounds. */ - fun candidates(bounds: LatLngBounds, padding: Double = 0.2): List { + fun candidates( + bounds: LatLngBounds, + padding: Double = 0.2, + ): List { if (count == 0) { return emptyList() } val latSpan = bounds.northeast.latitude - bounds.southwest.latitude - val lonSpan = if (bounds.northeast.longitude < bounds.southwest.longitude) { - bounds.northeast.longitude - bounds.southwest.longitude + 360.0 - } else { - bounds.northeast.longitude - bounds.southwest.longitude - } + val lonSpan = + if (bounds.northeast.longitude < bounds.southwest.longitude) { + bounds.northeast.longitude - bounds.southwest.longitude + 360.0 + } else { + bounds.northeast.longitude - bounds.southwest.longitude + } val latPad = latSpan * padding val lonPad = lonSpan * padding @@ -88,7 +92,10 @@ internal class MarkerSpatialIndex( return result } - private fun longitudeColumns(minLon: Double, maxLon: Double): List { + private fun longitudeColumns( + minLon: Double, + maxLon: Double, + ): List { if (maxLon - minLon >= 360.0) { return (0 until side).toList() } @@ -113,7 +120,10 @@ internal class MarkerSpatialIndex( return wrapped } - private fun cellIndex(lat: Double, lon: Double): Int { + private fun cellIndex( + lat: Double, + lon: Double, + ): Int { return clampedRow(lat) * side + clampedColumn(lon) } diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerViewportFilter.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerViewportFilter.kt index 036f881..dc87521 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerViewportFilter.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerViewportFilter.kt @@ -22,11 +22,12 @@ internal object MarkerViewportFilter { val maxCount = maxMarkersForZoom(latitudeSpan) val paddedBounds = bounds.expandBy(0.2) - val visible = candidates.filter { descriptor -> - paddedBounds.contains( - LatLng(descriptor.coordinate.latitude, descriptor.coordinate.longitude), - ) - } + val visible = + candidates.filter { descriptor -> + paddedBounds.contains( + LatLng(descriptor.coordinate.latitude, descriptor.coordinate.longitude), + ) + } if (visible.size <= maxCount) { return visible diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/NitroMapsPackage.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/NitroMapsPackage.kt index 2feeb9e..6b0ebef 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/NitroMapsPackage.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/NitroMapsPackage.kt @@ -12,7 +12,10 @@ import com.margelo.nitro.nitromaps.views.HybridMapViewManager * manager and loads the native NitroMaps C++ library. */ class NitroMapsPackage : BaseReactPackage() { - override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + override fun getModule( + name: String, + reactContext: ReactApplicationContext, + ): NativeModule? { return null } diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/OverlayEnteringAnimation.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/OverlayEnteringAnimation.kt index 3b0c1ab..6bbae97 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/OverlayEnteringAnimation.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/OverlayEnteringAnimation.kt @@ -22,14 +22,18 @@ internal object OverlayEnteringAnimationResolver { fallback: OverlayEnteringAnimationDescriptor? = null, ): ResolvedOverlayEnteringAnimation { val descriptor = animation ?: fallback - val kind = when (descriptor?.kind) { - OverlayEnteringAnimationKind.NONE -> ResolvedOverlayEnteringAnimationKind.NONE - OverlayEnteringAnimationKind.FADE -> ResolvedOverlayEnteringAnimationKind.FADE - OverlayEnteringAnimationKind.FADE_SCALE -> ResolvedOverlayEnteringAnimationKind.FADE - OverlayEnteringAnimationKind.SYSTEM, - null, - -> ResolvedOverlayEnteringAnimationKind.SYSTEM - } + val kind = + when (descriptor?.kind) { + OverlayEnteringAnimationKind.NONE -> ResolvedOverlayEnteringAnimationKind.NONE + + OverlayEnteringAnimationKind.FADE -> ResolvedOverlayEnteringAnimationKind.FADE + + OverlayEnteringAnimationKind.FADE_SCALE -> ResolvedOverlayEnteringAnimationKind.FADE + + OverlayEnteringAnimationKind.SYSTEM, + null, + -> ResolvedOverlayEnteringAnimationKind.SYSTEM + } return ResolvedOverlayEnteringAnimation( kind = kind, @@ -56,7 +60,10 @@ internal object OverlayEnteringAnimationResolver { return animation.durationMs > 0 } - private fun milliseconds(value: Double?, fallback: Long): Long { + private fun milliseconds( + value: Double?, + fallback: Long, + ): Long { if (value == null || !value.isFinite()) { return fallback } diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/PolygonDescriptor+PolygonOptions.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/PolygonDescriptor+PolygonOptions.kt index 1219aba..50db49f 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/PolygonDescriptor+PolygonOptions.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/PolygonDescriptor+PolygonOptions.kt @@ -4,11 +4,12 @@ import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.PolygonOptions fun PolygonDescriptor.toPolygonOptions(): PolygonOptions { - val options = PolygonOptions() - .addAll(coordinates.map { LatLng(it.latitude, it.longitude) }) - .strokeWidth((strokeWidth ?: 2.0).toFloat()) - .zIndex((zIndex ?: 0.0).toFloat()) - .clickable(tappable == true) + val options = + PolygonOptions() + .addAll(coordinates.map { LatLng(it.latitude, it.longitude) }) + .strokeWidth((strokeWidth ?: 2.0).toFloat()) + .zIndex((zIndex ?: 0.0).toFloat()) + .clickable(tappable == true) holes?.forEach { hole -> options.addHole(hole.map { LatLng(it.latitude, it.longitude) }) diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/PolylineDescriptor+PolylineOptions.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/PolylineDescriptor+PolylineOptions.kt index 054b3c7..02c2541 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/PolylineDescriptor+PolylineOptions.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/PolylineDescriptor+PolylineOptions.kt @@ -4,11 +4,12 @@ import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.PolylineOptions fun PolylineDescriptor.toPolylineOptions(): PolylineOptions { - val options = PolylineOptions() - .addAll(coordinates.map { LatLng(it.latitude, it.longitude) }) - .width((strokeWidth ?: 4.0).toFloat()) - .zIndex((zIndex ?: 0.0).toFloat()) - .clickable(tappable == true) + val options = + PolylineOptions() + .addAll(coordinates.map { LatLng(it.latitude, it.longitude) }) + .width((strokeWidth ?: 4.0).toFloat()) + .zIndex((zIndex ?: 0.0).toFloat()) + .clickable(tappable == true) strokeColor?.let { options.color(it.toColorInt()) } diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/String+ColorInt.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/String+ColorInt.kt index caf4566..2c0e37c 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/String+ColorInt.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/String+ColorInt.kt @@ -7,11 +7,12 @@ package com.margelo.nitro.nitromaps fun String.toColorInt(fallback: Int = 0xFF000000.toInt()): Int { val digits = trim().removePrefix("#") - val expanded = when (digits.length) { - 3, 4 -> digits.map { "$it$it" }.joinToString("") - 6, 8 -> digits - else -> return fallback - } + val expanded = + when (digits.length) { + 3, 4 -> digits.map { "$it$it" }.joinToString("") + 6, 8 -> digits + else -> return fallback + } val rgba = if (expanded.length == 6) "${expanded}FF" else expanded if (!rgba.all { it.digitToIntOrNull(16) != null }) { diff --git a/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerDisplayedIdentityTest.kt b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerDisplayedIdentityTest.kt index ac15b88..3aad949 100644 --- a/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerDisplayedIdentityTest.kt +++ b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerDisplayedIdentityTest.kt @@ -8,24 +8,25 @@ import org.junit.Test class MarkerDisplayedIdentityTest { @Test fun `visual field changes update displayed identity`() { - val pairs = listOf( - "image" to ( - marker(image = MarkerImage("asset:/pin.png", 32.0, 32.0, 2.0)) to - marker(image = MarkerImage("asset:/pin-alt.png", 32.0, 32.0, 2.0)) + val pairs = + listOf( + "image" to ( + marker(image = MarkerImage("asset:/pin.png", 32.0, 32.0, 2.0)) to + marker(image = MarkerImage("asset:/pin-alt.png", 32.0, 32.0, 2.0)) ), - "rotation" to (marker(rotation = 0.0) to marker(rotation = 45.0)), - "opacity" to (marker(opacity = 1.0) to marker(opacity = 0.4)), - "markerColor" to (marker(markerColor = "#FF0000") to marker(markerColor = "#00FF00")), - "zIndex" to (marker(zIndex = 1.0) to marker(zIndex = 2.0)), - "anchor" to ( - marker(anchor = MarkerAnchor(0.5, 1.0)) to marker(anchor = MarkerAnchor(0.5, 0.5)) + "rotation" to (marker(rotation = 0.0) to marker(rotation = 45.0)), + "opacity" to (marker(opacity = 1.0) to marker(opacity = 0.4)), + "markerColor" to (marker(markerColor = "#FF0000") to marker(markerColor = "#00FF00")), + "zIndex" to (marker(zIndex = 1.0) to marker(zIndex = 2.0)), + "anchor" to ( + marker(anchor = MarkerAnchor(0.5, 1.0)) to marker(anchor = MarkerAnchor(0.5, 0.5)) ), - "centerOffset" to ( - marker(centerOffset = MarkerPoint(0.0, 0.0)) to - marker(centerOffset = MarkerPoint(4.0, -8.0)) + "centerOffset" to ( + marker(centerOffset = MarkerPoint(0.0, 0.0)) to + marker(centerOffset = MarkerPoint(4.0, -8.0)) ), - "flat" to (marker(flat = false) to marker(flat = true)), - ) + "flat" to (marker(flat = false) to marker(flat = true)), + ) for ((field, pair) in pairs) { val (before, after) = pair @@ -59,22 +60,26 @@ class MarkerDisplayedIdentityTest { @Test fun `entering animation change does not update displayed identity`() { - val before = marker( - enteringAnimation = OverlayEnteringAnimationDescriptor( - OverlayEnteringAnimationKind.FADE, - 200.0, - 0.0, - OverlayEnteringAnimationReduceMotion.SYSTEM, - ), - ) - val after = marker( - enteringAnimation = OverlayEnteringAnimationDescriptor( - OverlayEnteringAnimationKind.NONE, - 400.0, - 50.0, - OverlayEnteringAnimationReduceMotion.NEVER, - ), - ) + val before = + marker( + enteringAnimation = + OverlayEnteringAnimationDescriptor( + OverlayEnteringAnimationKind.FADE, + 200.0, + 0.0, + OverlayEnteringAnimationReduceMotion.SYSTEM, + ), + ) + val after = + marker( + enteringAnimation = + OverlayEnteringAnimationDescriptor( + OverlayEnteringAnimationKind.NONE, + 400.0, + 50.0, + OverlayEnteringAnimationReduceMotion.NEVER, + ), + ) assertEquals(before.displayedIdentityVersion(), after.displayedIdentityVersion()) assertNotEquals(before.fingerprint(), after.fingerprint()) @@ -84,40 +89,46 @@ class MarkerDisplayedIdentityTest { fun `displayed identity change reaches retained list`() { val displayed = ClusterElement.Single(marker(opacity = 1.0)) val next = ClusterElement.Single(marker(opacity = 0.2)) - val diff = computeMarkerRenderDiff( - listOf(next), - mapOf(displayed.diffKey to displayed.renderVersion), - ) + val diff = + computeMarkerRenderDiff( + listOf(next), + mapOf(displayed.diffKey to displayed.renderVersion), + ) assertEquals(listOf(next), diff.retained) } @Test fun `entering animation change does not reach retained list`() { - val displayed = ClusterElement.Single( - marker( - enteringAnimation = OverlayEnteringAnimationDescriptor( - OverlayEnteringAnimationKind.FADE, - 200.0, - null, - null, + val displayed = + ClusterElement.Single( + marker( + enteringAnimation = + OverlayEnteringAnimationDescriptor( + OverlayEnteringAnimationKind.FADE, + 200.0, + null, + null, + ), ), - ), - ) - val next = ClusterElement.Single( - marker( - enteringAnimation = OverlayEnteringAnimationDescriptor( - OverlayEnteringAnimationKind.NONE, - 400.0, - null, - null, + ) + val next = + ClusterElement.Single( + marker( + enteringAnimation = + OverlayEnteringAnimationDescriptor( + OverlayEnteringAnimationKind.NONE, + 400.0, + null, + null, + ), ), - ), - ) - val diff = computeMarkerRenderDiff( - listOf(next), - mapOf(displayed.diffKey to displayed.renderVersion), - ) + ) + val diff = + computeMarkerRenderDiff( + listOf(next), + mapOf(displayed.diffKey to displayed.renderVersion), + ) assertTrue(diff.retained.isEmpty()) assertTrue(diff.added.isEmpty()) diff --git a/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerRenderDiffTest.kt b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerRenderDiffTest.kt index 916e577..2a3c95f 100644 --- a/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerRenderDiffTest.kt +++ b/package/android/src/test/java/com/margelo/nitro/nitromaps/MarkerRenderDiffTest.kt @@ -19,10 +19,11 @@ class MarkerRenderDiffTest { @Test fun `missing keys are removed`() { val kept = ClusterElement.Single(marker(id = "a")) - val diff = computeMarkerRenderDiff( - listOf(kept), - mapOf("s:a" to kept.renderVersion, "s:gone" to 9L), - ) + val diff = + computeMarkerRenderDiff( + listOf(kept), + mapOf("s:a" to kept.renderVersion, "s:gone" to 9L), + ) assertEquals(setOf("s:gone"), diff.removedKeys) assertTrue(diff.added.isEmpty()) @@ -33,10 +34,11 @@ class MarkerRenderDiffTest { fun `version change marks retained`() { val displayed = ClusterElement.Single(marker(id = "a", opacity = 1.0)) val next = ClusterElement.Single(marker(id = "a", opacity = 0.2)) - val diff = computeMarkerRenderDiff( - listOf(next), - mapOf(displayed.diffKey to displayed.renderVersion), - ) + val diff = + computeMarkerRenderDiff( + listOf(next), + mapOf(displayed.diffKey to displayed.renderVersion), + ) assertTrue(diff.removedKeys.isEmpty()) assertTrue(diff.added.isEmpty()) @@ -46,10 +48,11 @@ class MarkerRenderDiffTest { @Test fun `unchanged version is skipped`() { val element = ClusterElement.Single(marker(id = "a")) - val diff = computeMarkerRenderDiff( - listOf(element), - mapOf(element.diffKey to element.renderVersion), - ) + val diff = + computeMarkerRenderDiff( + listOf(element), + mapOf(element.diffKey to element.renderVersion), + ) assertTrue(diff.removedKeys.isEmpty()) assertTrue(diff.added.isEmpty()) diff --git a/package/ios/CustomMapStyle+MKMapConfiguration.swift b/package/ios/CustomMapStyle+MKMapConfiguration.swift index 9192346..5f9c5d8 100644 --- a/package/ios/CustomMapStyle+MKMapConfiguration.swift +++ b/package/ios/CustomMapStyle+MKMapConfiguration.swift @@ -6,8 +6,9 @@ enum CustomMapStyleParser { @available(iOS 16.0, *) static func apply(json: String?, mapType: MapType, to mapView: MKMapView) { guard let json, !json.isEmpty, - let data = json.data(using: .utf8), - let rules = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { + let data = json.data(using: .utf8), + let rules = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] + else { mapView.preferredConfiguration = mapType.toMKMapConfiguration() return } diff --git a/package/ios/GoogleMapOverlayController.swift b/package/ios/GoogleMapOverlayController.swift index 6636451..a1183fc 100644 --- a/package/ios/GoogleMapOverlayController.swift +++ b/package/ios/GoogleMapOverlayController.swift @@ -125,10 +125,10 @@ final class GoogleMapOverlayController { func handleMarkerTap(_ marker: GMSMarker) -> Bool { switch marker.userData as? MarkerPayload { - case let .marker(id): + case .marker(let id): onMarkerPress?(id) return marker.title == nil && marker.snippet == nil - case let .cluster(memberIds, region): + case .cluster(let memberIds, let region): onClusterPress?( memberIds, Coordinate(latitude: marker.position.latitude, longitude: marker.position.longitude) @@ -141,7 +141,7 @@ final class GoogleMapOverlayController { } func handleMarkerDragEnd(_ marker: GMSMarker) { - guard case let .marker(id) = marker.userData as? MarkerPayload else { + guard case .marker(let id) = marker.userData as? MarkerPayload else { return } @@ -228,7 +228,8 @@ final class GoogleMapOverlayController { let marker = GMSMarker() updateMarker(marker, with: entry.element) let animation = enteringAnimation(for: entry.element) - let shouldAnimate = animateEntering + let shouldAnimate = + animateEntering && remainingAnimationBudget > 0 && OverlayEnteringAnimationResolver.canAnimateGoogleMarker(animation) @@ -275,7 +276,7 @@ final class GoogleMapOverlayController { for element: MarkerClusterEngine.Element ) -> ResolvedOverlayEnteringAnimation { switch element { - case let .single(descriptor): + case .single(let descriptor): return OverlayEnteringAnimationResolver.resolve( descriptor.enteringAnimation, fallback: markerEnteringAnimation @@ -287,7 +288,7 @@ final class GoogleMapOverlayController { private func updateMarker(_ marker: GMSMarker, with element: MarkerClusterEngine.Element) { switch element { - case let .single(descriptor): + case .single(let descriptor): marker.position = descriptor.coordinate.toCLLocationCoordinate2D() marker.title = descriptor.title marker.snippet = descriptor.subtitle @@ -295,7 +296,7 @@ final class GoogleMapOverlayController { marker.zIndex = Self.nativeZIndex(descriptor.zIndex) marker.userData = MarkerPayload.marker(descriptor.id) visualApplier.apply(descriptor, to: marker) - case let .cluster(_, coordinate, count, memberIds, region): + case .cluster(_, let coordinate, let count, let memberIds, let region): marker.position = coordinate marker.title = nil marker.snippet = nil @@ -323,10 +324,11 @@ final class GoogleMapOverlayController { let icon = UIGraphicsImageRenderer(size: CGSize(width: diameter, height: diameter), format: format) .image { context in let rect = CGRect(x: 0, y: 0, width: diameter, height: diameter) - let colors = [ - UIColor(red: 0.30, green: 0.62, blue: 1.0, alpha: 1).cgColor, - UIColor(red: 0.04, green: 0.52, blue: 1.0, alpha: 1).cgColor, - ] as CFArray + let colors = + [ + UIColor(red: 0.30, green: 0.62, blue: 1.0, alpha: 1).cgColor, + UIColor(red: 0.04, green: 0.52, blue: 1.0, alpha: 1).cgColor, + ] as CFArray let colorSpace = CGColorSpaceCreateDeviceRGB() let gradient = CGGradient(colorsSpace: colorSpace, colors: colors, locations: [0, 1])! context.cgContext.addEllipse(in: rect.insetBy(dx: 1, dy: 1)) @@ -412,9 +414,10 @@ final class GoogleMapOverlayController { polygon.path = descriptor.coordinates.toGMSPath() polygon.holes = descriptor.holes?.map { $0.toGMSPath() } polygon.strokeColor = descriptor.strokeColor?.toUIColor(fallback: .systemBlue) ?? .systemBlue - polygon.fillColor = descriptor.fillColor?.toUIColor( - fallback: UIColor.systemBlue.withAlphaComponent(0.2) - ) ?? UIColor.systemBlue.withAlphaComponent(0.2) + polygon.fillColor = + descriptor.fillColor?.toUIColor( + fallback: UIColor.systemBlue.withAlphaComponent(0.2) + ) ?? UIColor.systemBlue.withAlphaComponent(0.2) polygon.strokeWidth = CGFloat(descriptor.strokeWidth ?? 2) polygon.zIndex = Self.nativeZIndex(descriptor.zIndex) polygon.isTappable = descriptor.tappable ?? false @@ -434,9 +437,10 @@ final class GoogleMapOverlayController { circle.position = descriptor.center.toCLLocationCoordinate2D() circle.radius = descriptor.radius circle.strokeColor = descriptor.strokeColor?.toUIColor(fallback: .systemBlue) ?? .systemBlue - circle.fillColor = descriptor.fillColor?.toUIColor( - fallback: UIColor.systemBlue.withAlphaComponent(0.2) - ) ?? UIColor.systemBlue.withAlphaComponent(0.2) + circle.fillColor = + descriptor.fillColor?.toUIColor( + fallback: UIColor.systemBlue.withAlphaComponent(0.2) + ) ?? UIColor.systemBlue.withAlphaComponent(0.2) circle.strokeWidth = CGFloat(descriptor.strokeWidth ?? 2) circle.isTappable = descriptor.tappable ?? false circle.userData = descriptor.id @@ -478,8 +482,8 @@ extension PolylineDescriptor: IdentifiedOverlayDescriptor {} extension PolygonDescriptor: IdentifiedOverlayDescriptor {} extension CircleDescriptor: IdentifiedOverlayDescriptor {} -private extension Array where Element == Coordinate { - func toGMSPath() -> GMSPath { +extension Array where Element == Coordinate { + fileprivate func toGMSPath() -> GMSPath { let path = GMSMutablePath() for coordinate in self { path.add(coordinate.toCLLocationCoordinate2D()) diff --git a/package/ios/GoogleMapProviderAdapter.swift b/package/ios/GoogleMapProviderAdapter.swift index 95c5b6a..591ad83 100644 --- a/package/ios/GoogleMapProviderAdapter.swift +++ b/package/ios/GoogleMapProviderAdapter.swift @@ -32,11 +32,13 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter { } lazy var view: GMSMapView = { - let camera = self.camera?.toGMSCameraPosition() + let camera = + self.camera?.toGMSCameraPosition() ?? GMSCameraPosition(latitude: 0, longitude: 0, zoom: 10) let mapView: GMSMapView if let googleMapId = _googleMapId?.trimmingCharacters(in: .whitespacesAndNewlines), - !googleMapId.isEmpty { + !googleMapId.isEmpty + { mapView = GMSMapView( frame: .zero, mapID: GMSMapID(identifier: googleMapId), @@ -454,10 +456,11 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter { followedLocationMapView = mapView myLocationObservation = mapView.observe(\.myLocation, options: [.new]) { [weak self, weak mapView] _, change in guard let self, - self.followsUserLocation == true, - self.showsUserLocation == true, - let mapView, - let location = change.newValue ?? mapView.myLocation else { + self.followsUserLocation == true, + self.showsUserLocation == true, + let mapView, + let location = change.newValue ?? mapView.myLocation + else { return } diff --git a/package/ios/GoogleMapsAPIKey.swift b/package/ios/GoogleMapsAPIKey.swift index 0604b38..ecc1578 100644 --- a/package/ios/GoogleMapsAPIKey.swift +++ b/package/ios/GoogleMapsAPIKey.swift @@ -9,8 +9,9 @@ enum GoogleMapsAPIKey { static func configureIfNeeded() throws { let key = Bundle.main.object(forInfoDictionaryKey: "GoogleMapsIosApiKey") as? String guard let key = key?.trimmingCharacters(in: .whitespacesAndNewlines), - !key.isEmpty, - !key.hasPrefix("$(") else { + !key.isEmpty, + !key.hasPrefix("$(") + else { throw MapProviderConfigurationError.missingGoogleMapsIosApiKey } @@ -34,8 +35,9 @@ enum MapProviderConfigurationError: LocalizedError { case .missingGoogleMapsIosApiKey: return "react-native-better-maps: provider=\"google\" on iOS requires GoogleMapsIosApiKey in the host app Info.plist." case .googleMapsSdkNotLinked: - return "react-native-better-maps: provider=\"google\" on iOS requires the Google Maps SDK to be linked. Configure iosGoogleMapsApiKey or googleMapsApiKey in the config plugin, or set betterMaps.iosGoogleProvider=true in Podfile.properties.json, then run pod install." - case let .unsupportedIOSProvider(provider): + return + "react-native-better-maps: provider=\"google\" on iOS requires the Google Maps SDK to be linked. Configure iosGoogleMapsApiKey or googleMapsApiKey in the config plugin, or set betterMaps.iosGoogleProvider=true in Podfile.properties.json, then run pod install." + case .unsupportedIOSProvider(let provider): return "Map provider \"\(provider)\" is not supported on iOS." } } diff --git a/package/ios/GoogleMarkerVisualApplier.swift b/package/ios/GoogleMarkerVisualApplier.swift index e1f4dc2..97d64d8 100644 --- a/package/ios/GoogleMarkerVisualApplier.swift +++ b/package/ios/GoogleMarkerVisualApplier.swift @@ -51,9 +51,10 @@ final class GoogleMarkerVisualApplier { guard let image = descriptor.image else { cancelPending(state) let markerColor = descriptor.markerColor - let iconToken = markerColor.map { - "\(Self.defaultIconToken):\($0)" as NSString - } ?? Self.defaultIconToken + let iconToken = + markerColor.map { + "\(Self.defaultIconToken):\($0)" as NSString + } ?? Self.defaultIconToken if state.appliedImageToken != iconToken { marker.icon = markerColor.map { GMSMarker.markerImage(with: $0.toUIColor(fallback: .systemRed)) @@ -89,7 +90,7 @@ final class GoogleMarkerVisualApplier { return } guard let pending = state.pending, - pending.applicationToken === applicationToken + pending.applicationToken === applicationToken else { return } @@ -134,7 +135,7 @@ final class GoogleMarkerVisualApplier { private func anchorImageSize(_ descriptor: MarkerDescriptor, icon: UIImage?) -> CGSize { if let image = descriptor.image, let width = image.width, let height = image.height, - width > 0, height > 0 + width > 0, height > 0 { return CGSize(width: width, height: height) } diff --git a/package/ios/HybridMapView.swift b/package/ios/HybridMapView.swift index 05dca26..de8d26f 100644 --- a/package/ios/HybridMapView.swift +++ b/package/ios/HybridMapView.swift @@ -350,7 +350,8 @@ final class HybridMapView: HybridMapViewSpec { } if let lifecycle, - lifecycle.isRecycled || lifecycle.generation != currentGeneration { + lifecycle.isRecycled || lifecycle.generation != currentGeneration + { throw Self.mapViewNotMountedError() } } @@ -376,17 +377,17 @@ final class HybridMapView: HybridMapViewSpec { case .apple: return AppleMapProviderAdapter() case .google: -#if canImport(GoogleMaps) + #if canImport(GoogleMaps) do { return try GoogleMapProviderAdapter(googleMapId: withStateLock { self._state.googleMapId }) } catch { return UnavailableMapProviderAdapter(error: error) } -#else + #else return UnavailableMapProviderAdapter( error: MapProviderConfigurationError.googleMapsSdkNotLinked ) -#endif + #endif case .openstreetmap, .mapbox: return UnavailableMapProviderAdapter( error: MapProviderConfigurationError.unsupportedIOSProvider(provider) diff --git a/package/ios/HybridMapViewDelegate.swift b/package/ios/HybridMapViewDelegate.swift index 67891a2..9303e8e 100644 --- a/package/ios/HybridMapViewDelegate.swift +++ b/package/ios/HybridMapViewDelegate.swift @@ -101,9 +101,10 @@ final class HybridMapViewDelegate: NSObject, MKMapViewDelegate, UIGestureRecogni func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { if let cluster = annotation as? MapClusterAnnotation { - let view = mapView.dequeueReusableAnnotationView( - withIdentifier: NitroClusterAnnotationView.reuseIdentifier - ) as? NitroClusterAnnotationView + let view = + mapView.dequeueReusableAnnotationView( + withIdentifier: NitroClusterAnnotationView.reuseIdentifier + ) as? NitroClusterAnnotationView ?? NitroClusterAnnotationView( annotation: cluster, reuseIdentifier: NitroClusterAnnotationView.reuseIdentifier @@ -119,19 +120,21 @@ final class HybridMapViewDelegate: NSObject, MKMapViewDelegate, UIGestureRecogni } if marker.image != nil { - let imageView = mapView.dequeueReusableAnnotationView( - withIdentifier: NitroImageAnnotationView.reuseIdentifier, - for: marker - ) as! NitroImageAnnotationView + let imageView = + mapView.dequeueReusableAnnotationView( + withIdentifier: NitroImageAnnotationView.reuseIdentifier, + for: marker + ) as! NitroImageAnnotationView imageView.configure(for: marker) return imageView } - let pinView = mapView.dequeueReusableAnnotationView( - withIdentifier: NitroPinAnnotationView.reuseIdentifier, - for: marker - ) as! NitroPinAnnotationView + let pinView = + mapView.dequeueReusableAnnotationView( + withIdentifier: NitroPinAnnotationView.reuseIdentifier, + for: marker + ) as! NitroPinAnnotationView pinView.configure(for: marker) return pinView @@ -140,14 +143,16 @@ final class HybridMapViewDelegate: NSObject, MKMapViewDelegate, UIGestureRecogni func mapView(_ mapView: MKMapView, didAdd views: [MKAnnotationView]) { for view in views { if let marker = view.annotation as? MapMarkerAnnotation, - marker.enteringAnimation.kind != .system { + marker.enteringAnimation.kind != .system + { OverlayEnteringAnimationResolver.animateAnnotationView( view, animation: marker.enteringAnimation, supportsScale: true ) } else if let cluster = view.annotation as? MapClusterAnnotation, - cluster.enteringAnimation.kind != .system { + cluster.enteringAnimation.kind != .system + { OverlayEnteringAnimationResolver.animateAnnotationView( view, animation: cluster.enteringAnimation, @@ -159,8 +164,9 @@ final class HybridMapViewDelegate: NSObject, MKMapViewDelegate, UIGestureRecogni func mapView(_ mapView: MKMapView, didSelect view: MKAnnotationView) { if #available(iOS 16.0, *), - let mapFeature = view.annotation as? MKMapFeatureAnnotation, - handleMapFeatureSelection(mapFeature) { + let mapFeature = view.annotation as? MKMapFeatureAnnotation, + handleMapFeatureSelection(mapFeature) + { return } @@ -187,7 +193,8 @@ final class HybridMapViewDelegate: NSObject, MKMapViewDelegate, UIGestureRecogni func mapView(_ mapView: MKMapView, didSelect annotation: MKAnnotation) { if #available(iOS 16.0, *), - let mapFeature = annotation as? MKMapFeatureAnnotation { + let mapFeature = annotation as? MKMapFeatureAnnotation + { _ = handleMapFeatureSelection(mapFeature) } } @@ -221,7 +228,8 @@ final class HybridMapViewDelegate: NSObject, MKMapViewDelegate, UIGestureRecogni fromOldState oldState: MKAnnotationView.DragState ) { guard newState == .ending, - let marker = view.annotation as? MapMarkerAnnotation else { + let marker = view.annotation as? MapMarkerAnnotation + else { return } diff --git a/package/ios/MapMarkerAnnotation.swift b/package/ios/MapMarkerAnnotation.swift index 97e8d04..9c916ec 100644 --- a/package/ios/MapMarkerAnnotation.swift +++ b/package/ios/MapMarkerAnnotation.swift @@ -62,15 +62,17 @@ final class MapMarkerAnnotation: NSObject, MKAnnotation { draggable = nextDraggable isClusterable = nextClusterable - let imageChanged = switch (image, descriptor.image) { - case (nil, nil): false - case let (current?, next?): - MarkerImageLoader.cacheKey(for: current) != MarkerImageLoader.cacheKey(for: next) - default: true - } + let imageChanged = + switch (image, descriptor.image) { + case (nil, nil): false + case (let current?, let next?): + MarkerImageLoader.cacheKey(for: current) != MarkerImageLoader.cacheKey(for: next) + default: true + } let markerColorChanged = markerColor != descriptor.markerColor let anchorChanged = anchor?.x != descriptor.anchor?.x || anchor?.y != descriptor.anchor?.y - let centerOffsetChanged = centerOffset?.x != descriptor.centerOffset?.x + let centerOffsetChanged = + centerOffset?.x != descriptor.centerOffset?.x || centerOffset?.y != descriptor.centerOffset?.y let rotationChanged = rotation != descriptor.rotation let flatChanged = flat != descriptor.flat diff --git a/package/ios/MapOverlayController.swift b/package/ios/MapOverlayController.swift index 73a6378..4127fc4 100644 --- a/package/ios/MapOverlayController.swift +++ b/package/ios/MapOverlayController.swift @@ -146,14 +146,14 @@ final class MapOverlayController { } switch entry.element { - case let .single(descriptor): + case .single(let descriptor): if let marker = existing as? MapMarkerAnnotation { let visualChanged = marker.update(from: descriptor) if visualChanged { refreshMarkerView(for: marker) } } - case let .cluster(key, coordinate, count, memberIds, region): + case .cluster(let key, let coordinate, let count, let memberIds, let region): if let cluster = existing as? MapClusterAnnotation { cluster.update( id: key, diff --git a/package/ios/MarkerClusterEngine.swift b/package/ios/MarkerClusterEngine.swift index d45c6b6..4dc7287 100644 --- a/package/ios/MarkerClusterEngine.swift +++ b/package/ios/MarkerClusterEngine.swift @@ -21,18 +21,18 @@ enum MarkerClusterEngine { /// instead of removing and re-adding the native marker during gestures. var diffKey: String { switch self { - case let .single(descriptor): + case .single(let descriptor): return "s:" + descriptor.id - case let .cluster(key, _, _, _, _): + case .cluster(let key, _, _, _, _): return "c:" + key } } var renderVersion: Int { switch self { - case let .single(descriptor): + case .single(let descriptor): return descriptor.displayedIdentityVersion() - case let .cluster(key, coordinate, count, memberIds, region): + case .cluster(let key, let coordinate, let count, let memberIds, let region): var hasher = Hasher() hasher.combine("cluster") hasher.combine(key) @@ -55,7 +55,7 @@ enum MarkerClusterEngine { clusterEnteringAnimation: OverlayEnteringAnimationDescriptor? ) -> MKAnnotation { switch self { - case let .single(descriptor): + case .single(let descriptor): return MapMarkerAnnotation( descriptor: descriptor, enteringAnimation: OverlayEnteringAnimationResolver.resolve( @@ -63,7 +63,7 @@ enum MarkerClusterEngine { fallback: markerEnteringAnimation ) ) - case let .cluster(key, coordinate, count, memberIds, region): + case .cluster(let key, let coordinate, let count, let memberIds, let region): return MapClusterAnnotation( id: key, coordinate: coordinate, @@ -187,7 +187,8 @@ enum MarkerClusterEngine { var buckets: [String: Bucket] = [:] for descriptor in clusterableCandidates { let lat = descriptor.coordinate.latitude - let lon = wraps + let lon = + wraps ? normalizeLongitude(descriptor.coordinate.longitude, reference: referenceLon) : descriptor.coordinate.longitude let row = Int((lat / cellLat).rounded(.down)) @@ -223,21 +224,22 @@ enum MarkerClusterEngine { if bucket.count == 1, let descriptor = bucket.first { elements.append(.single(descriptor)) } else { - elements.append(.cluster( - key: bucket.key, - coordinate: CLLocationCoordinate2D( - latitude: bucket.sumLat / Double(bucket.count), - longitude: bucket.sumLon / Double(bucket.count) - ), - count: bucket.count, - memberIds: bucket.memberIds, - region: expandedRegion( - minLat: bucket.minLat, - maxLat: bucket.maxLat, - minLon: wrapTo180(bucket.minLon), - maxLon: wrapTo180(bucket.maxLon) - ) - )) + elements.append( + .cluster( + key: bucket.key, + coordinate: CLLocationCoordinate2D( + latitude: bucket.sumLat / Double(bucket.count), + longitude: bucket.sumLon / Double(bucket.count) + ), + count: bucket.count, + memberIds: bucket.memberIds, + region: expandedRegion( + minLat: bucket.minLat, + maxLat: bucket.maxLat, + minLon: wrapTo180(bucket.minLon), + maxLon: wrapTo180(bucket.maxLon) + ) + )) } } return elements @@ -264,7 +266,8 @@ enum MarkerClusterEngine { let referenceLon = region.center.longitude - region.span.longitudeDelta / 2 let spanLat = max(region.span.latitudeDelta, 1e-9) let spanLon = max(region.span.longitudeDelta, 1e-9) - let centerLon = wraps + let centerLon = + wraps ? normalizeLongitude(referenceLon + spanLon / 2, reference: referenceLon) : region.center.longitude @@ -273,7 +276,8 @@ enum MarkerClusterEngine { for i in 0.. Bool { +extension MKCoordinateRegion { + fileprivate func contains(_ coordinate: Coordinate, padding: Double) -> Bool { let latPadding = span.latitudeDelta * padding let lonPadding = span.longitudeDelta * padding let minLat = center.latitude - span.latitudeDelta / 2 - latPadding diff --git a/package/ios/NitroImageAnnotationView.swift b/package/ios/NitroImageAnnotationView.swift index 4e2aee1..ab01643 100644 --- a/package/ios/NitroImageAnnotationView.swift +++ b/package/ios/NitroImageAnnotationView.swift @@ -24,9 +24,10 @@ final class NitroImageAnnotationView: MKAnnotationView { isDraggable = marker.draggable canShowCallout = marker.title != nil || marker.subtitle != nil alpha = marker.opacity - zPriority = marker.zIndex.map { - MKAnnotationViewZPriority(rawValue: Float($0)) - } ?? .defaultUnselected + zPriority = + marker.zIndex.map { + MKAnnotationViewZPriority(rawValue: Float($0)) + } ?? .defaultUnselected guard let imageDescriptor = marker.image else { loadToken = nil @@ -49,8 +50,9 @@ final class NitroImageAnnotationView: MKAnnotationView { guard let self else { return } guard let marker = self.annotation as? MapMarkerAnnotation, - let image = marker.image, - MarkerImageLoader.cacheKey(for: image) == token else { + let image = marker.image, + MarkerImageLoader.cacheKey(for: image) == token + else { if self.loadToken == token { self.loadToken = nil } @@ -70,7 +72,8 @@ final class NitroImageAnnotationView: MKAnnotationView { centerOffset = marker.centerOffset(forImageSize: imageSize) let rotation = marker.rotation ?? 0 - transform = marker.flat != true && rotation != 0 + transform = + marker.flat != true && rotation != 0 ? CGAffineTransform(rotationAngle: rotation * .pi / 180) : .identity } diff --git a/package/ios/NitroPinAnnotationView.swift b/package/ios/NitroPinAnnotationView.swift index 9226cab..0c52091 100644 --- a/package/ios/NitroPinAnnotationView.swift +++ b/package/ios/NitroPinAnnotationView.swift @@ -30,16 +30,18 @@ final class NitroPinAnnotationView: MKMarkerAnnotationView { displayPriority = .required alpha = marker.opacity markerTintColor = marker.markerColor?.toUIColor(fallback: .systemRed) - zPriority = marker.zIndex.map { - MKAnnotationViewZPriority(rawValue: Float($0)) - } ?? .defaultUnselected + zPriority = + marker.zIndex.map { + MKAnnotationViewZPriority(rawValue: Float($0)) + } ?? .defaultUnselected layoutIfNeeded() let pinSize = bounds.size == .zero ? Self.defaultPinSize : bounds.size centerOffset = marker.centerOffset(forImageSize: pinSize) let rotation = marker.rotation ?? 0 - transform = marker.flat != true && rotation != 0 + transform = + marker.flat != true && rotation != 0 ? CGAffineTransform(rotationAngle: rotation * .pi / 180) : .identity } diff --git a/package/ios/OverlayEnteringAnimation.swift b/package/ios/OverlayEnteringAnimation.swift index 914ebc8..882eee2 100644 --- a/package/ios/OverlayEnteringAnimation.swift +++ b/package/ios/OverlayEnteringAnimation.swift @@ -1,8 +1,9 @@ +import QuartzCore +import UIKit + #if canImport(GoogleMaps) import GoogleMaps #endif -import QuartzCore -import UIKit enum ResolvedOverlayEnteringAnimationKind: Equatable { case none @@ -81,7 +82,7 @@ enum OverlayEnteringAnimationResolver { } } -#if canImport(GoogleMaps) + #if canImport(GoogleMaps) static func prepareGoogleMarker(_ marker: GMSMarker, animation: ResolvedOverlayEnteringAnimation) { marker.appearAnimation = .none marker.iconView = nil @@ -176,7 +177,7 @@ enum OverlayEnteringAnimationResolver { } } -#endif + #endif private static func seconds( fromMilliseconds value: Double?, From 22f7e2d316fff0ce3a86bf2fc363446c4530076f Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Fri, 11 Sep 2026 15:42:40 +0200 Subject: [PATCH 8/9] chore: tell reporters to redact secrets in issue forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bug and build-error forms ask for logs, build output and native configuration — exactly the places a Google Maps API key ends up. Issues are public, so each of those fields now says to replace keys, tokens and credentials with placeholders first, naming the three keys build logs echo. The log field also says where a leak by this library itself belongs: a private security advisory, not a public issue, matching SECURITY.md. --- .github/ISSUE_TEMPLATE/BUG_REPORT.yml | 5 +++++ .github/ISSUE_TEMPLATE/BUILD_ERROR.yml | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.yml b/.github/ISSUE_TEMPLATE/BUG_REPORT.yml index 8eda52e..80240f5 100644 --- a/.github/ISSUE_TEMPLATE/BUG_REPORT.yml +++ b/.github/ISSUE_TEMPLATE/BUG_REPORT.yml @@ -15,6 +15,8 @@ body: description: > Share a small reproduceable snippet — ideally the whole component. Include the `MapView` props you set, the marker/overlay data shape, and any imperative `MapViewRef` calls. + + **Redact secrets first.** Replace Google Maps API keys, tokens and any private configuration with placeholders — this issue is public. render: tsx placeholder: | Share the full build output, from the first command to the last line. Do not paste only the final few lines — the real cause is usually much earlier. + + **Redact secrets first.** Build logs can echo `googleMapsApiKey`, `GoogleMapsIosApiKey` and `com.google.android.geo.API_KEY` — replace them with placeholders before pasting. render: shell validations: required: true @@ -39,6 +41,8 @@ body: If you use the config plugin, share the `react-native-better-maps` entry from your `app.json` / `app.config.js`. For a bare app, share the relevant parts of `Info.plist`, `AndroidManifest.xml` and `Podfile.properties.json` instead. See [Expo setup](https://github.com/gmi-software/react-native-better-maps/blob/main/docs/expo-setup.md). + + **Redact secrets first.** Replace the actual API key values with placeholders such as `` — this issue is public. render: json validations: required: false From 9497304f32d102fcb70ba7f97d2af4b4e7a5260c Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Fri, 11 Sep 2026 15:42:41 +0200 Subject: [PATCH 9/9] fix: preserve backslashes when reading paths in the formatter scripts `read` without `-r` interprets backslashes, so a path containing one would reach the formatter mangled. No such path exists today; `read -r -d ''` is the correct idiom for NUL-delimited input and costs nothing. --- scripts/clang-format.sh | 2 +- scripts/swift-format.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/clang-format.sh b/scripts/clang-format.sh index 2c7c8a1..fcb832c 100755 --- a/scripts/clang-format.sh +++ b/scripts/clang-format.sh @@ -20,7 +20,7 @@ CPP_DIRS=( if which clang-format >/dev/null; then DIRS=$(printf "%s " "${CPP_DIRS[@]}") - find $DIRS -type f \( -name "*.h" -o -name "*.hpp" -o -name "*.cpp" -o -name "*.m" -o -name "*.mm" -o -name "*.c" \) -print0 | while read -d $'\0' file; do + find $DIRS -type f \( -name "*.h" -o -name "*.hpp" -o -name "*.cpp" -o -name "*.m" -o -name "*.mm" -o -name "*.c" \) -print0 | while read -r -d '' file; do clang-format -style=file:./config/.clang-format -i "$file" done echo "C++ Format done!" diff --git a/scripts/swift-format.sh b/scripts/swift-format.sh index d88531e..64474c6 100755 --- a/scripts/swift-format.sh +++ b/scripts/swift-format.sh @@ -11,7 +11,7 @@ SWIFT_DIRS=( if which swift >/dev/null; then DIRS=$(printf "%s " "${SWIFT_DIRS[@]}") - find $DIRS -type f \( -name "*.swift" \) -print0 | while read -d $'\0' file; do + find $DIRS -type f \( -name "*.swift" \) -print0 | while read -r -d '' file; do swift format --configuration ./config/.swift-format --in-place "$file" done echo "Swift Format done!"