763 lines
29 KiB
Kotlin
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()
|
|
}
|
|
}
|