package ai.beans.mapswidget.location import android.Manifest import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.pm.PackageManager import android.location.Location import android.location.LocationListener import android.location.LocationManager import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper import android.util.Log import android.webkit.GeolocationPermissions import android.webkit.JavascriptInterface import android.webkit.WebChromeClient import android.webkit.WebView import android.webkit.WebViewClient import org.json.JSONObject /** * Beans Maps Widget — Android WebView location bridge. * * Feeds the device's location into a WebView running the Beans maps widget so the * WebView never raises its own location permission prompt. The host app holds the * OS permission; the page consumes this stream instead of calling * `navigator.geolocation`. * * Drop this file into your project — no third-party dependencies required. It uses * the platform [LocationManager] by default. If `play-services-location` is on your * classpath, also drop in `BeansFusedLocationSource.kt` and it is picked up * automatically for better fixes while moving (see [Options.locationSource]). * * Minimal integration: * ``` * 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) * bridge.attachDefaultClients() // injects the shim + auto-grants * 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, grantResults: IntArray * ) { * super.onRequestPermissionsResult(requestCode, permissions, grantResults) * bridge.onRequestPermissionsResult(requestCode, grantResults) * } * } * ``` * * Manifest: * ``` * * * ``` * * If you enable minification, keep the JS interface methods: * ``` * -keepclassmembers class * { @android.webkit.JavascriptInterface ; } * ``` * * The page needs `navOptions.userLocation = 'LIVE'` (which is already how the * widget is configured for live location) — no page-side change required. */ class BeansLocationBridge private constructor( private val webView: WebView, private val activity: Activity, private val options: Options ) { /** * @param shimUrl remote URL of `beans-native-location-.js`. Used when * [shimAsset] is null. Subject to the page's CSP. * @param shimAsset filename of the shim inside `src/main/assets/` (for example * `"beans-native-location-1.0.0.js"`). Recommended: works offline, is not * affected by CSP, and is injected before the page's own scripts run. * @param locationSource where fixes come from. Leave null to auto-select: * `BeansFusedLocationSource` when both it and `play-services-location` are on * the classpath, otherwise [PlatformLocationSource]. * @param autoRequestPermission request ACCESS_FINE_LOCATION when the page asks * for location and the app does not hold it yet. * @param minIntervalMs minimum time between fixes. * @param minDistanceM minimum movement between fixes, in metres. * @param highAccuracy prefer GPS / PRIORITY_HIGH_ACCURACY. */ data class Options( val shimUrl: String = DEFAULT_SHIM_URL, val shimAsset: String? = null, val locationSource: BeansLocationSource? = null, val autoRequestPermission: Boolean = true, val minIntervalMs: Long = 1000L, val minDistanceM: Float = 1f, val highAccuracy: Boolean = true, val debug: Boolean = false ) companion object { const val JS_INTERFACE_NAME = "BeansLocationAndroid" const val PERMISSION_REQUEST_CODE = 0xBEA5 const val DEFAULT_SHIM_URL = "https://www.beans.ai/mapswidget/js/beans-native-location-1.0.0.js" private const val TAG = "BeansLocationBridge" /** * Wires the bridge into [webView]: enables JavaScript, registers the * `BeansLocationAndroid` interface, and picks a location source. Call before * `loadUrl`. Then either call [attachDefaultClients] or, if you use your own * clients, call [injectShim] from `onPageStarted`/`onPageFinished` and * [grantGeolocationPermission] from `onGeolocationPermissionsShowPrompt`. */ @JvmStatic @JvmOverloads fun attach( webView: WebView, activity: Activity, options: Options = Options() ): BeansLocationBridge = BeansLocationBridge(webView, activity, options).also { it.install() } /** * Auto-granting chrome client. Only a safety net: if some other script on the * page reaches `navigator.geolocation` before the shim is in place, this * answers the prompt instead of showing it to the user. */ open class GeolocationGrantingChromeClient : WebChromeClient() { override fun onGeolocationPermissionsShowPrompt( origin: String?, callback: GeolocationPermissions.Callback? ) { callback?.invoke(origin, true, false) } } /** * Prefers `BeansFusedLocationSource` when the app ships both that file and * `play-services-location`; falls back to the platform provider. Reflection * keeps this file compiling with no Play Services dependency. */ private fun autoSelectSource(context: Context, debug: Boolean): BeansLocationSource { try { Class.forName("com.google.android.gms.location.LocationServices") val clazz = Class.forName("ai.beans.mapswidget.location.BeansFusedLocationSource") val source = clazz.getConstructor(Context::class.java) .newInstance(context.applicationContext) as BeansLocationSource if (debug) Log.d(TAG, "using ${source.name}") return source } catch (e: ClassNotFoundException) { // Play Services and/or the optional source file are not present. } catch (e: Throwable) { Log.w(TAG, "could not create BeansFusedLocationSource: ${e.message}") } val fallback = PlatformLocationSource(context.applicationContext) if (debug) Log.d(TAG, "using ${fallback.name}") return fallback } } private val mainHandler = Handler(Looper.getMainLooper()) private val source: BeansLocationSource = options.locationSource ?: autoSelectSource(activity, options.debug) private var requestedByPage = false // the page asked for a stream private var listening = false // the source is actually running private var detached = false private var pausedWhileListening = false private var config = BeansLocationConfig( highAccuracy = options.highAccuracy, minIntervalMs = options.minIntervalMs, minDistanceM = options.minDistanceM ) /** Name of the active location source, for logging (`"FusedLocationProvider"`, …). */ val sourceName: String get() = source.name // ------------------------------------------------------------------ install private fun install() { webView.settings.javaScriptEnabled = true webView.settings.domStorageEnabled = true @Suppress("DEPRECATION") webView.settings.setGeolocationEnabled(true) webView.addJavascriptInterface(JsApi(), JS_INTERFACE_NAME) debug("installed JS interface $JS_INTERFACE_NAME, source=${source.name}") } /** * Convenience for apps that do not already set their own clients: installs a * [WebViewClient] that injects the shim on every page load and a * [WebChromeClient] that auto-grants geolocation prompts. */ fun attachDefaultClients() { webView.webViewClient = object : WebViewClient() { override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) { super.onPageStarted(view, url, favicon) injectShim() } override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url) injectShim() } } webView.webChromeClient = GeolocationGrantingChromeClient() } /** * Injects the WebView-side shim. Call from BOTH `onPageStarted` and * `onPageFinished`: the widget can request location before the page's `load` * event, and injecting only at `onPageFinished` leaves a race in which the real * `navigator.geolocation` is reached first and the prompt appears. Safe to call * repeatedly — the shim is idempotent per frame, and the widget's * `GeolocationBroker` accepts a provider registered after it has already started * watching (it hands the in-flight watch over). */ fun injectShim() { if (detached) return val js = options.shimAsset?.let { asset -> readAsset(asset)?.let { source -> BOOTSTRAP_QUEUE_JS + "\n" + source } } ?: (BOOTSTRAP_QUEUE_JS + "\n" + loaderJs(options.shimUrl)) evaluate(js) } /** Answer for `WebChromeClient.onGeolocationPermissionsShowPrompt`. */ fun grantGeolocationPermission(origin: String?, callback: GeolocationPermissions.Callback?) { callback?.invoke(origin, true, false) } // ------------------------------------------------------------------ JS API private inner class JsApi { /** Page asks for a live stream. [configJson] carries accuracy/interval hints. */ @JavascriptInterface fun start(configJson: String?) { applyConfig(configJson) mainHandler.post { requestedByPage = true; startListening() } } @JavascriptInterface fun stop() { mainHandler.post { requestedByPage = false; stopListening() } } /** Page asks for a single fix. */ @JavascriptInterface fun once(configJson: String?) { applyConfig(configJson) mainHandler.post { pushLastKnownLocation() if (!listening) startListening() } } @JavascriptInterface fun isAvailable(): Boolean = hasPermission() && source.isUsable() } private fun applyConfig(configJson: String?) { if (configJson.isNullOrEmpty()) return try { val json = JSONObject(configJson) config = BeansLocationConfig( highAccuracy = if (json.has("highAccuracy")) { json.optBoolean("highAccuracy", config.highAccuracy) } else config.highAccuracy, minIntervalMs = json.optLong("minIntervalMs", config.minIntervalMs), minDistanceM = json.optDouble("minDistanceM", config.minDistanceM.toDouble()).toFloat() ) } catch (e: Exception) { debug("bad config json: $configJson") } } // ------------------------------------------------------------------ location private val sourceListener = object : BeansLocationSource.Listener { override fun onLocation(location: Location) = push(location) override fun onUnavailable(code: Int, message: String) = pushError(code, message) } private fun startListening() { if (detached || listening) return if (!hasPermission()) { if (options.autoRequestPermission) requestPermission() else pushError(1, "App does not hold ACCESS_FINE_LOCATION") return } if (!source.isUsable()) { pushError(2, "Location is turned off on the device") return } try { source.start(config, sourceListener) } catch (e: SecurityException) { pushError(1, "SecurityException requesting location updates") return } catch (e: Throwable) { pushError(2, "Could not start ${source.name}: ${e.message}") return } listening = true debug("listening via ${source.name} ($config)") pushStatus("ready") pushLastKnownLocation() } private fun stopListening() { if (!listening) return try { source.stop() } catch (e: Throwable) { debug("stop failed: ${e.message}") } listening = false debug("stopped listening") } private fun pushLastKnownLocation() { if (!hasPermission()) return try { source.lastKnown()?.let { push(it) } } catch (e: SecurityException) { // ignored: the live stream is what matters } } // ------------------------------------------------------------------ permission private fun hasPermission(): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true val fine = activity.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) val coarse = activity.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) return fine == PackageManager.PERMISSION_GRANTED || coarse == PackageManager.PERMISSION_GRANTED } private fun requestPermission() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return debug("requesting location permission") activity.requestPermissions( arrayOf( Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION ), PERMISSION_REQUEST_CODE ) } /** * Forward from `Activity.onRequestPermissionsResult`. Using AndroidX * `ActivityResultContracts.RequestMultiplePermissions` instead? Call * [onPermissionsResolved] from your callback rather than this method. */ fun onRequestPermissionsResult(requestCode: Int, grantResults: IntArray) { if (requestCode != PERMISSION_REQUEST_CODE) return onPermissionsResolved( grantResults.isNotEmpty() && grantResults.any { it == PackageManager.PERMISSION_GRANTED } ) } /** Tell the bridge how a permission request you drove yourself turned out. */ fun onPermissionsResolved(granted: Boolean) { if (granted) { if (requestedByPage) startListening() } else { pushStatus("denied") } } // ------------------------------------------------------------------ lifecycle /** Pauses location updates while keeping the page's subscription intact. */ fun pause() { pausedWhileListening = listening stopListening() } /** Resumes updates if the page had asked for them before [pause]. */ fun resume() { if (pausedWhileListening || requestedByPage) startListening() pausedWhileListening = false } /** Releases everything. Call from `onDestroy`. */ fun detach() { stopListening() detached = true try { webView.removeJavascriptInterface(JS_INTERFACE_NAME) } catch (e: Exception) { // WebView may already be destroyed } } // ------------------------------------------------------------------ push to page /** Sends a [Location] to the page. */ fun push(location: Location) { val json = JSONObject().apply { put("lat", location.latitude) put("lng", location.longitude) if (location.hasAccuracy()) put("accuracy", location.accuracy) if (location.hasAltitude()) put("altitude", location.altitude) if (location.hasBearing()) put("heading", location.bearing) if (location.hasSpeed()) put("speed", location.speed) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && location.hasVerticalAccuracy()) { put("altitudeAccuracy", location.verticalAccuracyMeters) } put("timestamp", location.time) } debug("push $json") evaluate("window.BeansNativeLocation&&window.BeansNativeLocation.push($json);") } /** Sends an explicit lat/lng, e.g. for a simulated or app-chosen location. */ fun push(latitude: Double, longitude: Double, accuracyMeters: Float? = null) { val json = JSONObject().apply { put("lat", latitude) put("lng", longitude) accuracyMeters?.let { put("accuracy", it) } put("timestamp", System.currentTimeMillis()) } evaluate("window.BeansNativeLocation&&window.BeansNativeLocation.push($json);") } /** code: 1 = permission denied, 2 = position unavailable, 3 = timeout. */ fun pushError(code: Int, message: String) { debug("pushError $code $message") evaluate( "window.BeansNativeLocation&&window.BeansNativeLocation.pushError(" + "$code,${JSONObject.quote(message)});" ) } /** status: "ready" | "denied" | "stopped". */ fun pushStatus(status: String) { evaluate( "window.BeansNativeLocation&&window.BeansNativeLocation.pushStatus(" + "${JSONObject.quote(status)});" ) } private fun evaluate(js: String) { if (detached) return webView.post { try { webView.evaluateJavascript(js, null) } catch (e: Exception) { debug("evaluateJavascript failed: ${e.message}") } } } // ------------------------------------------------------------------ helpers private fun readAsset(name: String): String? = try { activity.assets.open(name).bufferedReader().use { it.readText() } } catch (e: Exception) { Log.w(TAG, "shimAsset '$name' not found in assets; falling back to shimUrl") null } private fun loaderJs(url: String): String = """ (function(){ if (document.getElementById('beans-native-location-shim')) return; var s = document.createElement('script'); s.id = 'beans-native-location-shim'; s.src = ${JSONObject.quote(url)}; s.async = false; (document.head || document.documentElement).appendChild(s); })(); """.trimIndent() private fun debug(message: String) { if (options.debug) Log.d(TAG, message) } } /** Accuracy and rate hints, negotiated between the page and [Options]. */ data class BeansLocationConfig( val highAccuracy: Boolean, val minIntervalMs: Long, val minDistanceM: Float ) /** * Where fixes come from. Swap implementations via `Options(locationSource = …)` — * for example to wrap your app's existing location stack instead of opening a * second one. */ interface BeansLocationSource { interface Listener { fun onLocation(location: Location) /** code: 1 = permission denied, 2 = position unavailable, 3 = timeout. */ fun onUnavailable(code: Int, message: String) } /** Human-readable name, used in debug logs. */ val name: String /** False when location is switched off device-wide, or no provider exists. */ fun isUsable(): Boolean /** * Begins delivering fixes to [listener] on the main thread. The bridge has * already checked permission. Called only when not already started. */ fun start(config: BeansLocationConfig, listener: Listener) fun stop() /** * A cached fix to show the marker immediately, or null. Implementations whose * cache is asynchronous may return null and deliver through the listener. */ fun lastKnown(): Location? } /** * Default source: the platform [LocationManager]. No dependencies. * * Listens to GPS and network together so an indoor first fix arrives quickly, and * suppresses coarse network fixes that would visibly drag the marker backwards * while a recent, more accurate GPS fix is in hand. */ class PlatformLocationSource(context: Context) : BeansLocationSource { override val name = "LocationManager" private val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager private var listener: BeansLocationSource.Listener? = null private var lastFineAtMs = 0L private var lastFineAccuracy = Float.MAX_VALUE private val androidListener = object : LocationListener { override fun onLocationChanged(location: Location) { if (!shouldDeliver(location)) return listener?.onLocation(location) } // Required on API < 30, where these have no default implementation. override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {} override fun onProviderEnabled(provider: String) {} override fun onProviderDisabled(provider: String) { if (!isUsable()) { listener?.onUnavailable(2, "Location providers are disabled on the device") } } } override fun isUsable(): Boolean = try { locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) } catch (e: Exception) { false } // Permission is checked by the bridge before start()/lastKnown() are called. @SuppressLint("MissingPermission") override fun start(config: BeansLocationConfig, listener: BeansLocationSource.Listener) { this.listener = listener lastFineAtMs = 0L lastFineAccuracy = Float.MAX_VALUE val available = locationManager.allProviders val providers = mutableListOf() if (config.highAccuracy && available.contains(LocationManager.GPS_PROVIDER)) { providers.add(LocationManager.GPS_PROVIDER) } if (available.contains(LocationManager.NETWORK_PROVIDER)) { providers.add(LocationManager.NETWORK_PROVIDER) } if (providers.isEmpty() && available.contains(LocationManager.GPS_PROVIDER)) { providers.add(LocationManager.GPS_PROVIDER) } if (providers.isEmpty()) { listener.onUnavailable(2, "No usable location provider") return } providers.forEach { provider -> locationManager.requestLocationUpdates( provider, config.minIntervalMs, config.minDistanceM, androidListener, Looper.getMainLooper() ) } } override fun stop() { try { locationManager.removeUpdates(androidListener) } catch (e: SecurityException) { // nothing to clean up } listener = null } @SuppressLint("MissingPermission") override fun lastKnown(): Location? { // getLastKnownLocation throws if the provider does not exist on the device. val candidates = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER) .mapNotNull { provider -> try { locationManager.getLastKnownLocation(provider) } catch (e: Exception) { null } } return candidates.maxByOrNull { it.time } } /** * GPS fixes always win. A network fix is dropped while a GPS fix from the last * 20s is at least as accurate — otherwise the marker jumps between a 5 m GPS * position and a 1500 m cell-tower position. */ private fun shouldDeliver(location: Location): Boolean { val accuracy = if (location.hasAccuracy()) location.accuracy else Float.MAX_VALUE if (location.provider == LocationManager.GPS_PROVIDER) { lastFineAtMs = System.currentTimeMillis() lastFineAccuracy = accuracy return true } val sinceFine = System.currentTimeMillis() - lastFineAtMs return !(sinceFine < COARSE_SUPPRESSION_MS && accuracy >= lastFineAccuracy) } private companion object { const val COARSE_SUPPRESSION_MS = 20_000L } } /** * Queueing stub installed before the real shim loads, so fixes pushed during the * gap are not dropped. The shim drains `__queue` when it initialises. */ private val BOOTSTRAP_QUEUE_JS = """ (function(){ if (window.BeansNativeLocation) return; var q = []; window.BeansNativeLocation = { version: 'boot', __queue: q, push: function(p){ q.push({method:'push', args:[p]}); return true; }, pushError: function(c,m){ q.push({method:'pushError', args:[c,m]}); return true; }, pushStatus: function(s){ q.push({method:'pushStatus', args:[s]}); return true; } }; })(); """.trimIndent()