What It Does
The Beans.ai Maps Widget shows the resident or driver as a live marker on the property map, keeps the map pinned to them as they walk, measures distance to a unit or parking spot, and hands off to Apple or Google Maps for turn-by-turn.
All of that needs the device's location.
When you embed the widget in a mobile app's
WebView, the page asking for location triggers a
second permission prompt — on top of the one your app has already been granted. It is confusing for the user, and on some platforms it cannot be answered at all:
| Platform |
What happens without this SDK |
| Android WebView |
The page fires onGeolocationPermissionsShowPrompt. If your app does not implement it, location never arrives and the marker never appears. If it does, the user sees a second, web-looking prompt. |
| iOS WKWebView |
iOS 15 and later show a system prompt naming the web page's origin, and there is no delegate hook to pre-answer it. Earlier versions never resolve it at all. |
The Location SDK removes that prompt by changing
where the page gets location from: your app streams the position it already has permission for directly into the widget. One permission, asked by your app, in your own words.
No change is required to your map page. Keep rendering the widget exactly as documented in the
MapsWidget API v1, with
navOptions.userLocation = 'LIVE'.
Requirements
| Item |
Requirement |
| Maps Widget version |
1.0.4 or later (any provider: ESRI, Beans Canvas, Google, Mapbox). Also works with Beans.ai-hosted pages. |
| Page configuration |
navOptions.userLocation = 'LIVE'. Nothing else to change. |
| Android |
minSdk 21. Location permission declared and granted to your app. No Gradle dependencies required. |
| iOS |
iOS 12+, WKWebView, and NSLocationWhenInUseUsageDescription in your Info.plist. No CocoaPods/SPM dependencies required. |
Downloads
Every file is a single, self-contained drop-in. There is no package to install and no build step.
We recommend
bundling the JavaScript file with your app (Android
assets/, iOS app bundle) rather than loading it from beans.ai: it then works offline, is unaffected by your page's content-security policy, and is in place before the page's own scripts run. Both SDKs support either.
How It Works
Your app owns the location permission and the location stream. The page-side bridge takes over the browser's geolocation entry point, so neither the widget nor any other script on the page can reach the prompt.
your app
BeansLocationBridge ──── start / stop ────► location provider
│ │
│ ◄──────────── device position ───────────┘
▼
beans-native-location.js (injected into the page)
1. registers as the widget's location provider
2. replaces navigator.geolocation
3. relays positions into iframes
│
▼
Beans Maps Widget
user marker · map pinning · distance · navigation hand-off
Two independent safeguards mean a partial integration still cannot produce a prompt: the widget is pointed at your app's stream,
and the browser geolocation API is replaced.
Load order does not matter. If the bridge is installed after the page has already asked the browser for location, the widget's in-flight request is handed over to your app without dropping it.
Widget inside an iframe. Mobile platforms can only evaluate JavaScript in a page's main frame. The bridge handles this for you: injected into every frame, a framed copy of the widget subscribes to its parent and the parent relays each position down. No configuration needed.
Android
1. Manifest
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
If you minify, keep the JavaScript interface methods:
-keepclassmembers class * { @android.webkit.JavascriptInterface <methods>; }
2. Attach the bridge
Copy
BeansLocationBridge.kt into your project and attach it
before loading the page.
class MapActivity : Activity() {
private lateinit var bridge: BeansLocationBridge
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val webView = WebView(this)
setContentView(webView)
bridge = BeansLocationBridge.attach(
webView, this,
BeansLocationBridge.Options(
shimAsset = "beans-native-location-1.0.0.js",
minIntervalMs = 1000,
minDistanceM = 1f,
debug = BuildConfig.DEBUG
)
)
bridge.attachDefaultClients()
webView.loadUrl("https://your-host/your-map-page.html")
}
override fun onResume() { super.onResume(); bridge.resume() }
override fun onPause() { super.onPause(); bridge.pause() }
override fun onDestroy() { bridge.detach(); super.onDestroy() }
override fun onRequestPermissionsResult(
requestCode: Int, permissions: Array<out String>, grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
bridge.onRequestPermissionsResult(requestCode, grantResults)
}
}
attachDefaultClients() installs a
WebViewClient that injects the page-side bridge on every navigation, and a
WebChromeClient that answers any stray geolocation prompt. If you already set your own clients, wire these two hooks instead:
webView.webViewClient = object : MyWebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon); bridge.injectShim()
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url); bridge.injectShim()
}
}
webView.webChromeClient = object : MyChromeClient() {
override fun onGeolocationPermissionsShowPrompt(
origin: String?, callback: GeolocationPermissions.Callback?
) = bridge.grantGeolocationPermission(origin, callback)
}
Options
| Option |
Default |
Meaning |
| shimAsset |
null |
Filename of the page-side bridge in src/main/assets/. Recommended. |
| shimUrl |
beans.ai hosted |
Used when shimAsset is not set. |
| locationSource |
auto |
Where fixes come from. Auto-selects fused location if available, else the platform provider. Implement BeansLocationSource to reuse your app's existing location stack. |
| autoRequestPermission |
true |
Ask for location permission the first time the page needs it. Set false if you request it during your own onboarding, then call bridge.onPermissionsResolved(granted). |
| minIntervalMs |
1000 |
Minimum time between fixes. |
| minDistanceM |
1 |
Minimum movement between fixes, in metres. |
| highAccuracy |
true |
Prefer GPS / high-accuracy priority. |
| debug |
false |
Logcat tracing under the tag BeansLocationBridge. |
Google Play Services (optional)
If your app already depends on Play Services location, also copy
BeansFusedLocationSource.kt into the same package. It is detected and used automatically — no wiring. Fused positions blend GPS, wifi, cell and sensors, so a resident walking a property gets smoother movement and usable bearings at lower battery cost.
implementation("com.google.android.gms:play-services-location:21.0.1")
Without it, the platform
LocationManager is used: GPS and network together for a fast first fix indoors, with coarse fixes suppressed while a recent, more accurate one is in hand.
Other calls you may need
| Call |
Use |
bridge.push(location) bridge.push(lat, lng) |
Send a position yourself — a simulated location in tests, or a position your app already tracks. |
| bridge.pushError(code, message) |
Report a failure to the page. 1 = permission denied, 2 = position unavailable, 3 = timeout. |
| bridge.pause() / bridge.resume() |
Stop and restart location while keeping the page's subscription. Call these from your lifecycle so the GPS is not held open in the background. |
| bridge.sourceName |
Which provider is active, for logs and bug reports. |
iOS
1. Info.plist
Required — without it iOS refuses to start location updates.
<key>NSLocationWhenInUseUsageDescription</key>
<string>Shows your position on the property map.</string>
2. Create the bridge
Copy
BeansLocationBridge.swift into your target and construct it
before loading the page — it installs the page-side bridge as a document-start script.
final class MapViewController: UIViewController {
private var webView: WKWebView!
private var bridge: BeansLocationBridge!
override func viewDidLoad() {
super.viewDidLoad()
webView = WKWebView(frame: view.bounds, configuration: WKWebViewConfiguration())
view.addSubview(webView)
bridge = BeansLocationBridge(
webView: webView,
options: .init(
shimBundleResource: "beans-native-location-1.0.0.js",
distanceFilter: 1,
useHeading: true,
debug: true
)
)
webView.load(URLRequest(url: URL(string: "https://your-host/your-map-page.html")!))
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated); bridge.resume()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated); bridge.pause()
}
deinit { bridge?.detach() }
}
Options
| Option |
Default |
Meaning |
| shimBundleResource |
nil |
Filename of the page-side bridge in the app bundle. Recommended. |
| shimURL |
beans.ai hosted |
Used when shimBundleResource is not set. |
| autoRequestAuthorization |
true |
Call requestWhenInUseAuthorization() the first time the page needs location. |
| desiredAccuracy |
kCLLocationAccuracyBest |
Passed to CLLocationManager. |
| distanceFilter |
1 |
Minimum movement between fixes, in metres. |
| useHeading |
false |
Also start the compass. Recommended: a device's course is only valid while moving, so without it the marker's direction indicator does not update while the user stands still. |
| debug |
false |
Console tracing under [BeansLocationBridge]. |
Note: a
WKWebViewConfiguration carries the injected scripts, so create one bridge per web view and call
detach() when you are done with it.
push(latitude:longitude:),
pushError(code:message:),
pause() and
resume() work as on Android.
Other Hosts
React Native, Flutter, Capacitor, Cordova or your own container all work: anything that can inject the page-side file and evaluate a line of JavaScript can drive the bridge.
The simplest integration is
push-only — you never listen for requests from the page, you just send positions on your own schedule. The first position you send activates the bridge, and the prompt stays gone.
- Inject beans-native-location-1.0.0.js into the page (as early as your container allows).
- Whenever your app has a position, evaluate:
window.BeansNativeLocation.push({
lat: 37.4220,
lng: -122.0841,
accuracy: 6.5
});
To also let the page turn location on and off — worth doing for battery, since the widget only needs it while a map is on screen — expose one of the transports in the
Wire Protocol section below. React Native's
window.ReactNativeWebView is detected automatically; your app receives
{type:'beans-location', action:'start'|'stop'|'once'} messages.
Location Payload
The object you hand to
push(). Only latitude and longitude are required; everything else improves the experience where available.
| Field |
Type |
Required? |
Notes |
| lat |
Double |
Yes |
Degrees. latitude is also accepted. |
| lng |
Double |
Yes |
Degrees. longitude is also accepted. |
| accuracy |
Double |
No |
Horizontal accuracy in metres. |
| heading |
Double |
No |
Degrees clockwise from true north. Drives the direction indicator on the marker. |
| speed |
Double |
No |
Metres per second. |
altitude altitudeAccuracy |
Double |
No |
Metres. Used by multi-floor properties. |
| timestamp |
Long |
No |
Milliseconds since the Unix epoch. |
A JSON string of the same object is also accepted, as is a standard browser
GeolocationPosition.
JavaScript API
Available on the page as
window.BeansNativeLocation once the file is injected.
| Method |
Called by |
Purpose |
| push(position) |
Your app |
Deliver a position. See Location Payload. |
| pushError(code, message) |
Your app |
Report a failure. 1 = permission denied, 2 = position unavailable, 3 = timeout. |
| pushStatus(status) |
Your app |
'ready', 'denied' or 'stopped'. Optional lifecycle signal. |
| configure(options) |
Page |
Tune the stream: highAccuracy, minIntervalMs, minDistanceM, eagerStart, debug. |
isNative() getTransport() |
Page |
Whether a native host is present, and which channel it uses. Useful for building one page that runs both on the web and in your app. |
getLastPosition() getLastError() isWatching() |
Page |
Current state, for diagnostics. |
| uninstall() |
Page |
Hand geolocation back to the browser. |
Wire Protocol
Only needed if you are writing your own host rather than using the Android or iOS file. The page-side bridge picks the first transport it finds, in this order:
| Transport |
Detected by |
Call the page makes |
| iOS |
window.webkit.messageHandlers.beansLocation |
.postMessage({action, highAccuracy, minIntervalMs, minDistanceM}) |
| Android |
window.BeansLocationAndroid |
.start(jsonConfig) • .stop() • .once(jsonConfig) |
| React Native |
window.ReactNativeWebView |
.postMessage(JSON.stringify({type:'beans-location', action, ...})) |
| Parent frame |
the page is in an iframe |
parent.postMessage({__beansLocation:true, action, ...}, '*') |
action is
'start',
'stop' or
'once'. Positions travel back the other way through
push(). If you implement no transport at all, push-only mode applies and the page simply consumes whatever you send.
Testing
Load the diagnostic page in your own build — before pointing it at a real map — and confirm the wiring end to end:
https://www.beans.ai/mapswidget/sdk/test-native-location.html
| Row |
What you want to see |
| Shim loaded |
A version number. bootstrap stub only means the JavaScript file itself never loaded — usually a content-security policy blocking it, so bundle it with the app instead. |
| Transport |
android, ios or push-only. none means your injection is not landing. |
| navigator.geolocation |
bridged (no prompt). |
| Fixes received |
Climbing as you move, with a plausible position and accuracy. |
Then load your real map page and confirm three things: no permission prompt from the page, the user marker appears, and it follows you.
Troubleshooting
| Symptom |
Cause |
| Prompt still appears |
The page-side file is not injected (check Transport on the diagnostic page), or the page reached geolocation before injection. Inject at document start, or from both onPageStarted and onPageFinished. |
| Transport reads none inside your app |
Android: the JavaScript interface was not registered before the page loaded, or was renamed. iOS: the message handler is not named beansLocation. |
| No positions and no error |
The page never asked. The widget only asks when navOptions.userLocation = 'LIVE'. Also check that pause() was not called without a matching resume(). |
| Positions arrive but no marker |
The position is far from the property. Confirm the fix on the diagnostic page first; if it is correct there, the bridge has done its job. |
| Marker direction is wrong when standing still |
A device reports no course while stationary. On iOS set useHeading: true; on Android this resolves as soon as the user moves. |
| Works on the main page, not in an embedded map |
The iframe case — make sure the file is injected into subframes as well as the main frame. |
| Battery drain |
Call pause() and resume() from your lifecycle, and raise minIntervalMs / minDistanceM if metre-level tracking is not needed. |
Privacy
The SDK is a transport: it moves the position your app already holds into the page that is already displaying your map. It adds no storage, no logging to Beans.ai, and no background collection — it streams only while the map is asking, and stops when you call
pause() or the map goes away.
Because your app now asks for location itself, the permission prompt your users see is yours: your wording, your timing, and a single request instead of two. What the widget then does with the position — drawing the user marker, pinning the map, measuring distance to a unit, handing off to Apple or Google Maps — is unchanged from the standard integration described in the
MapsWidget API v1.
Note that a WebView JavaScript interface is reachable by every frame in the page. As with any WebView integration, load only pages you control or trust.