391 lines
14 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.getcapacitor.annotation.PermissionCallback
import org.json.JSONArray
import org.json.JSONObject
import kotlin.math.*
import android.location.Location
@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 val significantChangeThreshold = 7 // ~7 meters
private val speedChangeThreshold = 0.5f // m/s
private val 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 val drivingEmitIntervalMs = 1500L
private var currentTrackingMode = GpsTrackingMode.NORMAL
override fun load() {
gpsManager = GpsStatusManager(
context,
onSatelliteStatusUpdate = { satelliteStatus = it },
onLocationUpdate = onLocationUpdate@{ location, isMocked ->
if (location.latitude == 0.0 && location.longitude == 0.0) {
Log.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() {
drivingEmitHandler?.removeCallbacks(drivingEmitRunnable!!)
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?.startPeriodicScan(3000L)
call.resolve()
}
@PluginMethod
fun stopPositioning(call: PluginCall) {
gpsManager?.stop()
imuManager?.stop()
wifiManager?.stopPeriodicScan()
stopDrivingEmitLoop()
call.resolve()
}
@PluginMethod
fun getLatestPosition(call: PluginCall) {
call.resolve(buildPositionData())
}
@PluginMethod
fun checkAndRequestPermissions(call: PluginCall) {
// requestAllPermissions(call, "checkAndRequestPermissions")
requestAllPermissions(call, "onPermissionResult")
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()
Log.d("DUMON_GEOLOCATION", "Switched to driving mode (continuous GPS)")
} else {
gpsManager?.startPollingMode()
currentTrackingMode = GpsTrackingMode.NORMAL
stopDrivingEmitLoop()
Log.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)
}
private fun emitPositionUpdate(forceEmit: Boolean = false) {
val now = System.currentTimeMillis()
if (!forceEmit && now - lastEmitTimestamp < emitIntervalMs) 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
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)
Log.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 buildPositionData(): JSObject {
val obj = JSObject()
obj.put("source", latestSource)
obj.put("timestamp", if (latestTimestamp > 0) latestTimestamp else System.currentTimeMillis())
obj.put("latitude", latestLatitude)
obj.put("longitude", latestLongitude)
obj.put("accuracy", latestAccuracy)
obj.put("isMocked", isMockedLocation)
latestImu?.let {
obj.put("speed", it.speed)
obj.put("acceleration", it.acceleration)
obj.put("directionRad", it.directionRad)
}
obj.put("predicted", latestSource == "PREDICTED")
return obj
}
}