updated 121025-01

This commit is contained in:
wengki81 2025-10-13 00:21:31 +08:00
parent f5be8180f2
commit 511d903890
24 changed files with 1841 additions and 28 deletions

View File

@ -4,7 +4,7 @@ Capacitor Plugin untuk Android yang menyediakan **real-time high-accuracy positi
- 📡 **GNSS multi-konstelasi** (GPS, GLONASS, BeiDou, Galileo) - 📡 **GNSS multi-konstelasi** (GPS, GLONASS, BeiDou, Galileo)
- 📶 **Wi-Fi RTT / RSSI** - 📶 **Wi-Fi RTT / RSSI**
- 🎯 **IMU Sensor** (Accelerometer, Gyroscope, Rotation) - 🎯 **IMU Sensor** (Accelerometer, Gyroscope, Rotation)
- ⚙️ **Kalman Filter Fusion** dan **Forward Prediction** - ⏩ **Forward Prediction (opsional)**
- 🛡️ **Deteksi Lokasi Palsu (Mock Location Detection)** - 🛡️ **Deteksi Lokasi Palsu (Mock Location Detection)**
--- ---
@ -64,7 +64,7 @@ Menghentikan semua sensor dan proses positioning.
getLatestPosition(): Promise<PositioningData> getLatestPosition(): Promise<PositioningData>
``` ```
Mengembalikan posisi terkini yang telah melalui proses fusion/prediksi. Mengembalikan posisi terkini. Jika opsi prediksi diaktifkan (lihat setOptions), nilai dapat diproyeksikan pendek dan ditandai `predicted: true`.
### addListener(onPositionUpdate, …) ### addListener(onPositionUpdate, …)
@ -94,6 +94,16 @@ configureEdgeToEdge(options: {
Mengatur status bar dan navigasi bar agar transparan, dengan warna dan icon style sesuai UI. Mengatur status bar dan navigasi bar agar transparan, dengan warna dan icon style sesuai UI.
### setGpsMode()
```typescript
setGpsMode(options: { mode: 'normal' | 'driving' }): Promise<void>
```
Mengganti mode GPS:
- `normal`: polling adaptif (hemat baterai)
- `driving`: continuous updates + loop emisi periodik
### setOptions() ### setOptions()
```typescript ```typescript
@ -174,8 +184,9 @@ interface PermissionStatus {
## Fusion dan Prediksi ## Fusion dan Prediksi
- Fusion posisi dilakukan menggunakan Kalman Filter sederhana untuk latitude & longitude. - Tidak ada Kalman/Fusion saat ini. Data yang dipancarkan berasal dari GNSS, dilengkapi IMU untuk heuristik interval dan estimasi arah/kecepatan.
- Saat GNSS tidak tersedia (signal hilang), plugin akan melakukan forward prediction berbasis speed + heading dari IMU selama 1 detik ke depan. - Prediksi maju (deadreckoning) bersifat opsional dan nonaktif secara default. Aktifkan dengan `setOptions({ enableForwardPrediction: true, maxPredictionSeconds?: number })`.
- Saat prediksi aktif, posisi dapat diproyeksikan pendek (<= `maxPredictionSeconds`) berdasarkan `speed` dan `directionRad` dari IMU, serta ditandai `predicted: true`. Nilai `source` tidak berubah.
--- ---
@ -225,12 +236,13 @@ interface WifiAp {
## Testing (example-app) ## Testing (example-app)
Contoh implementasi tersedia di folder /example-app dengan tombol: Contoh implementasi tersedia di folder `/example-app` dengan kontrol:
- Start Positioning - Start/Stop Positioning, Get Latest Position
- Stop Positioning - Request Permissions, Clear Log
- Get Latest Position - Mode: Driving / Normal
- Realtime log via onPositionUpdate - Apply Options (semua opsi runtime, termasuk keepScreenOn, emitGnssStatus, enableForwardPrediction, interval)
- Diagnostics: Get GNSS Status, Get Location Services Status
--- ---

View File

@ -1,7 +1,23 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" /> <uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" /> <uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
<!-- Background + Foreground service permissions -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- Android 14+ dedicated permission for location foreground services -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<!-- Android 13+ notifications runtime permission to show FGS notification -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application>
<service
android:name="com.dumon.plugin.geolocation.bg.BackgroundLocationService"
android:exported="false"
android:foregroundServiceType="location" />
</application>
</manifest> </manifest>

View File

@ -27,10 +27,15 @@ import com.dumon.plugin.geolocation.wifi.WifiScanResult
import com.dumon.plugin.geolocation.utils.PermissionUtils import com.dumon.plugin.geolocation.utils.PermissionUtils
import com.dumon.plugin.geolocation.utils.LogUtils import com.dumon.plugin.geolocation.utils.LogUtils
import com.getcapacitor.annotation.PermissionCallback 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.JSONArray
import org.json.JSONObject import org.json.JSONObject
import kotlin.math.* import kotlin.math.*
import android.location.Location import android.location.Location
import com.dumon.plugin.geolocation.utils.AuthStore
@CapacitorPlugin( @CapacitorPlugin(
name = "DumonGeolocation", name = "DumonGeolocation",
@ -199,7 +204,38 @@ class DumonGeolocation : Plugin() {
@PluginMethod @PluginMethod
fun getLatestPosition(call: PluginCall) { 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()) 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 @PluginMethod
@ -366,6 +402,26 @@ class DumonGeolocation : Plugin() {
keepScreenOn = it keepScreenOn = it
applyKeepScreenOn(keepScreenOn) 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() call.resolve()
} }
@ -413,6 +469,162 @@ class DumonGeolocation : Plugin() {
call.resolve(obj) 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) { private fun emitPositionUpdate(forceEmit: Boolean = false) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (!forceEmit && now - lastEmitTimestamp < emitIntervalMs) return if (!forceEmit && now - lastEmitTimestamp < emitIntervalMs) return

View File

@ -0,0 +1,260 @@
package com.dumon.plugin.geolocation.bg
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
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.utils.BgPrefs
import com.dumon.plugin.geolocation.utils.LogUtils
import com.dumon.plugin.geolocation.utils.AuthStore
import java.io.BufferedOutputStream
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.Executors
import kotlin.math.*
class BackgroundLocationService : Service() {
private var gpsManager: GpsStatusManager? = null
private var postUrl: String? = null
private val postExecutor = Executors.newSingleThreadExecutor()
@Volatile private var pendingPostedLat: Double? = null
@Volatile private var pendingPostedLon: Double? = null
@Volatile private var pendingPostedTs: Long? = null
@Volatile private var lastPostAttemptTs: Long = 0L
override fun onCreate() {
super.onCreate()
LogUtils.d("BG_SERVICE", "onCreate")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val channelId = intent?.getStringExtra(EXTRA_CHANNEL_ID) ?: DEFAULT_CHANNEL_ID
val channelName = intent?.getStringExtra(EXTRA_CHANNEL_NAME) ?: DEFAULT_CHANNEL_NAME
val title = intent?.getStringExtra(EXTRA_TITLE) ?: DEFAULT_TITLE
val text = intent?.getStringExtra(EXTRA_TEXT) ?: DEFAULT_TEXT
createChannelIfNeeded(channelId, channelName)
val notification = buildNotification(channelId, title, text)
startForeground(DEFAULT_NOTIFICATION_ID, notification)
// Mark active
BgPrefs.setActive(this, true)
// Optional remote posting endpoint (persisted)
postUrl = intent?.getStringExtra(EXTRA_POST_URL)
postUrl?.let { BgPrefs.setPostUrl(applicationContext, it) }
// Start GNSS continuous updates; avoid Wi-Fi scans in background for efficiency
gpsManager = GpsStatusManager(
applicationContext,
onSatelliteStatusUpdate = { /* no-op in background by default */ },
onLocationUpdate = { location, isMocked ->
if (location.latitude == 0.0 && location.longitude == 0.0) return@GpsStatusManager
BgPrefs.saveLatestFix(
context = applicationContext,
latitude = location.latitude,
longitude = location.longitude,
accuracy = location.accuracy.toDouble(),
timestamp = location.time,
source = if (isMocked) "MOCK" else "GNSS",
isMocked = isMocked,
speed = null,
acceleration = null,
directionRad = null,
)
val endpoint = postUrl ?: BgPrefs.getPostUrl(applicationContext)
endpoint?.let { url ->
// Check movement threshold, accuracy, and in-flight before posting
val last = BgPrefs.getLastPostedFix(applicationContext)
val minDistM = BgPrefs.getBackgroundPostMinDistanceMeters(applicationContext, 10.0)
val minAccM = BgPrefs.getBackgroundPostMinAccuracyMeters(applicationContext, Double.POSITIVE_INFINITY)
val minPostIntervalMs = BgPrefs.getBackgroundMinPostIntervalMs(applicationContext, 10_000L)
// Accuracy gating
if (minAccM.isFinite() && location.accuracy.toDouble() > minAccM) {
LogUtils.d("BG_SERVICE", "Skip post: accuracy ${location.accuracy} m > minAcc ${minAccM} m")
return@let
}
if (last != null) {
val d = haversineMeters(last.latitude, last.longitude, location.latitude, location.longitude)
if (d < minDistM) {
LogUtils.d("BG_SERVICE", "Skip post: moved ${"%.1f".format(d)} m < ${minDistM} m")
return@let
}
}
// In-flight pending gating
val pendLat = pendingPostedLat
val pendLon = pendingPostedLon
if (pendLat != null && pendLon != null) {
val dPend = haversineMeters(pendLat, pendLon, location.latitude, location.longitude)
if (dPend < minDistM) {
LogUtils.d("BG_SERVICE", "Skip post: pending in-flight within ${"%.1f".format(dPend)} m < ${minDistM} m")
return@let
}
}
// Min post interval gating
val now = System.currentTimeMillis()
val sinceLastAttempt = now - lastPostAttemptTs
if (minPostIntervalMs > 0 && sinceLastAttempt < minPostIntervalMs) {
LogUtils.d("BG_SERVICE", "Skip post: interval ${sinceLastAttempt} ms < min ${minPostIntervalMs} ms")
return@let
}
// Mark pending and timestamp before enqueue
pendingPostedLat = location.latitude
pendingPostedLon = location.longitude
pendingPostedTs = location.time
lastPostAttemptTs = now
postExecutor.execute {
try {
postFix(url,
latitude = location.latitude,
longitude = location.longitude,
accuracy = location.accuracy.toDouble(),
timestamp = location.time,
source = if (isMocked) "MOCK" else "GNSS",
isMocked = isMocked
)
// Only mark posted on success inside postFix (returns via exception or code check)
} catch (e: Exception) {
LogUtils.e("BG_SERVICE", "postFix failed: ${e.message}")
} finally {
pendingPostedLat = null
pendingPostedLon = null
pendingPostedTs = null
}
}
}
}
)
// Use polling mode with a configurable interval to save battery in background
gpsManager?.start(GpsTrackingMode.NORMAL)
val interval = BgPrefs.getBackgroundIntervalMs(applicationContext, 5000L)
gpsManager?.setPollingInterval(interval)
return START_STICKY
}
override fun onDestroy() {
LogUtils.d("BG_SERVICE", "onDestroy")
gpsManager?.stop()
gpsManager = null
BgPrefs.setActive(this, false)
try { postExecutor.shutdownNow() } catch (_: Exception) {}
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun createChannelIfNeeded(channelId: String, name: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
var ch = nm.getNotificationChannel(channelId)
if (ch == null) {
ch = NotificationChannel(channelId, name, NotificationManager.IMPORTANCE_LOW).apply {
description = "Background geolocation tracking"
setShowBadge(false)
enableVibration(false)
}
nm.createNotificationChannel(ch)
}
}
}
private fun buildNotification(channelId: String, title: String, text: String): Notification {
val builder = NotificationCompat.Builder(this, channelId)
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
.setContentTitle(title)
.setContentText(text)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
return builder.build()
}
companion object {
private const val DEFAULT_NOTIFICATION_ID = 0xD61
private const val DEFAULT_CHANNEL_ID = "DUMON_GEO_BG"
private const val DEFAULT_CHANNEL_NAME = "Dumon Geolocation"
private const val DEFAULT_TITLE = "Location tracking active"
private const val DEFAULT_TEXT = "Updating location in background"
const val EXTRA_CHANNEL_ID = "channelId"
const val EXTRA_CHANNEL_NAME = "channelName"
const val EXTRA_TITLE = "title"
const val EXTRA_TEXT = "text"
const val EXTRA_POST_URL = "postUrl"
}
private fun postFix(
endpoint: String,
latitude: Double,
longitude: Double,
accuracy: Double,
timestamp: Long,
source: String,
isMocked: Boolean
) {
val json = """{"source":"$source","timestamp":$timestamp,"latitude":$latitude,"longitude":$longitude,"accuracy":$accuracy,"isMocked":$isMocked}"""
val url = URL(endpoint)
val conn = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
connectTimeout = 5000
readTimeout = 5000
doOutput = true
setRequestProperty("Content-Type", "application/json")
// Attach auth headers if stored
val tokens = AuthStore.getTokens(applicationContext)
if (tokens != null) {
setRequestProperty("Authorization", "Bearer ${tokens.accessToken}")
setRequestProperty("refresh-token", tokens.refreshToken)
}
}
try {
BufferedOutputStream(conn.outputStream).use { out ->
out.write(json.toByteArray(Charsets.UTF_8))
out.flush()
}
val code = conn.responseCode
if (code in 200..299) {
LogUtils.d("BG_SERVICE", "Posted location successfully (HTTP $code)")
// Update last posted marker after success
BgPrefs.setLastPostedFix(applicationContext, latitude, longitude, timestamp)
} else {
val err = try {
BufferedReader(InputStreamReader(conn.errorStream ?: conn.inputStream)).readText()
} catch (_: Exception) { "" }
LogUtils.e("BG_SERVICE", "Posting failed (HTTP $code) $err")
}
} finally {
conn.disconnect()
}
}
private fun haversineMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val R = 6371000.0
val dLat = Math.toRadians(lat2 - lat1)
val dLon = Math.toRadians(lon2 - lon1)
val a = sin(dLat / 2).pow(2.0) + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * sin(dLon / 2).pow(2.0)
val c = 2 * atan2(sqrt(a), sqrt(1 - a))
return R * c
}
}

View File

@ -58,6 +58,101 @@ class GpsStatusManager(
} }
} }
/**
* Request a single fresh location fix from available providers with a timeout.
* - Tries GPS if fine location is granted
* - Optionally also tries NETWORK if coarse location is granted (faster, less accurate)
* - Returns the first location received and stops listening
* - On timeout or missing permissions, returns null
*/
@SuppressLint("MissingPermission")
fun requestSingleFix(
timeoutMs: Long = 3000L,
useNetworkProvider: Boolean = true,
callback: (Location?, Boolean) -> Unit
) {
val hasFine = ActivityCompat.checkSelfPermission(context, android.Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
val hasCoarse = ActivityCompat.checkSelfPermission(context, android.Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
if (!hasFine && !hasCoarse) {
LogUtils.e("GPS_STATUS", "requestSingleFix: missing location permissions")
callback(null, false)
return
}
var done = false
val mainHandler = Handler(Looper.getMainLooper())
lateinit var timeoutRunnable: Runnable
val singleListener = object : LocationListener {
override fun onLocationChanged(location: Location) {
if (done) return
done = true
val isMocked = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) location.isMock else location.isFromMockProvider
try {
locationManager.removeUpdates(this)
} catch (_: Exception) {}
// Cancel timeout if we already have a fix
try { mainHandler.removeCallbacks(timeoutRunnable) } catch (_: Exception) {}
callback(location, isMocked)
}
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
override fun onProviderEnabled(provider: String) {}
override fun onProviderDisabled(provider: String) {}
}
// Start listening from eligible providers
var requested = false
if (hasFine) {
try {
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
0L,
0f,
singleListener
)
requested = true
} catch (e: Exception) {
LogUtils.e("GPS_STATUS", "requestSingleFix: failed to request GPS provider: ${e.message}")
}
}
if (useNetworkProvider && hasCoarse) {
try {
locationManager.requestLocationUpdates(
LocationManager.NETWORK_PROVIDER,
0L,
0f,
singleListener
)
requested = true
} catch (e: Exception) {
LogUtils.e("GPS_STATUS", "requestSingleFix: failed to request NETWORK provider: ${e.message}")
}
}
if (!requested) {
LogUtils.e("GPS_STATUS", "requestSingleFix: no provider requested (permissions/providers)")
callback(null, false)
return
}
// Timeout fallback
timeoutRunnable = Runnable {
if (done) return@Runnable
done = true
try {
locationManager.removeUpdates(singleListener)
} catch (_: Exception) {}
callback(null, false)
}
mainHandler.postDelayed(timeoutRunnable, timeoutMs)
}
private fun pollOnceAndEmit() { private fun pollOnceAndEmit() {
val oneShotListener = object : LocationListener { val oneShotListener = object : LocationListener {
override fun onLocationChanged(location: Location) { override fun onLocationChanged(location: Location) {
@ -85,16 +180,40 @@ class GpsStatusManager(
override fun onProviderEnabled(provider: String) {} override fun onProviderEnabled(provider: String) {}
override fun onProviderDisabled(provider: String) {} override fun onProviderDisabled(provider: String) {}
} }
val hasFine = ActivityCompat.checkSelfPermission(context, android.Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
val hasCoarse = ActivityCompat.checkSelfPermission(context, android.Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
if (ActivityCompat.checkSelfPermission(context, android.Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) { var requested = false
if (hasFine) {
try {
locationManager.requestLocationUpdates( locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER, LocationManager.GPS_PROVIDER,
0L, 0L,
0f, 0f,
oneShotListener oneShotListener
) )
} else { requested = true
LogUtils.e("GPS_STATUS", "Missing location permission") } catch (e: Exception) {
LogUtils.e("GPS_STATUS", "Failed GPS one-shot request: ${e.message}")
}
}
if (hasCoarse) {
try {
locationManager.requestLocationUpdates(
LocationManager.NETWORK_PROVIDER,
0L,
0f,
oneShotListener
)
requested = true
} catch (e: Exception) {
LogUtils.e("GPS_STATUS", "Failed NETWORK one-shot request: ${e.message}")
}
}
if (!requested) {
LogUtils.e("GPS_STATUS", "pollOnceAndEmit: no provider requested (missing permissions)")
} }
} }

View File

@ -0,0 +1,42 @@
package com.dumon.plugin.geolocation.utils
import android.content.Context
import android.os.Build
import androidx.annotation.WorkerThread
// Optional secure storage using EncryptedSharedPreferences if available.
// Falls back to regular SharedPreferences if crypto init fails.
object AuthStore {
private const val PREFS_NAME = "dumon_geo_auth_prefs"
private const val KEY_AT = "access_token"
private const val KEY_RT = "refresh_token"
data class Tokens(val accessToken: String, val refreshToken: String)
fun saveTokens(context: Context, accessToken: String, refreshToken: String) {
val sp = prefs(context)
sp.edit()
.putString(KEY_AT, accessToken)
.putString(KEY_RT, refreshToken)
.apply()
}
fun getTokens(context: Context): Tokens? {
val sp = prefs(context)
val at = sp.getString(KEY_AT, null)
val rt = sp.getString(KEY_RT, null)
return if (!at.isNullOrBlank() && !rt.isNullOrBlank()) Tokens(at, rt) else null
}
fun clear(context: Context) {
prefs(context).edit().clear().apply()
}
private fun prefs(context: Context): android.content.SharedPreferences {
// For simplicity and build reliability, use standard SharedPreferences.
// If you want strong encryption, migrate to EncryptedSharedPreferences with
// androidx.security:security-crypto dependency and MasterKey.
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
}

View File

@ -0,0 +1,182 @@
package com.dumon.plugin.geolocation.utils
import android.content.Context
object BgPrefs {
private const val PREFS = "dumon_geo_bg_prefs"
private const val KEY_ACTIVE = "active"
private const val KEY_LAT = "last_lat"
private const val KEY_LON = "last_lon"
private const val KEY_ACC = "last_acc"
private const val KEY_TS = "last_ts"
private const val KEY_SRC = "last_src"
private const val KEY_MOCK = "last_mock"
private const val KEY_SPEED = "last_speed"
private const val KEY_ACCEL = "last_accel"
private const val KEY_DIR = "last_dir"
private const val KEY_BG_INTERVAL = "bg_interval_ms"
private const val KEY_POST_URL = "post_url"
private const val KEY_POST_LAST_LAT = "post_last_lat"
private const val KEY_POST_LAST_LON = "post_last_lon"
private const val KEY_POST_LAST_TS = "post_last_ts"
private const val KEY_POST_MIN_DIST_M = "bg_post_min_dist_m"
private const val KEY_POST_MIN_ACC_M = "bg_post_min_acc_m"
private const val KEY_POST_MIN_INTERVAL_MS = "bg_post_min_interval_ms"
fun setActive(context: Context, active: Boolean) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putBoolean(KEY_ACTIVE, active)
.apply()
}
fun isActive(context: Context): Boolean {
return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getBoolean(KEY_ACTIVE, false)
}
fun saveLatestFix(
context: Context,
latitude: Double,
longitude: Double,
accuracy: Double,
timestamp: Long,
source: String,
isMocked: Boolean,
speed: Float?,
acceleration: Float?,
directionRad: Float?,
) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putFloat(KEY_LAT, latitude.toFloat())
.putFloat(KEY_LON, longitude.toFloat())
.putFloat(KEY_ACC, accuracy.toFloat())
.putLong(KEY_TS, timestamp)
.putString(KEY_SRC, source)
.putBoolean(KEY_MOCK, isMocked)
.apply()
// Optional IMU fields
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putFloat(KEY_SPEED, speed ?: 0f)
.putFloat(KEY_ACCEL, acceleration ?: 0f)
.putFloat(KEY_DIR, directionRad ?: 0f)
.apply()
}
data class BgFix(
val latitude: Double,
val longitude: Double,
val accuracy: Double,
val timestamp: Long,
val source: String,
val isMocked: Boolean,
val speed: Float,
val acceleration: Float,
val directionRad: Float,
)
fun readLatestFix(context: Context): BgFix? {
val sp = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val ts = sp.getLong(KEY_TS, 0L)
if (ts == 0L) return null
val lat = sp.getFloat(KEY_LAT, 0f).toDouble()
val lon = sp.getFloat(KEY_LON, 0f).toDouble()
val acc = sp.getFloat(KEY_ACC, 999f).toDouble()
val src = sp.getString(KEY_SRC, "GNSS") ?: "GNSS"
val mocked = sp.getBoolean(KEY_MOCK, false)
val speed = sp.getFloat(KEY_SPEED, 0f)
val accel = sp.getFloat(KEY_ACCEL, 0f)
val dir = sp.getFloat(KEY_DIR, 0f)
return BgFix(lat, lon, acc, ts, src, mocked, speed, accel, dir)
}
fun setBackgroundIntervalMs(context: Context, intervalMs: Long) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putLong(KEY_BG_INTERVAL, intervalMs)
.apply()
}
fun getBackgroundIntervalMs(context: Context, defaultMs: Long = 5000L): Long {
return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getLong(KEY_BG_INTERVAL, defaultMs)
.coerceAtLeast(1000L)
}
fun setBackgroundPostMinDistanceMeters(context: Context, meters: Double) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putFloat(KEY_POST_MIN_DIST_M, meters.toFloat())
.apply()
}
fun getBackgroundPostMinDistanceMeters(context: Context, defaultMeters: Double = 10.0): Double {
val v = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getFloat(KEY_POST_MIN_DIST_M, defaultMeters.toFloat())
return if (v <= 0f) defaultMeters else v.toDouble()
}
fun setBackgroundPostMinAccuracyMeters(context: Context, meters: Double) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putFloat(KEY_POST_MIN_ACC_M, meters.toFloat())
.apply()
}
fun getBackgroundPostMinAccuracyMeters(context: Context, defaultMeters: Double = Double.POSITIVE_INFINITY): Double {
val v = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getFloat(KEY_POST_MIN_ACC_M, if (defaultMeters.isFinite()) defaultMeters.toFloat() else Float.POSITIVE_INFINITY)
return if (v <= 0f || v.isInfinite()) Double.POSITIVE_INFINITY else v.toDouble()
}
fun setBackgroundMinPostIntervalMs(context: Context, intervalMs: Long) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putLong(KEY_POST_MIN_INTERVAL_MS, intervalMs)
.apply()
}
fun getBackgroundMinPostIntervalMs(context: Context, defaultMs: Long = 10_000L): Long {
return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getLong(KEY_POST_MIN_INTERVAL_MS, defaultMs)
.coerceAtLeast(0L)
}
fun setPostUrl(context: Context, url: String?) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.apply {
if (url.isNullOrBlank()) remove(KEY_POST_URL) else putString(KEY_POST_URL, url)
}
.apply()
}
fun getPostUrl(context: Context): String? {
return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_POST_URL, null)
}
data class PostedFix(val latitude: Double, val longitude: Double, val timestamp: Long)
fun setLastPostedFix(context: Context, lat: Double, lon: Double, ts: Long) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putFloat(KEY_POST_LAST_LAT, lat.toFloat())
.putFloat(KEY_POST_LAST_LON, lon.toFloat())
.putLong(KEY_POST_LAST_TS, ts)
.apply()
}
fun getLastPostedFix(context: Context): PostedFix? {
val sp = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val ts = sp.getLong(KEY_POST_LAST_TS, 0L)
if (ts == 0L) return null
val lat = sp.getFloat(KEY_POST_LAST_LAT, 0f).toDouble()
val lon = sp.getFloat(KEY_POST_LAST_LON, 0f).toDouble()
return PostedFix(lat, lon, ts)
}
}

