2025-10-13 00:21:31 +08:00

763 lines
29 KiB
Kotlin

package com.dumon.plugin.geolocation
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.Color
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.View
import android.view.WindowInsetsController
import android.view.WindowManager
import androidx.core.app.ActivityCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.getcapacitor.*
import com.getcapacitor.annotation.CapacitorPlugin
import com.getcapacitor.annotation.Permission
import com.dumon.plugin.geolocation.gps.GpsStatusManager
import com.dumon.plugin.geolocation.gps.GpsTrackingMode
import com.dumon.plugin.geolocation.gps.SatelliteStatus
import com.dumon.plugin.geolocation.imu.ImuData
import com.dumon.plugin.geolocation.imu.ImuSensorManager
import com.dumon.plugin.geolocation.wifi.WifiPositioningManager
import com.dumon.plugin.geolocation.wifi.WifiScanResult
//import com.dumon.plugin.geolocation.fusion.SensorFusionManager
import com.dumon.plugin.geolocation.utils.PermissionUtils
import com.dumon.plugin.geolocation.utils.LogUtils
import com.getcapacitor.annotation.PermissionCallback
import android.content.Intent
import androidx.core.content.ContextCompat
import com.dumon.plugin.geolocation.bg.BackgroundLocationService
import com.dumon.plugin.geolocation.utils.BgPrefs
import org.json.JSONArray
import org.json.JSONObject
import kotlin.math.*
import android.location.Location
import com.dumon.plugin.geolocation.utils.AuthStore
@CapacitorPlugin(
name = "DumonGeolocation",
permissions = [
Permission(strings = [
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_WIFI_STATE,
Manifest.permission.CHANGE_WIFI_STATE,
Manifest.permission.NEARBY_WIFI_DEVICES
])
]
)
class DumonGeolocation : Plugin() {
private var gpsManager: GpsStatusManager? = null
private var imuManager: ImuSensorManager? = null
private var wifiManager: WifiPositioningManager? = null
private var latestLatitude = 0.0
private var latestLongitude = 0.0
private var latestAccuracy = 999.0
private var latestSource = "GNSS"
private var latestTimestamp: Long = 0L
private var latestImu: ImuData? = null
private var satelliteStatus: SatelliteStatus? = null
private var wifiScanResult: WifiScanResult? = null
private var isMockedLocation = false
private var lastEmitTimestamp: Long = 0L
private var prevLatitude = 0.0
private var prevLongitude = 0.0
private var prevSpeed = 0f
private var prevDirection = 0f
// private val significantChangeThreshold = 0.00007 // ~7 meters
private var significantChangeThreshold = 7.0 // ~7 meters
private var speedChangeThreshold = 0.5f // m/s
private var directionChangeThreshold = 0.17f // ~10 deg
// private val emitIntervalMs: Long = 500L
private var emitIntervalMs: Long = 1000L // hard debounce
// private val emitIntervalMs: Long = 500L
private var motionState: String = "idle" // 'idle', 'driving', 'mocked'
private var bufferedDrivingLocation: Location? = null
private var drivingEmitHandler: Handler? = null
private var drivingEmitRunnable: Runnable? = null
private var drivingEmitIntervalMs = 1600L
private var wifiScanIntervalMs = 3000L
private var enableWifiRtt = true
private var enableForwardPrediction = false
private var maxPredictionSeconds = 1.0
private var emitGnssStatus = false
private var suppressMockedUpdates = false
private var keepScreenOn = false
private var currentTrackingMode = GpsTrackingMode.NORMAL
override fun load() {
gpsManager = GpsStatusManager(
context,
onSatelliteStatusUpdate = { status ->
satelliteStatus = status
if (emitGnssStatus) {
Handler(Looper.getMainLooper()).post {
notifyListeners("onGnssStatus", buildGnssStatusData(status))
}
}
},
onLocationUpdate = onLocationUpdate@{ location, isMocked ->
if (location.latitude == 0.0 && location.longitude == 0.0) {
LogUtils.w("GPS_LOCATION", "Ignored location update: (0.0, 0.0)")
return@onLocationUpdate
}
latestLatitude = location.latitude
latestLongitude = location.longitude
latestAccuracy = location.accuracy.toDouble()
latestSource = if (isMocked) "MOCK" else "GNSS"
isMockedLocation = isMocked
latestTimestamp = location.time
if (currentTrackingMode == GpsTrackingMode.DRIVING) {
bufferedDrivingLocation = location
} else {
emitPositionUpdate() // langsung emit di mode normal
}
}
)
imuManager = ImuSensorManager(
context,
onImuUpdate = {
latestImu = it
adjustIntervalAndSensorRate(it.speed)
emitPositionUpdate()
}
)
wifiManager = WifiPositioningManager(
context,
onWifiPositioningUpdate = {
wifiScanResult = it
emitPositionUpdate()
}
)
}
private fun startDrivingEmitLoop() {
if (drivingEmitHandler != null) return // already running
drivingEmitHandler = Handler(Looper.getMainLooper())
drivingEmitRunnable = object : Runnable {
override fun run() {
bufferedDrivingLocation?.let { location ->
latestLatitude = location.latitude
latestLongitude = location.longitude
latestAccuracy = location.accuracy.toDouble()
latestTimestamp = location.time
latestSource = if (isMockedLocation) "MOCK" else "GNSS"
emitPositionUpdate(forceEmit = true) // force emit in driving
}
drivingEmitHandler?.postDelayed(this, drivingEmitIntervalMs)
}
}
drivingEmitHandler?.postDelayed(drivingEmitRunnable!!, drivingEmitIntervalMs)
}
private fun stopDrivingEmitLoop() {
drivingEmitRunnable?.let { runnable ->
drivingEmitHandler?.removeCallbacks(runnable)
}
drivingEmitHandler = null
drivingEmitRunnable = null
bufferedDrivingLocation = null
}
@PluginMethod
fun startPositioning(call: PluginCall) {
if (!PermissionUtils.hasLocationAndWifiPermissions(context)) {
call.reject("Required permissions not granted")
return
}
gpsManager?.start()
imuManager?.start()
wifiManager?.setEnableRtt(enableWifiRtt)
wifiManager?.startPeriodicScan(wifiScanIntervalMs)
applyKeepScreenOn(keepScreenOn)
call.resolve()
}
@PluginMethod
fun stopPositioning(call: PluginCall) {
gpsManager?.stop()
imuManager?.stop()
wifiManager?.stopPeriodicScan()
stopDrivingEmitLoop()
applyKeepScreenOn(false)
call.resolve()
}
@PluginMethod
fun getLatestPosition(call: PluginCall) {
// Try to fetch a fresh single fix first; fallback to cached snapshot if it fails
val hasFine = ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
val hasCoarse = ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
val manager = gpsManager
if (manager == null || (!hasFine && !hasCoarse)) {
// No manager or no permissions — fallback immediately
call.resolve(buildPositionData())
return
}
// Request a fresh single fix (useNetwork=true for faster first fix), short timeout
manager.requestSingleFix(timeoutMs = 3000L, useNetworkProvider = true) { location, isMocked ->
// If we received a location and it's not suppressed, return it
if (location != null) {
if (suppressMockedUpdates && isMocked) {
// Treat mocked as failure when suppression is enabled
call.resolve(buildPositionData())
} else {
latestLatitude = location.latitude
latestLongitude = location.longitude
latestAccuracy = location.accuracy.toDouble()
latestSource = if (isMocked) "MOCK" else "GNSS"
isMockedLocation = isMocked
latestTimestamp = location.time
call.resolve(buildPositionData())
}
} else {
// Timeout or failure — fallback to last known snapshot
call.resolve(buildPositionData())
}
}
}
@PluginMethod
fun checkAndRequestPermissions(call: PluginCall) {
// requestAllPermissions(call, "checkAndRequestPermissions")
val isLocationGranted =
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
val isWifiGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ActivityCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES) == PackageManager.PERMISSION_GRANTED
} else {
true
}
if (!isLocationGranted || !isWifiGranted) {
requestAllPermissions(call, "onPermissionResult")
return
}
val locationStatus = PermissionUtils.getPermissionStatus(
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
)
val wifiStatus = PermissionUtils.getPermissionStatus(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
ActivityCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES)
else
PackageManager.PERMISSION_GRANTED
)
val result = JSObject().apply {
put("location", locationStatus)
put("wifi", wifiStatus)
}
call.resolve(result)
}
@PluginMethod
fun configureEdgeToEdge(call: PluginCall) {
val bgColorHex = call.getString("bgColor") ?: "#FFFFFF"
val style = call.getString("style") ?: "DARK"
val overlay = call.getBoolean("overlay") ?: false
val activity = bridge?.activity
val window = activity?.window
val view = bridge?.webView
if (window == null || view == null) {
call.reject("No active window or webView")
return
}
val parsedColor = Color.parseColor(bgColorHex)
// Atur overlay (decor fit)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.setDecorFitsSystemWindows(!overlay)
} else {
if (!overlay) {
@Suppress("DEPRECATION")
window.clearFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS)
}
}
// Atur warna status bar & nav bar
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
window.statusBarColor = parsedColor
window.navigationBarColor = parsedColor
}
// Style icon (dark/light)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val controller = window.insetsController
val flags = WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS or
WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS
if (style.uppercase() == "DARK") {
controller?.setSystemBarsAppearance(flags, flags)
} else {
controller?.setSystemBarsAppearance(0, flags)
}
} else {
@Suppress("DEPRECATION")
view.systemUiVisibility = when (style.uppercase()) {
"DARK" -> View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
else -> 0
}
}
// Handling insets agar konten tidak tertutup nav/status bar
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
val sysInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(sysInsets.left, sysInsets.top, sysInsets.right, sysInsets.bottom)
WindowInsetsCompat.CONSUMED
}
call.resolve()
}
@PluginMethod
fun setGpsMode(call: PluginCall) {
val mode = call.getString("mode") ?: "normal"
if (mode == "driving") {
gpsManager?.startContinuousMode()
currentTrackingMode = GpsTrackingMode.DRIVING
startDrivingEmitLoop()
LogUtils.d("DUMON_GEOLOCATION", "Switched to driving mode (continuous GPS)")
} else {
gpsManager?.startPollingMode()
currentTrackingMode = GpsTrackingMode.NORMAL
stopDrivingEmitLoop()
LogUtils.d("DUMON_GEOLOCATION", "Switched to normal mode (polling GPS)")
}
call.resolve()
}
@PermissionCallback
private fun onPermissionResult(call: PluginCall) {
val locationStatus = PermissionUtils.getPermissionStatus(
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
)
val wifiStatus = PermissionUtils.getPermissionStatus(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
ActivityCompat.checkSelfPermission(context, Manifest.permission.NEARBY_WIFI_DEVICES)
else
PackageManager.PERMISSION_GRANTED
)
val result = JSObject().apply {
put("location", locationStatus)
put("wifi", wifiStatus)
}
call.resolve(result)
}
@PluginMethod
fun setOptions(call: PluginCall) {
call.getDouble("distanceThresholdMeters")?.let { significantChangeThreshold = it }
call.getDouble("speedChangeThreshold")?.let { speedChangeThreshold = it.toFloat() }
call.getDouble("directionChangeThreshold")?.let { directionChangeThreshold = it.toFloat() }
call.getInt("emitDebounceMs")?.let {
emitIntervalMs = it.toLong().coerceAtLeast(0L)
gpsManager?.setPollingInterval(emitIntervalMs)
}
call.getInt("drivingEmitIntervalMs")?.let {
drivingEmitIntervalMs = it.toLong().coerceAtLeast(200L)
if (currentTrackingMode == GpsTrackingMode.DRIVING) {
stopDrivingEmitLoop()
startDrivingEmitLoop()
}
}
call.getInt("wifiScanIntervalMs")?.let {
wifiScanIntervalMs = it.toLong().coerceAtLeast(1000L)
}
call.getBoolean("enableWifiRtt")?.let {
enableWifiRtt = it
wifiManager?.setEnableRtt(it)
}
call.getBoolean("enableLogging")?.let { LogUtils.enabled = it }
call.getBoolean("enableForwardPrediction")?.let { enableForwardPrediction = it }
call.getDouble("maxPredictionSeconds")?.let { maxPredictionSeconds = it.coerceIn(0.0, 5.0) }
call.getBoolean("emitGnssStatus")?.let { emitGnssStatus = it }
call.getBoolean("suppressMockedUpdates")?.let { suppressMockedUpdates = it }
call.getBoolean("keepScreenOn")?.let {
keepScreenOn = it
applyKeepScreenOn(keepScreenOn)
}
call.getInt("backgroundPollingIntervalMs")?.let {
val bgInterval = it.toLong().coerceAtLeast(1000L)
BgPrefs.setBackgroundIntervalMs(context, bgInterval)
LogUtils.d("DUMON_GEOLOCATION", "Set background polling interval = ${bgInterval} ms")
}
call.getDouble("backgroundPostMinDistanceMeters")?.let {
val v = it.coerceAtLeast(0.0)
BgPrefs.setBackgroundPostMinDistanceMeters(context, v)
LogUtils.d("DUMON_GEOLOCATION", "Set background min post distance = ${v} m")
}
call.getDouble("backgroundPostMinAccuracyMeters")?.let {
val v = it.coerceAtLeast(0.0)
BgPrefs.setBackgroundPostMinAccuracyMeters(context, v)
LogUtils.d("DUMON_GEOLOCATION", "Set background min accuracy = ${v} m")
}
call.getInt("backgroundMinPostIntervalMs")?.let {
val v = it.toLong().coerceAtLeast(0L)
BgPrefs.setBackgroundMinPostIntervalMs(context, v)
LogUtils.d("DUMON_GEOLOCATION", "Set background min post interval = ${v} ms")
}
call.resolve()
}
private fun applyKeepScreenOn(enabled: Boolean) {
val webView = bridge?.webView
val activity = bridge?.activity
Handler(Looper.getMainLooper()).post {
webView?.keepScreenOn = enabled
val window = activity?.window
if (window != null) {
if (enabled) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
}
}
@PluginMethod
fun getGnssStatus(call: PluginCall) {
val status = satelliteStatus
if (status == null) {
call.resolve(JSObject())
return
}
val obj = JSObject()
obj.put("satellitesInView", status.satellitesInView)
obj.put("usedInFix", status.usedInFix)
val counts = JSObject()
status.constellationCounts.forEach { (k, v) -> counts.put(k, v) }
obj.put("constellationCounts", counts)
call.resolve(obj)
}
@PluginMethod
fun getLocationServicesStatus(call: PluginCall) {
val lm = context.getSystemService(android.content.Context.LOCATION_SERVICE) as android.location.LocationManager
val gpsEnabled = try { lm.isProviderEnabled(android.location.LocationManager.GPS_PROVIDER) } catch (_: Exception) { false }
val netEnabled = try { lm.isProviderEnabled(android.location.LocationManager.NETWORK_PROVIDER) } catch (_: Exception) { false }
val obj = JSObject().apply {
put("gpsEnabled", gpsEnabled)
put("networkEnabled", netEnabled)
}
call.resolve(obj)
}
@PluginMethod
fun startBackgroundTracking(call: PluginCall) {
val title = call.getString("title") ?: "Location tracking active"
val text = call.getString("text") ?: "Updating location in background"
val channelId = call.getString("channelId") ?: "DUMON_GEO_BG"
val channelName = call.getString("channelName") ?: "Dumon Geolocation"
val postUrl = call.getString("postUrl")
val fine = ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
val coarse = ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
if (!fine && !coarse) {
call.reject("Location permission not granted")
return
}
val intent = Intent(context, BackgroundLocationService::class.java).apply {
putExtra(BackgroundLocationService.EXTRA_CHANNEL_ID, channelId)
putExtra(BackgroundLocationService.EXTRA_CHANNEL_NAME, channelName)
putExtra(BackgroundLocationService.EXTRA_TITLE, title)
putExtra(BackgroundLocationService.EXTRA_TEXT, text)
if (!postUrl.isNullOrBlank()) putExtra(BackgroundLocationService.EXTRA_POST_URL, postUrl)
}
try {
ContextCompat.startForegroundService(context, intent)
call.resolve()
} catch (e: Exception) {
call.reject("Failed to start background service: ${e.message}")
}
}
@PluginMethod
fun stopBackgroundTracking(call: PluginCall) {
try {
val stopped = context.stopService(Intent(context, BackgroundLocationService::class.java))
if (!stopped) {
// Even if service was not running, ensure flag cleared
BgPrefs.setActive(context, false)
}
call.resolve()
} catch (e: Exception) {
call.reject("Failed to stop background service: ${e.message}")
}
}
@PluginMethod
fun isBackgroundTrackingActive(call: PluginCall) {
val active = BgPrefs.isActive(context)
val obj = JSObject().apply { put("active", active) }
call.resolve(obj)
}
@PluginMethod
fun getBackgroundLatestPosition(call: PluginCall) {
val fix = BgPrefs.readLatestFix(context)
if (fix == null) {
call.resolve(JSObject())
return
}
val obj = JSObject().apply {
put("source", fix.source)
put("timestamp", fix.timestamp)
put("latitude", fix.latitude)
put("longitude", fix.longitude)
put("accuracy", fix.accuracy)
put("isMocked", fix.isMocked)
put("speed", fix.speed)
put("acceleration", fix.acceleration)
put("directionRad", fix.directionRad)
put("predicted", false)
}
call.resolve(obj)
}
// --- Auth token management for background posting ---
@PluginMethod
fun setAuthTokens(call: PluginCall) {
val access = call.getString("accessToken") ?: run {
call.reject("accessToken is required")
return
}
val refresh = call.getString("refreshToken") ?: run {
call.reject("refreshToken is required")
return
}
AuthStore.saveTokens(context, access, refresh)
call.resolve()
}
@PluginMethod
fun clearAuthTokens(call: PluginCall) {
AuthStore.clear(context)
call.resolve()
}
@PluginMethod
fun getAuthState(call: PluginCall) {
val present = AuthStore.getTokens(context) != null
val obj = JSObject().apply { put("present", present) }
call.resolve(obj)
}
// --- Background posting endpoint management ---
@PluginMethod
fun setBackgroundPostUrl(call: PluginCall) {
val url = call.getString("url")
if (url.isNullOrBlank()) {
BgPrefs.setPostUrl(context, null)
} else {
BgPrefs.setPostUrl(context, url)
}
call.resolve()
}
@PluginMethod
fun getBackgroundPostUrl(call: PluginCall) {
val url = BgPrefs.getPostUrl(context)
val obj = JSObject().apply { put("url", url) }
call.resolve(obj)
}
@PluginMethod
fun openBackgroundPermissionSettings(call: PluginCall) {
try {
val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = android.net.Uri.parse("package:" + context.packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
call.resolve()
} catch (e: Exception) {
call.reject("Failed to open settings: ${e.message}")
}
}
@PluginMethod
fun openNotificationPermissionSettings(call: PluginCall) {
try {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, context.packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
} else {
Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = android.net.Uri.parse("package:" + context.packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
context.startActivity(intent)
call.resolve()
} catch (e: Exception) {
call.reject("Failed to open notification settings: ${e.message}")
}
}
private fun emitPositionUpdate(forceEmit: Boolean = false) {
val now = System.currentTimeMillis()
if (!forceEmit && now - lastEmitTimestamp < emitIntervalMs) return
if (suppressMockedUpdates && isMockedLocation) return
val distance = calculateDistance(latestLatitude, latestLongitude, prevLatitude, prevLongitude)
val speedNow = latestImu?.speed ?: 0f
val directionNow = latestImu?.directionRad ?: 0f
val isSignificantChange = distance >= significantChangeThreshold
val speedChanged = abs(speedNow - prevSpeed) > speedChangeThreshold
val directionChanged = abs(directionNow - prevDirection) > directionChangeThreshold
// Tentukan motion state
motionState = when {
isMockedLocation -> "mocked"
speedNow > 1.0f -> "driving"
else -> "idle"
}
val shouldEmit = isSignificantChange || speedChanged || directionChanged
if (forceEmit || shouldEmit) {
prevLatitude = latestLatitude
prevLongitude = latestLongitude
prevSpeed = speedNow
prevDirection = directionNow
lastEmitTimestamp = now
// Ensure listener notifications run on the main thread for consistency
Handler(Looper.getMainLooper()).post {
notifyListeners("onPositionUpdate", buildPositionData())
}
}
}
private fun adjustIntervalAndSensorRate(speed: Float) {
val targetInterval = when {
speed > 5f -> 3000L
speed > 1.5f -> 8000L
speed > 0.3f -> 20000L
else -> 30000L
}
if (emitIntervalMs != targetInterval) {
emitIntervalMs = targetInterval
gpsManager?.setPollingInterval(targetInterval)
LogUtils.d("DUMON_GEOLOCATION", "Auto-set emitIntervalMs = $emitIntervalMs ms")
}
imuManager?.setSensorDelayBySpeed(speed)
}
private fun degToRad(deg: Double): Double {
return deg * PI / 180.0
}
private fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val R = 6371000.0 // Radius bumi dalam meter
val latDistance = degToRad(lat2 - lat1)
val lonDistance = degToRad(lon2 - lon1)
val a = sin(latDistance / 2).pow(2) + cos(degToRad(lat1)) * cos(degToRad(lat2)) *
sin(lonDistance / 2).pow(2)
val c = 2 * atan2(sqrt(a), sqrt(1 - a))
return R * c
}
private fun buildGnssStatusData(status: SatelliteStatus): JSObject {
val obj = JSObject()
obj.put("satellitesInView", status.satellitesInView)
obj.put("usedInFix", status.usedInFix)
val counts = JSObject()
status.constellationCounts.forEach { (k, v) -> counts.put(k, v) }
obj.put("constellationCounts", counts)
return obj
}
private fun buildPositionData(): JSObject {
val obj = JSObject()
val now = System.currentTimeMillis()
var outLat = latestLatitude
var outLon = latestLongitude
var predicted = false
val imu = latestImu
val dtSec = ((now - (if (latestTimestamp > 0) latestTimestamp else now)).toDouble() / 1000.0)
if (enableForwardPrediction && imu != null && !isMockedLocation && dtSec > 0) {
val clampedDt = dtSec.coerceAtMost(maxPredictionSeconds)
val speed = imu.speed
val dir = imu.directionRad
val dNorth = speed * clampedDt * kotlin.math.cos(dir)
val dEast = speed * clampedDt * kotlin.math.sin(dir)
val R = 6371000.0
val latRad = degToRad(latestLatitude)
val dLat = (dNorth / R) * (180.0 / PI)
val dLon = (dEast / (R * kotlin.math.cos(latRad))) * (180.0 / PI)
outLat = latestLatitude + dLat
outLon = latestLongitude + dLon
predicted = true
}
obj.put("source", latestSource)
obj.put("timestamp", if (latestTimestamp > 0) latestTimestamp else now)
obj.put("latitude", outLat)
obj.put("longitude", outLon)
obj.put("accuracy", latestAccuracy)
obj.put("isMocked", isMockedLocation)
// Always provide IMU-related fields to match TS definitions
val speedVal = latestImu?.speed ?: 0f
val accelVal = latestImu?.acceleration ?: 0f
val dirVal = latestImu?.directionRad ?: 0f
obj.put("speed", speedVal)
obj.put("acceleration", accelVal)
obj.put("directionRad", dirVal)
obj.put("predicted", predicted)
return obj
}
override fun handleOnDestroy() {
gpsManager?.stop()
imuManager?.stop()
wifiManager?.stopPeriodicScan()
stopDrivingEmitLoop()
applyKeepScreenOn(false)
super.handleOnDestroy()
}
}