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() } }