158
dist/docs.json vendored
View File

@ -89,6 +89,136 @@
"complexTypes": [], "complexTypes": [],
"slug": "getlocationservicesstatus" "slug": "getlocationservicesstatus"
}, },
{
"name": "startBackgroundTracking",
"signature": "(options?: { title?: string | undefined; text?: string | undefined; channelId?: string | undefined; channelName?: string | undefined; postUrl?: string | undefined; } | undefined) => Promise<void>",
"parameters": [
{
"name": "options",
"docs": "",
"type": "{ title?: string | undefined; text?: string | undefined; channelId?: string | undefined; channelName?: string | undefined; postUrl?: string | undefined; } | undefined"
}
],
"returns": "Promise<void>",
"tags": [],
"docs": "",
"complexTypes": [],
"slug": "startbackgroundtracking"
},
{
"name": "stopBackgroundTracking",
"signature": "() => Promise<void>",
"parameters": [],
"returns": "Promise<void>",
"tags": [],
"docs": "",
"complexTypes": [],
"slug": "stopbackgroundtracking"
},
{
"name": "isBackgroundTrackingActive",
"signature": "() => Promise<{ active: boolean; }>",
"parameters": [],
"returns": "Promise<{ active: boolean; }>",
"tags": [],
"docs": "",
"complexTypes": [],
"slug": "isbackgroundtrackingactive"
},
{
"name": "getBackgroundLatestPosition",
"signature": "() => Promise<PositioningData | null>",
"parameters": [],
"returns": "Promise<PositioningData | null>",
"tags": [],
"docs": "",
"complexTypes": [
"PositioningData"
],
"slug": "getbackgroundlatestposition"
},
{
"name": "openBackgroundPermissionSettings",
"signature": "() => Promise<void>",
"parameters": [],
"returns": "Promise<void>",
"tags": [],
"docs": "",
"complexTypes": [],
"slug": "openbackgroundpermissionsettings"
},
{
"name": "openNotificationPermissionSettings",
"signature": "() => Promise<void>",
"parameters": [],
"returns": "Promise<void>",
"tags": [],
"docs": "",
"complexTypes": [],
"slug": "opennotificationpermissionsettings"
},
{
"name": "setAuthTokens",
"signature": "(tokens: { accessToken: string; refreshToken: string; }) => Promise<void>",
"parameters": [
{
"name": "tokens",
"docs": "",
"type": "{ accessToken: string; refreshToken: string; }"
}
],
"returns": "Promise<void>",
"tags": [],
"docs": "",
"complexTypes": [],
"slug": "setauthtokens"
},
{
"name": "clearAuthTokens",
"signature": "() => Promise<void>",
"parameters": [],
"returns": "Promise<void>",
"tags": [],
"docs": "",
"complexTypes": [],
"slug": "clearauthtokens"
},
{
"name": "getAuthState",
"signature": "() => Promise<{ present: boolean; }>",
"parameters": [],
"returns": "Promise<{ present: boolean; }>",
"tags": [],
"docs": "",
"complexTypes": [],
"slug": "getauthstate"
},
{
"name": "setBackgroundPostUrl",
"signature": "(options: { url?: string; }) => Promise<void>",
"parameters": [
{
"name": "options",
"docs": "",
"type": "{ url?: string | undefined; }"
}
],
"returns": "Promise<void>",
"tags": [],
"docs": "",
"complexTypes": [],
"slug": "setbackgroundposturl"
},
{
"name": "getBackgroundPostUrl",
"signature": "() => Promise<{ url: string | null; }>",
"parameters": [],
"returns": "Promise<{ url: string | null; }>",
"tags": [],
"docs": "",
"complexTypes": [],
"slug": "getbackgroundposturl"
},
{ {
"name": "configureEdgeToEdge", "name": "configureEdgeToEdge",
"signature": "(options: { bgColor: string; style: 'DARK' | 'LIGHT'; overlay?: boolean; }) => Promise<void>", "signature": "(options: { bgColor: string; style: 'DARK' | 'LIGHT'; overlay?: boolean; }) => Promise<void>",
@ -372,6 +502,34 @@
"docs": "", "docs": "",
"complexTypes": [], "complexTypes": [],
"type": "boolean | undefined" "type": "boolean | undefined"
},
{
"name": "backgroundPollingIntervalMs",
"tags": [],
"docs": "",
"complexTypes": [],
"type": "number | undefined"
},
{
"name": "backgroundPostMinDistanceMeters",
"tags": [],
"docs": "",
"complexTypes": [],
"type": "number | undefined"
},
{
"name": "backgroundPostMinAccuracyMeters",
"tags": [],
"docs": "",
"complexTypes": [],
"type": "number | undefined"
},
{
"name": "backgroundMinPostIntervalMs",
"tags": [],
"docs": "",
"complexTypes": [],
"type": "number | undefined"
} }
] ]
}, },

