2025-09-28 19:43:26 +08:00

551 lines
20 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 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 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) {
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.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)
}
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()
}
}