View File

@ -32,6 +32,10 @@ export interface DumonGeoOptions {
emitGnssStatus?: boolean; emitGnssStatus?: boolean;
suppressMockedUpdates?: boolean; suppressMockedUpdates?: boolean;
keepScreenOn?: boolean; keepScreenOn?: boolean;
backgroundPollingIntervalMs?: number;
backgroundPostMinDistanceMeters?: number;
backgroundPostMinAccuracyMeters?: number;
backgroundMinPostIntervalMs?: number;
} }
export interface PermissionStatus { export interface PermissionStatus {
location: 'granted' | 'denied'; location: 'granted' | 'denied';
@ -48,6 +52,34 @@ export interface DumonGeolocationPlugin {
gpsEnabled: boolean; gpsEnabled: boolean;
networkEnabled: boolean; networkEnabled: boolean;
}>; }>;
startBackgroundTracking(options?: {
title?: string;
text?: string;
channelId?: string;
channelName?: string;
postUrl?: string;
}): Promise<void>;
stopBackgroundTracking(): Promise<void>;
isBackgroundTrackingActive(): Promise<{
active: boolean;
}>;
getBackgroundLatestPosition(): Promise<PositioningData | null>;
openBackgroundPermissionSettings(): Promise<void>;
openNotificationPermissionSettings(): Promise<void>;
setAuthTokens(tokens: {
accessToken: string;
refreshToken: string;
}): Promise<void>;
clearAuthTokens(): Promise<void>;
getAuthState(): Promise<{
present: boolean;
}>;
setBackgroundPostUrl(options: {
url?: string;
}): Promise<void>;
getBackgroundPostUrl(): Promise<{
url: string | null;
}>;
configureEdgeToEdge(options: { configureEdgeToEdge(options: {
bgColor: string; bgColor: string;
style: 'DARK' | 'LIGHT'; style: 'DARK' | 'LIGHT';

View File

@ -1 +1 @@
{"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["import type { PluginListenerHandle } from '@capacitor/core';\n\n// export interface SatelliteStatus {\n// satellitesInView: number;\n// usedInFix: number;\n// constellationCounts: { [key: string]: number };\n// }\n\n// export interface WifiAp {\n// ssid: string;\n// bssid: string;\n// rssi: number;\n// distance?: number;\n// }\n\n// export interface WifiScanResult {\n// apCount: number;\n// aps: WifiAp[];\n// }\n\n// export interface ImuData {\n// accelX: number;\n// accelY: number;\n// accelZ: number;\n// gyroX: number;\n// gyroY: number;\n// gyroZ: number;\n// speed?: number;\n// acceleration?: number;\n// directionRad?: number;\n// }\n\n// export interface GpsData {\n// latitude: number;\n// longitude: number;\n// accuracy: number;\n// satellitesInView?: number;\n// usedInFix?: number;\n// constellationCounts?: { [key: string]: number };\n// }\n\n// export interface PositioningData {\n// source: 'GNSS' | 'WIFI' | 'FUSED' | 'MOCK';\n// timestamp: number;\n// latitude: number;\n// longitude: number;\n// accuracy: number;\n\n// gnssData?: SatelliteStatus;\n// wifiData?: WifiAp[];\n// imuData?: ImuData;\n// }\n\nexport interface PositioningData {\n source: 'GNSS' | 'WIFI' | 'FUSED' | 'MOCK';\n timestamp: number;\n latitude: number;\n longitude: number;\n accuracy: number;\n speed: number;\n acceleration: number;\n directionRad: number;\n isMocked: boolean;\n predicted?: boolean;\n}\n\nexport interface SatelliteStatus {\n satellitesInView: number;\n usedInFix: number;\n constellationCounts: { [key: string]: number };\n}\n\nexport interface DumonGeoOptions {\n distanceThresholdMeters?: number;\n speedChangeThreshold?: number;\n directionChangeThreshold?: number;\n emitDebounceMs?: number;\n drivingEmitIntervalMs?: number;\n wifiScanIntervalMs?: number;\n enableWifiRtt?: boolean;\n enableLogging?: boolean;\n enableForwardPrediction?: boolean;\n maxPredictionSeconds?: number;\n emitGnssStatus?: boolean;\n suppressMockedUpdates?: boolean;\n keepScreenOn?: boolean;\n}\n\nexport interface PermissionStatus {\n location: 'granted' | 'denied';\n wifi: 'granted' | 'denied';\n}\n\nexport interface DumonGeolocationPlugin {\n startPositioning(): Promise<void>;\n stopPositioning(): Promise<void>;\n getLatestPosition(): Promise<PositioningData>;\n checkAndRequestPermissions(): Promise<PermissionStatus>;\n setOptions(options: DumonGeoOptions): Promise<void>;\n getGnssStatus(): Promise<SatelliteStatus | null>;\n getLocationServicesStatus(): Promise<{ gpsEnabled: boolean; networkEnabled: boolean }>;\n\n configureEdgeToEdge(options: {\n bgColor: string;\n style: 'DARK' | 'LIGHT';\n overlay?: boolean;\n }): Promise<void>;\n\n setGpsMode(options: { mode: 'normal' | 'driving' }): Promise<void>;\n\n addListener(\n eventName: 'onPositionUpdate',\n listenerFunc: (data: PositioningData) => void\n ): PluginListenerHandle;\n\n addListener(\n eventName: 'onGnssStatus',\n listenerFunc: (data: SatelliteStatus) => void\n ): PluginListenerHandle;\n}\n"]} {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["import type { PluginListenerHandle } from '@capacitor/core';\n\n// export interface SatelliteStatus {\n// satellitesInView: number;\n// usedInFix: number;\n// constellationCounts: { [key: string]: number };\n// }\n\n// export interface WifiAp {\n// ssid: string;\n// bssid: string;\n// rssi: number;\n// distance?: number;\n// }\n\n// export interface WifiScanResult {\n// apCount: number;\n// aps: WifiAp[];\n// }\n\n// export interface ImuData {\n// accelX: number;\n// accelY: number;\n// accelZ: number;\n// gyroX: number;\n// gyroY: number;\n// gyroZ: number;\n// speed?: number;\n// acceleration?: number;\n// directionRad?: number;\n// }\n\n// export interface GpsData {\n// latitude: number;\n// longitude: number;\n// accuracy: number;\n// satellitesInView?: number;\n// usedInFix?: number;\n// constellationCounts?: { [key: string]: number };\n// }\n\n// export interface PositioningData {\n// source: 'GNSS' | 'WIFI' | 'FUSED' | 'MOCK';\n// timestamp: number;\n// latitude: number;\n// longitude: number;\n// accuracy: number;\n\n// gnssData?: SatelliteStatus;\n// wifiData?: WifiAp[];\n// imuData?: ImuData;\n// }\n\nexport interface PositioningData {\n source: 'GNSS' | 'WIFI' | 'FUSED' | 'MOCK';\n timestamp: number;\n latitude: number;\n longitude: number;\n accuracy: number;\n speed: number;\n acceleration: number;\n directionRad: number;\n isMocked: boolean;\n predicted?: boolean;\n}\n\nexport interface SatelliteStatus {\n satellitesInView: number;\n usedInFix: number;\n constellationCounts: { [key: string]: number };\n}\n\nexport interface DumonGeoOptions {\n distanceThresholdMeters?: number;\n speedChangeThreshold?: number;\n directionChangeThreshold?: number;\n emitDebounceMs?: number;\n drivingEmitIntervalMs?: number;\n wifiScanIntervalMs?: number;\n enableWifiRtt?: boolean;\n enableLogging?: boolean;\n enableForwardPrediction?: boolean;\n maxPredictionSeconds?: number;\n emitGnssStatus?: boolean;\n suppressMockedUpdates?: boolean;\n keepScreenOn?: boolean;\n backgroundPollingIntervalMs?: number; // Android background polling interval\n backgroundPostMinDistanceMeters?: number; // Android background min distance to post\n backgroundPostMinAccuracyMeters?: number; // Android background min acceptable accuracy for POST (meters)\n backgroundMinPostIntervalMs?: number; // Android background minimum interval between POST attempts\n}\n\nexport interface PermissionStatus {\n location: 'granted' | 'denied';\n wifi: 'granted' | 'denied';\n}\n\nexport interface DumonGeolocationPlugin {\n startPositioning(): Promise<void>;\n stopPositioning(): Promise<void>;\n getLatestPosition(): Promise<PositioningData>;\n checkAndRequestPermissions(): Promise<PermissionStatus>;\n setOptions(options: DumonGeoOptions): Promise<void>;\n getGnssStatus(): Promise<SatelliteStatus | null>;\n getLocationServicesStatus(): Promise<{ gpsEnabled: boolean; networkEnabled: boolean }>;\n // Background tracking (Android)\n startBackgroundTracking(options?: {\n title?: string;\n text?: string;\n channelId?: string;\n channelName?: string;\n postUrl?: string; // optional: service will POST latest fixes here as JSON\n }): Promise<void>;\n stopBackgroundTracking(): Promise<void>;\n isBackgroundTrackingActive(): Promise<{ active: boolean }>;\n getBackgroundLatestPosition(): Promise<PositioningData | null>;\n openBackgroundPermissionSettings(): Promise<void>;\n openNotificationPermissionSettings(): Promise<void>;\n // Auth token management for background posting\n setAuthTokens(tokens: { accessToken: string; refreshToken: string }): Promise<void>;\n clearAuthTokens(): Promise<void>;\n getAuthState(): Promise<{ present: boolean }>;\n setBackgroundPostUrl(options: { url?: string }): Promise<void>;\n getBackgroundPostUrl(): Promise<{ url: string | null }>;\n\n configureEdgeToEdge(options: {\n bgColor: string;\n style: 'DARK' | 'LIGHT';\n overlay?: boolean;\n }): Promise<void>;\n\n setGpsMode(options: { mode: 'normal' | 'driving' }): Promise<void>;\n\n addListener(\n eventName: 'onPositionUpdate',\n listenerFunc: (data: PositioningData) => void\n ): PluginListenerHandle;\n\n addListener(\n eventName: 'onGnssStatus',\n listenerFunc: (data: SatelliteStatus) => void\n ): PluginListenerHandle;\n}\n"]}

28
dist/esm/web.d.ts vendored
View File

@ -19,4 +19,32 @@ export declare class DumonGeolocationWeb extends WebPlugin {
gpsEnabled: boolean; gpsEnabled: boolean;
networkEnabled: boolean; networkEnabled: boolean;
}>; }>;
startBackgroundTracking(_options?: {
title?: string;
text?: string;
channelId?: string;
channelName?: string;
postUrl?: string;
}): Promise<void>;
stopBackgroundTracking(): Promise<void>;
isBackgroundTrackingActive(): Promise<{
active: boolean;
}>;
getBackgroundLatestPosition(): Promise<PositioningData | null>;
openBackgroundPermissionSettings(): Promise<void>;
openNotificationPermissionSettings(): Promise<void>;
setAuthTokens(_tokens: {
accessToken: string;
refreshToken: string;
}): Promise<void>;
clearAuthTokens(): Promise<void>;
getAuthState(): Promise<{
present: boolean;
}>;
setBackgroundPostUrl(_options: {
url?: string;
}): Promise<void>;
getBackgroundPostUrl(): Promise<{
url: string | null;
}>;
} }

34
dist/esm/web.js vendored
View File

@ -41,5 +41,39 @@ export class DumonGeolocationWeb extends WebPlugin {
// Web stub; assume enabled // Web stub; assume enabled
return { gpsEnabled: true, networkEnabled: true }; return { gpsEnabled: true, networkEnabled: true };
} }
// Background tracking stubs (no-op on web)
async startBackgroundTracking(_options) {
console.info('[dumon-geolocation] startBackgroundTracking is not supported on web.');
}
async stopBackgroundTracking() {
console.info('[dumon-geolocation] stopBackgroundTracking is not supported on web.');
}
async isBackgroundTrackingActive() {
return { active: false };
}
async getBackgroundLatestPosition() {
return null;
}
async openBackgroundPermissionSettings() {
console.info('[dumon-geolocation] openBackgroundPermissionSettings is not supported on web.');
}
async openNotificationPermissionSettings() {
console.info('[dumon-geolocation] openNotificationPermissionSettings is not supported on web.');
}
async setAuthTokens(_tokens) {
console.info('[dumon-geolocation] setAuthTokens is a no-op on web.');
}
async clearAuthTokens() {
console.info('[dumon-geolocation] clearAuthTokens is a no-op on web.');
}
async getAuthState() {
return { present: false };
}
async setBackgroundPostUrl(_options) {
console.info('[dumon-geolocation] setBackgroundPostUrl is not supported on web.');
}
async getBackgroundPostUrl() {
return { url: null };
}
} }
//# sourceMappingURL=web.js.map //# sourceMappingURL=web.js.map

2
dist/esm/web.js.map vendored

File diff suppressed because one or more lines are too long

34
dist/plugin.cjs.js vendored
View File

@ -48,6 +48,40 @@ class DumonGeolocationWeb extends core.WebPlugin {
// Web stub; assume enabled // Web stub; assume enabled
return { gpsEnabled: true, networkEnabled: true }; return { gpsEnabled: true, networkEnabled: true };
} }
// Background tracking stubs (no-op on web)
async startBackgroundTracking(_options) {
console.info('[dumon-geolocation] startBackgroundTracking is not supported on web.');
}
async stopBackgroundTracking() {
console.info('[dumon-geolocation] stopBackgroundTracking is not supported on web.');
}
async isBackgroundTrackingActive() {
return { active: false };
}
async getBackgroundLatestPosition() {
return null;
}
async openBackgroundPermissionSettings() {
console.info('[dumon-geolocation] openBackgroundPermissionSettings is not supported on web.');
}
async openNotificationPermissionSettings() {
console.info('[dumon-geolocation] openNotificationPermissionSettings is not supported on web.');
}
async setAuthTokens(_tokens) {
console.info('[dumon-geolocation] setAuthTokens is a no-op on web.');
}
async clearAuthTokens() {
console.info('[dumon-geolocation] clearAuthTokens is a no-op on web.');
}
async getAuthState() {
return { present: false };
}
async setBackgroundPostUrl(_options) {
console.info('[dumon-geolocation] setBackgroundPostUrl is not supported on web.');
}
async getBackgroundPostUrl() {
return { url: null };
}
} }
var web = /*#__PURE__*/Object.freeze({ var web = /*#__PURE__*/Object.freeze({

File diff suppressed because one or more lines are too long

34
dist/plugin.js vendored
View File

@ -47,6 +47,40 @@ var capacitorDumonGeolocation = (function (exports, core) {
// Web stub; assume enabled // Web stub; assume enabled
return { gpsEnabled: true, networkEnabled: true }; return { gpsEnabled: true, networkEnabled: true };
} }
// Background tracking stubs (no-op on web)
async startBackgroundTracking(_options) {
console.info('[dumon-geolocation] startBackgroundTracking is not supported on web.');
}
async stopBackgroundTracking() {
console.info('[dumon-geolocation] stopBackgroundTracking is not supported on web.');
}
async isBackgroundTrackingActive() {
return { active: false };
}
async getBackgroundLatestPosition() {
return null;
}
async openBackgroundPermissionSettings() {
console.info('[dumon-geolocation] openBackgroundPermissionSettings is not supported on web.');
}
async openNotificationPermissionSettings() {
console.info('[dumon-geolocation] openNotificationPermissionSettings is not supported on web.');
}
async setAuthTokens(_tokens) {
console.info('[dumon-geolocation] setAuthTokens is a no-op on web.');
}
async clearAuthTokens() {
console.info('[dumon-geolocation] clearAuthTokens is a no-op on web.');
}
async getAuthState() {
return { present: false };
}
async setBackgroundPostUrl(_options) {
console.info('[dumon-geolocation] setBackgroundPostUrl is not supported on web.');
}
async getBackgroundPostUrl() {
return { url: null };
}
} }
var web = /*#__PURE__*/Object.freeze({ var web = /*#__PURE__*/Object.freeze({

2
dist/plugin.js.map vendored

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,386 @@
# Dumon Geolocation Plugin — Reference & Internals
Dokumen ini merangkum API publik, perilaku runtime, state internal, serta contoh penggunaan plugin dumongeolocation (Android). Disusun agar mudah dipahami developer aplikasi.
## Ringkasan
- Platform: Android (web memiliki stub; iOS saat ini bukan target utama).
- Sumber data: GNSS (GPS/GLONASS/BeiDou/Galileo), IMU (accelerometer/gyro/rotation), WiFi (RTT/RSSI untuk diagnostik), deteksi lokasi palsu (mock).
- Update realtime melalui event `onPositionUpdate` dengan debounce dan threshold perubahan posisi/kecepatan/arah.
- getLatestPosition: mencoba satu kali pembacaan “fresh” (oneshot) lalu fallback ke snapshot terakhir bila gagal/timeout.
- Opsi prediksi maju (deadreckoning) opsional untuk memproyeksikan posisi pendek ke depan.
---
## API Publik (TypeScript)
Tipe didefinisikan di `dumon-geolocation/src/definitions.ts`.
### Interfaces
```ts
export interface PositioningData {
source: 'GNSS' | 'WIFI' | 'FUSED' | 'MOCK';
timestamp: number; // epoch ms
latitude: number;
longitude: number;
accuracy: number; // meter
speed: number; // m/s (dari IMU heuristik)
acceleration: number; // m/s^2 (magnitude IMU)
directionRad: number; // radian, relatif utara
isMocked: boolean; // lokasi terdeteksi palsu
predicted?: boolean; // true jika posisi diproyeksikan ke depan
}
export interface SatelliteStatus {
satellitesInView: number;
usedInFix: number;
constellationCounts: { [key: string]: number };
}
export interface DumonGeoOptions {
distanceThresholdMeters?: number; // default ~7.0
speedChangeThreshold?: number; // default ~0.5 m/s
directionChangeThreshold?: number; // default ~0.17 rad (~10°)
emitDebounceMs?: number; // default adaptif; awal 1000 ms
drivingEmitIntervalMs?: number; // default ~1600 ms
wifiScanIntervalMs?: number; // default ~3000 ms
enableWifiRtt?: boolean; // default true
enableLogging?: boolean; // default off → bisa on lewat options
enableForwardPrediction?: boolean; // default false
maxPredictionSeconds?: number; // default 1.0, clamp 0..5
emitGnssStatus?: boolean; // default false
suppressMockedUpdates?: boolean; // default false
keepScreenOn?: boolean; // default false
backgroundPollingIntervalMs?: number; // Android: interval polling service (default 5000)
backgroundPostMinDistanceMeters?: number; // Android: minimum perpindahan untuk POST (default 10m)
backgroundPostMinAccuracyMeters?: number; // Android: minimum akurasi fix untuk POST (meter); kosong = tidak dibatasi
backgroundMinPostIntervalMs?: number; // Android: interval minimum antar upaya POST (default ~10000 ms)
}
export interface PermissionStatus {
location: 'granted' | 'denied';
wifi: 'granted' | 'denied';
}
```
### Methods
```ts
startPositioning(): Promise<void>
```
- Mulai GNSS, IMU, dan WiFi scanning periodik (RTT opsional). Mengaktifkan loop emisi posisi.
```ts
stopPositioning(): Promise<void>
```
- Hentikan semua sensor dan emisi update.
```ts
getLatestPosition(): Promise<PositioningData>
```
- Mencoba mengambil 1x lokasi “fresh” (oneshot) dari provider (GPS + opsional Network) dengan timeout singkat (~3000 ms). Jika gagal/timeout atau disupress (mock), fallback ke snapshot terakhir yang tersimpan.
```ts
checkAndRequestPermissions(): Promise<PermissionStatus>
```
- Memeriksa dan (bila perlu) meminta semua izin lokasi/WiFi yang diperlukan.
```ts
setOptions(options: DumonGeoOptions): Promise<void>
```
- Mengubah opsi runtime (threshold, interval, logging, prediksi, GNSS status, WiFi RTT, dll). Sebagian opsi langsung mempengaruhi perilaku GPS polling dan loop emisi.
```ts
setGpsMode(options: { mode: 'normal' | 'driving' }): Promise<void>
```
- `normal`: polling adaptif (hemat baterai). `driving`: continuous GPS + loop emisi paksa berkala.
```ts
configureEdgeToEdge(options: { bgColor: string; style: 'DARK' | 'LIGHT'; overlay?: boolean }): Promise<void>
```
- Mengatur status bar/navigation bar agar sesuai tema UI.
```ts
getGnssStatus(): Promise<SatelliteStatus | null>
```
- Mengambil status satelit terakhir (untuk diagnostik). Listener realtime tersedia jika `emitGnssStatus` diaktifkan.
```ts
getLocationServicesStatus(): Promise<{ gpsEnabled: boolean; networkEnabled: boolean }>
```
- Memeriksa apakah provider GNSS dan Network aktif di perangkat.
```ts
// Android only — background tracking via Foreground Service
startBackgroundTracking(options?: { title?: string; text?: string; channelId?: string; channelName?: string; postUrl?: string }): Promise<void>
stopBackgroundTracking(): Promise<void>
isBackgroundTrackingActive(): Promise<{ active: boolean }>
getBackgroundLatestPosition(): Promise<PositioningData | null>
setAuthTokens(tokens: { accessToken: string; refreshToken: string }): Promise<void>
clearAuthTokens(): Promise<void>
getAuthState(): Promise<{ present: boolean }>
setBackgroundPostUrl(options: { url?: string }): Promise<void>
getBackgroundPostUrl(): Promise<{ url: string | null }>
```
- Menjalankan tracking lokasi saat app dipause menggunakan Foreground Service. Menyimpan latest fix ke penyimpanan lokal agar bisa diambil saat kembali ke foreground. Opsi `postUrl` (opsional) akan mengirim setiap pembaruan lokasi via HTTP POST (JSON) ke endpoint tersebut. Untuk autentikasi header, panggil `setAuthTokens` (menyimpan token secara native) agar setiap POST menyertakan header `Authorization: Bearer <token>` dan `refresh-token: <token>`. Di web: noop.
### Events
```ts
addListener('onPositionUpdate', (data: PositioningData) => void)
```
- Menerima update posisi realtime dengan debounce dan threshold perubahan. Di mode `driving`, emisi dipaksa periodik (default ~1600 ms) agar UI responsif saat bergerak cepat.
```ts
addListener('onGnssStatus', (data: SatelliteStatus) => void)
```
- Mengalirkan status GNSS jika `emitGnssStatus: true` diaktifkan.
---
## Perilaku & State Internal (Android)
Implementasi utama: `android/src/main/java/com/dumon/plugin/geolocation/DumonGeolocation.kt`.
### State penting (ringkas)
- `latestLatitude/Longitude/Accuracy/Source/Timestamp`: snapshot posisi terakhir dari GNSS.
- `latestImu: ImuData?`: speed, acceleration, directionRad dari IMU (heuristik, dismoothing).
- `isMockedLocation`: flag hasil deteksi mock dari OS.
- `prevLatitude/Longitude`, `prevSpeed`, `prevDirection`: baseline untuk menentukan perubahan signifikan.
- Threshold dan interval:
- `significantChangeThreshold` (meter), `speedChangeThreshold` (m/s), `directionChangeThreshold` (rad).
- `emitIntervalMs`: base debounce; diatur adaptif via kecepatan IMU.
- `drivingEmitIntervalMs`: interval emisi paksa di mode `driving`.
- Opsi runtime:
- `enableWifiRtt`, `enableForwardPrediction`, `maxPredictionSeconds`, `emitGnssStatus`, `suppressMockedUpdates`, `keepScreenOn`.
- Mode GPS: `NORMAL` (polling) vs `DRIVING` (continuous + emit paksa).
### Sumber data
- GNSS: `GpsStatusManager`
- Mode polling adaptif (memicu request single update secara berkala) atau continuous (mode `driving`).
- `requestSingleFix(timeoutMs, useNetworkProvider)` untuk oneshot lokasi dengan timeout.
- Meneruskan status satelit via callback.
- IMU: `ImuSensorManager`
- Menggabungkan accelerometer, gyroscope, rotation vector untuk estimasi `speed`, `acceleration`, `directionRad`.
- Menyetel delay sensor dan interval emisi adaptif berdasarkan kecepatan.
- WiFi: `WifiPositioningManager`
- Scan periodik; jika RTT didukung dan diaktifkan, akan mencoba pengukuran jarak AP (diagnostik). Data WiFi saat ini belum dipakai untuk menghitung posisi final; menjadi sinyal tambahan di update.
### Emisi update posisi
- `emitPositionUpdate()` dipicu oleh perubahan GNSS/IMU/WiFi dengan aturan:
- Hanya emit jika lewat debounce dan terjadi perubahan signifikan: jarak, selisih speed, atau selisih arah.
- Di mode `driving`, ada loop yang memaksa emisi periodik supaya UI tetap responsif.
- Jika `suppressMockedUpdates` aktif dan lokasi terdeteksi mock, emisi dibatalkan.
### Prediksi maju (opsional)
- Jika `enableForwardPrediction` aktif dan ada IMU, posisi dapat diproyeksikan pendek (<= `maxPredictionSeconds`) memakai `speed` dan `directionRad` → flag `predicted: true`.
- `source` tetap diisi nilai asli (mis. `GNSS`), bukan `FUSED`.
### getLatestPosition (perilaku terbaru)
- Memicu `GpsStatusManager.requestSingleFix()` (GPS + opsional Network) dengan timeout default ~3000 ms.
- Jika berhasil dan tidak disupress (mock), update state lalu kembalikan hasil `buildPositionData()`.
- Jika timeout atau disupress, fallback ke snapshot terakhir.
---
## Driving Mode vs Normal Mode (Android)
Perbedaan inti antara kedua mode ini ada pada cara GPS diakses dan bagaimana event dikirim ke JS.
- Pola akses GPS
- Driving: continuous updates (GPS selalu aktif dengan requestLocationUpdates 0 ms/0 m). Cocok untuk navigasi realtime dan pergerakan cepat.
- Normal: polling periodik (oneshot tiap interval) dengan handler internal. Lebih hemat baterai.
- Emisi ke JS
- Driving: ada loop emisi paksa (force emit) menggunakan lokasi yang dibuffer, pada interval `drivingEmitIntervalMs` (default ~1600 ms) agar UI tetap responsif.
- Normal: emisi hanya terjadi bila melewati debounce (`emitIntervalMs`) dan ada perubahan signifikan (jarak/speed/arah) dari baseline sebelumnya.
- Interval & adaptasi
- Driving: diatur oleh `drivingEmitIntervalMs` (ubah via `setOptions`).
- Normal: `emitIntervalMs` diatur adaptif berdasarkan estimasi kecepatan IMU, kirakira:
- > 5 m/s → 3000 ms
- > 1.5 m/s → 8000 ms
- > 0.3 m/s → 20000 ms
- idle → 30000 ms
- Tradeoff
- Driving: latency rendah, konsumsi baterai lebih tinggi.
- Normal: hemat baterai, cukup untuk usecase nonnavigasi.
- Cara ganti mode
- `setGpsMode({ mode: 'driving' })` atau `setGpsMode({ mode: 'normal' })`.
## Permissions (Android)
- Wajib: `ACCESS_FINE_LOCATION` (GNSS), `ACCESS_COARSE_LOCATION` (Network provider/WiFi scan), `ACCESS_WIFI_STATE`, `CHANGE_WIFI_STATE`.
- Android 13+: `NEARBY_WIFI_DEVICES` untuk RTT.
- Disarankan: aktifkan Location Services (GPS dan Network) agar oneshot lebih cepat.
Gunakan `checkAndRequestPermissions()` sebelum memulai.
---
## Contoh Penggunaan
### Persiapan dasar
```ts
import { DumonGeolocation } from 'dumon-geolocation';
// 1) Minta izin
await DumonGeolocation.checkAndRequestPermissions();
// 2) Opsi (opsional)
await DumonGeolocation.setOptions({
enableLogging: true,
emitDebounceMs: 1000,
enableForwardPrediction: false,
});
// 3) Listener real-time
const handle = DumonGeolocation.addListener('onPositionUpdate', data => {
console.log('pos:', data);
});
// 4) Mulai
await DumonGeolocation.startPositioning();
// ...
// 5) Stop bila perlu
await DumonGeolocation.stopPositioning();
await handle.remove();
```
### Satu kali pembacaan (fresh + fallback)
```ts
const data = await DumonGeolocation.getLatestPosition();
console.log('latest:', data);
// Catatan: bisa menunggu ~3s untuk one-shot fix,
// lalu jatuh ke snapshot kalau timeout.
```
### Mode GPS
```ts
await DumonGeolocation.setGpsMode({ mode: 'driving' }); // continuous + emit paksa
// ...
await DumonGeolocation.setGpsMode({ mode: 'normal' }); // polling adaptif
```
### GNSS status dan layanan lokasi
```ts
await DumonGeolocation.setOptions({ emitGnssStatus: true });
const gnssHandle = DumonGeolocation.addListener('onGnssStatus', s => console.log('gnss:', s));
const services = await DumonGeolocation.getLocationServicesStatus();
console.log('services:', services);
```
### UI EdgetoEdge
```ts
await DumonGeolocation.configureEdgeToEdge({
bgColor: '#ffffff',
style: 'DARK',
overlay: false,
});
```
### Background tracking (Android)
```ts
// Minta izin lokasi dan notifikasi (Android 13+) lebih dulu
await DumonGeolocation.checkAndRequestPermissions();
// Mulai Foreground Service untuk tracking di background
await DumonGeolocation.startBackgroundTracking({
title: 'Dumon tracking active',
text: 'Collecting location in background',
// opsional: posting otomatis setiap update lokasi (JSON)
postUrl: 'https://dumonapp.com/dev-test-cap',
});
// Cek status dan tarik latest fix yang disimpan oleh service
const { active } = await DumonGeolocation.isBackgroundTrackingActive();
const latestBg = await DumonGeolocation.getBackgroundLatestPosition();
// Berhenti
await DumonGeolocation.stopBackgroundTracking();
// Opsi: set token autentikasi dan URL endpoint secara terpisah (dapat diubah kapan saja)
await DumonGeolocation.setAuthTokens({ accessToken: '<ACCESS>', refreshToken: '<REFRESH>' });
await DumonGeolocation.setBackgroundPostUrl({ url: 'https://dumonapp.com/dev-test-cap' });
// Kirim otomatis hanya jika perpindahan >= 10 meter (default 10 m); atur interval polling 5 detik
await DumonGeolocation.setOptions({ backgroundPostMinDistanceMeters: 10, backgroundPollingIntervalMs: 5000 });
// (Opsional) Tambah pembatasan akurasi & interval minimum antar POST
await DumonGeolocation.setOptions({ backgroundPostMinAccuracyMeters: 50, backgroundMinPostIntervalMs: 10000 });
// Catatan perilaku background:
// - Service melakukan polling oneshot fix periodik dan menyimpan latest fix.
// - Posting ke REST API hanya terjadi jika bergerak minimal 'backgroundPostMinDistanceMeters' dari lokasi terakhir yang sukses diposting.
// - Jika GPS lemah (mis. indoor), service juga mencoba NETWORK provider saat polling agar tetap ada pembaruan.
// - Token Authorization/refreshtoken diambil dari penyimpanan native yang diisi via setAuthTokens().
```
---
## Catatan & Batasan
- Event JS tidak berjalan saat app dipause/background. Untuk update background yang andal, gunakan Foreground Service native (rencana peningkatan berikutnya).
- Prediksi maju bersifat heuristik (berbasis IMU); aktifkan hanya bila sesuai kebutuhan.
- WiFi RTT dibatasi oleh hardware/OS; di background, kemampuan scan bisa dibatasi OS.
- Web: stub (mengembalikan data dummy) sehingga hanya bermanfaat untuk pengembangan UI.
---
## Rencana Peningkatan (arah)
- Background tracking via Foreground Service (lokasi dan penyimpanan/telemetri saat app dipause).
- Opsi konfigurasi timeouts/strategi oneshot getLatestPosition via `setOptions`.
- Integrasi Fused Location Provider untuk TTFF/efisiensi lebih baik.
---
## Build & Run (Example App)
Plugin build
```
cd dumon-geolocation
npm install
npm run build
```
Example app
```
cd dumon-geolocation/example-app
npm install
npm run build // <-- untuk update pack ke dist
npx cap sync android
npx cap run android
```
Alternatif: `npx cap open android` lalu Run dari Android Studio.
---
## Referensi File Penting
- Plugin Android: `dumon-geolocation/android/src/main/java/com/dumon/plugin/geolocation/DumonGeolocation.kt`
- GNSS Manager: `dumon-geolocation/android/src/main/java/com/dumon/plugin/geolocation/gps/GpsStatusManager.kt`
- IMU Manager: `dumon-geolocation/android/src/main/java/com/dumon/plugin/geolocation/imu/ImuSensorManager.kt`
- WiFi Manager: `dumon-geolocation/android/src/main/java/com/dumon/plugin/geolocation/wifi/WifiPositioningManager.kt`
- Definisi TS: `dumon-geolocation/src/definitions.ts`
- Contoh App: `dumon-geolocation/example-app`

View File

@ -10,3 +10,12 @@ To run the provided example, you can use `npm start` command.
```bash ```bash
npm start npm start
``` ```
### Notes on getLatestPosition()
- The example now reflects the plugin's updated behavior: `getLatestPosition()` attempts a fresh one-shot location fix and falls back to the last cached snapshot on failure/timeout.
- The UI logs additional diagnostics:
- `_elapsedMs`: total call duration (includes waiting up to ~3000ms for a fresh fix).
- `_ageMs`: `Date.now() - timestamp` of the returned position.
- `_guessedFresh`: a simple heuristic based on the two numbers above to hint whether the result is likely fresh.
- Make sure to grant location permissions and enable location services for meaningful results.

View File

@ -13,6 +13,8 @@
font-family: sans-serif; font-family: sans-serif;
margin: 1rem; margin: 1rem;
background-color: #ffffff; background-color: #ffffff;
padding-top: 56px;
padding-bottom: 56px;
} }
h1 { h1 {
@ -71,6 +73,10 @@
<label>emitDebounceMs <input type="number" id="optEmitDebounceMs" placeholder="1000" /></label><br /> <label>emitDebounceMs <input type="number" id="optEmitDebounceMs" placeholder="1000" /></label><br />
<label>drivingEmitIntervalMs <input type="number" id="optDrivingEmitIntervalMs" placeholder="1600" /></label><br /> <label>drivingEmitIntervalMs <input type="number" id="optDrivingEmitIntervalMs" placeholder="1600" /></label><br />
<label>wifiScanIntervalMs <input type="number" id="optWifiScanIntervalMs" placeholder="3000" /></label><br /> <label>wifiScanIntervalMs <input type="number" id="optWifiScanIntervalMs" placeholder="3000" /></label><br />
<label>backgroundPollingIntervalMs <input type="number" id="optBgIntervalMs" placeholder="5000" /></label><br />
<label>backgroundPostMinDistanceMeters <input type="number" step="0.1" id="optBgPostMinDist" placeholder="10.0" /></label><br />
<label>backgroundPostMinAccuracyMeters <input type="number" step="0.1" id="optBgPostMinAcc" placeholder="" /></label><br />
<label>backgroundMinPostIntervalMs <input type="number" id="optBgMinPostInterval" placeholder="10000" /></label><br />
</div> </div>
<div> <div>
<label>distanceThresholdMeters <input type="number" step="0.1" id="optDistanceThreshold" placeholder="7.0" /></label><br /> <label>distanceThresholdMeters <input type="number" step="0.1" id="optDistanceThreshold" placeholder="7.0" /></label><br />
@ -85,6 +91,27 @@
<button id="getGnssStatusButton">Get GNSS Status</button> <button id="getGnssStatusButton">Get GNSS Status</button>
<button id="getLocationServicesStatusButton">Get Location Services Status</button> <button id="getLocationServicesStatusButton">Get Location Services Status</button>
<h1>Background</h1>
<button id="startBgBtn">Start Background Tracking</button>
<button id="stopBgBtn">Stop Background Tracking</button>
<button id="getBgLatestBtn">Get Background Latest Position</button>
<button id="quickStartBgBtn" style="background-color:#00695c">Quick Start BG Posting</button>
<div style="margin-top:0.5rem">
<button id="openBgPermBtn" style="background-color:#795548">Open Background Permission Settings</button>
<button id="openNotifPermBtn" style="background-color:#795548">Open Notification Settings</button>
</div>
<div style="margin-top:0.5rem">
<label>Background POST URL <input type="text" id="bgPostUrl" placeholder="https://dumonapp.com/dev-test-cap" style="width:420px"/></label>
</div>
<div style="margin-top:0.5rem">
<label>Access Token <input type="text" id="bgAccessToken" placeholder="<ACCESS_TOKEN>" style="width:360px"/></label>
<label>Refresh Token <input type="text" id="bgRefreshToken" placeholder="<REFRESH_TOKEN>" style="width:360px"/></label>
<button id="saveTokensBtn" style="background-color:#8e24aa">Save Tokens</button>
<button id="clearTokensBtn" style="background-color:#8e24aa">Clear Tokens</button>
<button id="getAuthStateBtn" style="background-color:#8e24aa">Get Auth State</button>
<button id="getPostUrlBtn" style="background-color:#8e24aa">Get Post URL</button>
</div>
<pre id="logArea"></pre> <pre id="logArea"></pre>
<script type="module" src="./js/example.js"></script> <script type="module" src="./js/example.js"></script>

View File

@ -49,6 +49,10 @@ async function applyOptionsFromForm() {
const emitDebounceMs = readNumber('optEmitDebounceMs'); const emitDebounceMs = readNumber('optEmitDebounceMs');
const drivingEmitIntervalMs = readNumber('optDrivingEmitIntervalMs'); const drivingEmitIntervalMs = readNumber('optDrivingEmitIntervalMs');
const wifiScanIntervalMs = readNumber('optWifiScanIntervalMs'); const wifiScanIntervalMs = readNumber('optWifiScanIntervalMs');
const backgroundPollingIntervalMs = readNumber('optBgIntervalMs');
const backgroundPostMinDistanceMeters = readNumber('optBgPostMinDist');
const backgroundPostMinAccuracyMeters = readNumber('optBgPostMinAcc');
const backgroundMinPostIntervalMs = readNumber('optBgMinPostInterval');
const distanceThresholdMeters = readNumber('optDistanceThreshold'); const distanceThresholdMeters = readNumber('optDistanceThreshold');
const speedChangeThreshold = readNumber('optSpeedThreshold'); const speedChangeThreshold = readNumber('optSpeedThreshold');
const directionChangeThreshold = readNumber('optDirectionThreshold'); const directionChangeThreshold = readNumber('optDirectionThreshold');
@ -57,6 +61,10 @@ async function applyOptionsFromForm() {
if (emitDebounceMs !== undefined) options.emitDebounceMs = emitDebounceMs; if (emitDebounceMs !== undefined) options.emitDebounceMs = emitDebounceMs;
if (drivingEmitIntervalMs !== undefined) options.drivingEmitIntervalMs = drivingEmitIntervalMs; if (drivingEmitIntervalMs !== undefined) options.drivingEmitIntervalMs = drivingEmitIntervalMs;
if (wifiScanIntervalMs !== undefined) options.wifiScanIntervalMs = wifiScanIntervalMs; if (wifiScanIntervalMs !== undefined) options.wifiScanIntervalMs = wifiScanIntervalMs;
if (backgroundPollingIntervalMs !== undefined) options.backgroundPollingIntervalMs = backgroundPollingIntervalMs;
if (backgroundPostMinDistanceMeters !== undefined) options.backgroundPostMinDistanceMeters = backgroundPostMinDistanceMeters;
if (backgroundPostMinAccuracyMeters !== undefined) options.backgroundPostMinAccuracyMeters = backgroundPostMinAccuracyMeters;
if (backgroundMinPostIntervalMs !== undefined) options.backgroundMinPostIntervalMs = backgroundMinPostIntervalMs;
if (distanceThresholdMeters !== undefined) if (distanceThresholdMeters !== undefined)
options.distanceThresholdMeters = distanceThresholdMeters; options.distanceThresholdMeters = distanceThresholdMeters;
if (speedChangeThreshold !== undefined) options.speedChangeThreshold = speedChangeThreshold; if (speedChangeThreshold !== undefined) options.speedChangeThreshold = speedChangeThreshold;
@ -112,11 +120,33 @@ async function stopGeolocation() {
} }
async function getLatestPosition() { async function getLatestPosition() {
const btn = document.getElementById('getLatestButton');
const originalText = btn ? btn.textContent : '';
if (btn) {
btn.disabled = true;
btn.textContent = 'Getting…';
}
const t0 = performance.now();
try { try {
const data = await DumonGeolocation.getLatestPosition(); const result = await DumonGeolocation.getLatestPosition();
appendLog('getLatestPosition', data); const t1 = performance.now();
const elapsedMs = Math.round(t1 - t0);
const now = Date.now();
const ageMs = typeof result?.timestamp === 'number' ? Math.max(0, now - result.timestamp) : undefined;
const guessedFresh = typeof ageMs === 'number' ? (elapsedMs < 2900 && ageMs < 2500) : undefined;
appendLog('getLatestPosition', {
...result,
_elapsedMs: elapsedMs, // how long the call took (includes single-fix wait/timeout)
_ageMs: ageMs, // now - result.timestamp
_guessedFresh: guessedFresh,
});
} catch (err) { } catch (err) {
appendLog('getLatestPosition', { error: err.message }); appendLog('getLatestPosition', { error: err?.message || String(err) });
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = originalText;
}
} }
} }
@ -174,4 +204,128 @@ window.addEventListener('DOMContentLoaded', async () => {
style: 'DARK', style: 'DARK',
overlay: false, overlay: false,
}); });
// Background tracking controls
document.getElementById('startBgBtn').addEventListener('click', async () => {
try {
const postUrl = document.getElementById('bgPostUrl')?.value?.trim();
await DumonGeolocation.startBackgroundTracking({
title: 'Dumon tracking active',
text: 'Collecting location in background',
postUrl: postUrl || undefined,
});
appendLog('startBackgroundTracking', { success: true });
} catch (err) {
appendLog('startBackgroundTracking', { error: err.message });
}
});
document.getElementById('stopBgBtn').addEventListener('click', async () => {
try {
await DumonGeolocation.stopBackgroundTracking();
appendLog('stopBackgroundTracking', { success: true });
} catch (err) {
appendLog('stopBackgroundTracking', { error: err.message });
}
});
document.getElementById('getBgLatestBtn').addEventListener('click', async () => {
try {
const active = await DumonGeolocation.isBackgroundTrackingActive();
const latest = await DumonGeolocation.getBackgroundLatestPosition();
appendLog('backgroundStatus', { active });
appendLog('getBackgroundLatestPosition', latest);
} catch (err) {
appendLog('getBackgroundLatestPosition', { error: err.message });
}
});
document.getElementById('openBgPermBtn').addEventListener('click', async () => {
try {
await DumonGeolocation.openBackgroundPermissionSettings();
appendLog('openBackgroundPermissionSettings', { opened: true });
} catch (err) {
appendLog('openBackgroundPermissionSettings', { error: err.message });
}
});
document.getElementById('openNotifPermBtn').addEventListener('click', async () => {
try {
await DumonGeolocation.openNotificationPermissionSettings();
appendLog('openNotificationPermissionSettings', { opened: true });
} catch (err) {
appendLog('openNotificationPermissionSettings', { error: err.message });
}
});
// Quick Start: set tokens + endpoint + start BG service
document.getElementById('quickStartBgBtn').addEventListener('click', async () => {
const accessToken = document.getElementById('bgAccessToken')?.value?.trim();
const refreshToken = document.getElementById('bgRefreshToken')?.value?.trim();
const postUrl = document.getElementById('bgPostUrl')?.value?.trim();
if (!accessToken || !refreshToken || !postUrl) {
appendLog('quickStartBackground', { error: 'accessToken, refreshToken, and postUrl are required' });
return;
}
try {
await DumonGeolocation.setAuthTokens({ accessToken, refreshToken });
await DumonGeolocation.setBackgroundPostUrl({ url: postUrl });
await DumonGeolocation.startBackgroundTracking({
title: 'Dumon tracking active',
text: 'Collecting location in background',
});
const active = await DumonGeolocation.isBackgroundTrackingActive();
const auth = await DumonGeolocation.getAuthState();
const url = await DumonGeolocation.getBackgroundPostUrl();
appendLog('quickStartBackground', { success: true, active, auth, url });
} catch (err) {
appendLog('quickStartBackground', { error: err.message });
}
});
// Helpers to inspect stored auth and post URL
document.getElementById('getAuthStateBtn').addEventListener('click', async () => {
try {
const state = await DumonGeolocation.getAuthState();
appendLog('getAuthState', state);
} catch (err) {
appendLog('getAuthState', { error: err.message });
}
});
document.getElementById('getPostUrlBtn').addEventListener('click', async () => {
try {
const url = await DumonGeolocation.getBackgroundPostUrl();
appendLog('getBackgroundPostUrl', url);
} catch (err) {
appendLog('getBackgroundPostUrl', { error: err.message });
}
});
// Tokens management
document.getElementById('saveTokensBtn').addEventListener('click', async () => {
const accessToken = document.getElementById('bgAccessToken')?.value?.trim();
const refreshToken = document.getElementById('bgRefreshToken')?.value?.trim();
if (!accessToken || !refreshToken) {
appendLog('setAuthTokens', { error: 'Both accessToken and refreshToken are required' });
return;
}
try {
await DumonGeolocation.setAuthTokens({ accessToken, refreshToken });
const state = await DumonGeolocation.getAuthState();
appendLog('setAuthTokens', { success: true, state });
} catch (err) {
appendLog('setAuthTokens', { error: err.message });
}
});
document.getElementById('clearTokensBtn').addEventListener('click', async () => {
try {
await DumonGeolocation.clearAuthTokens();
const state = await DumonGeolocation.getAuthState();
appendLog('clearAuthTokens', { success: true, state });
} catch (err) {
appendLog('clearAuthTokens', { error: err.message });
}
});
}); });

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "dumon-geolocation", "name": "dumon-geolocation",
"version": "0.0.1", "version": "1.0.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dumon-geolocation", "name": "dumon-geolocation",
"version": "0.0.1", "version": "1.0.2",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@capacitor/android": "^7.0.0", "@capacitor/android": "^7.0.0",

View File

@ -84,6 +84,10 @@ export interface DumonGeoOptions {
emitGnssStatus?: boolean; emitGnssStatus?: boolean;
suppressMockedUpdates?: boolean; suppressMockedUpdates?: boolean;
keepScreenOn?: boolean; keepScreenOn?: boolean;
backgroundPollingIntervalMs?: number; // Android background polling interval
backgroundPostMinDistanceMeters?: number; // Android background min distance to post
backgroundPostMinAccuracyMeters?: number; // Android background min acceptable accuracy for POST (meters)
backgroundMinPostIntervalMs?: number; // Android background minimum interval between POST attempts
} }
export interface PermissionStatus { export interface PermissionStatus {
@ -99,6 +103,25 @@ export interface DumonGeolocationPlugin {
setOptions(options: DumonGeoOptions): Promise<void>; setOptions(options: DumonGeoOptions): Promise<void>;
getGnssStatus(): Promise<SatelliteStatus | null>; getGnssStatus(): Promise<SatelliteStatus | null>;
getLocationServicesStatus(): Promise<{ gpsEnabled: boolean; networkEnabled: boolean }>; getLocationServicesStatus(): Promise<{ gpsEnabled: boolean; networkEnabled: boolean }>;
// Background tracking (Android)
startBackgroundTracking(options?: {
title?: string;
text?: string;
channelId?: string;
channelName?: string;
postUrl?: string; // optional: service will POST latest fixes here as JSON
}): Promise<void>;
stopBackgroundTracking(): Promise<void>;
isBackgroundTrackingActive(): Promise<{ active: boolean }>;
getBackgroundLatestPosition(): Promise<PositioningData | null>;
openBackgroundPermissionSettings(): Promise<void>;
openNotificationPermissionSettings(): Promise<void>;
// Auth token management for background posting
setAuthTokens(tokens: { accessToken: string; refreshToken: string }): Promise<void>;
clearAuthTokens(): Promise<void>;
getAuthState(): Promise<{ present: boolean }>;
setBackgroundPostUrl(options: { url?: string }): Promise<void>;
getBackgroundPostUrl(): Promise<{ url: string | null }>;
configureEdgeToEdge(options: { configureEdgeToEdge(options: {
bgColor: string; bgColor: string;

View File

@ -57,4 +57,55 @@ export class DumonGeolocationWeb extends WebPlugin {
// Web stub; assume enabled // Web stub; assume enabled
return { gpsEnabled: true, networkEnabled: true }; return { gpsEnabled: true, networkEnabled: true };
} }
// Background tracking stubs (no-op on web)
async startBackgroundTracking(_options?: {
title?: string;
text?: string;
channelId?: string;
channelName?: string;
postUrl?: string;
}): Promise<void> {
console.info('[dumon-geolocation] startBackgroundTracking is not supported on web.');
}
async stopBackgroundTracking(): Promise<void> {
console.info('[dumon-geolocation] stopBackgroundTracking is not supported on web.');
}
async isBackgroundTrackingActive(): Promise<{ active: boolean }> {
return { active: false };
}
async getBackgroundLatestPosition(): Promise<PositioningData | null> {
return null;
}
async openBackgroundPermissionSettings(): Promise<void> {
console.info('[dumon-geolocation] openBackgroundPermissionSettings is not supported on web.');
}
async openNotificationPermissionSettings(): Promise<void> {
console.info('[dumon-geolocation] openNotificationPermissionSettings is not supported on web.');
}
async setAuthTokens(_tokens: { accessToken: string; refreshToken: string }): Promise<void> {
console.info('[dumon-geolocation] setAuthTokens is a no-op on web.');
}
async clearAuthTokens(): Promise<void> {
console.info('[dumon-geolocation] clearAuthTokens is a no-op on web.');
}
async getAuthState(): Promise<{ present: boolean }> {
return { present: false };
}
async setBackgroundPostUrl(_options: { url?: string }): Promise<void> {
console.info('[dumon-geolocation] setBackgroundPostUrl is not supported on web.');
}
async getBackgroundPostUrl(): Promise<{ url: string | null }> {
return { url: null };
}
} }