20260108-01
This commit is contained in:
parent
0c141c88ad
commit
2575acb553
2
.gitignore
vendored
2
.gitignore
vendored
@ -14,6 +14,8 @@ xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/configuration/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
example-app/ios/App/App/public
|
||||
example-app/ios/App/App/capacitor.config.json
|
||||
.netrc
|
||||
.history
|
||||
|
||||
|
||||
@ -13,12 +13,44 @@ This guide provides instructions for contributing to this Capacitor plugin.
|
||||
npm install
|
||||
```
|
||||
|
||||
1. Install iOS dependencies (macOS only).
|
||||
|
||||
```shell
|
||||
cd example-app/ios/App
|
||||
pod install
|
||||
```
|
||||
|
||||
1. Install SwiftLint if you're on macOS.
|
||||
|
||||
```shell
|
||||
brew install swiftlint
|
||||
```
|
||||
|
||||
### Example App (iOS Simulator)
|
||||
|
||||
1. Sync native projects.
|
||||
|
||||
```shell
|
||||
cd example-app
|
||||
npx cap sync ios
|
||||
```
|
||||
|
||||
1. Build for a simulator device.
|
||||
|
||||
```shell
|
||||
xcodebuild -workspace ios/App/App.xcworkspace -scheme App -destination "id=<SIMULATOR_UDID>" build
|
||||
```
|
||||
|
||||
1. Install and launch on the simulator.
|
||||
|
||||
```shell
|
||||
xcrun simctl boot <SIMULATOR_UDID>
|
||||
xcrun simctl install <SIMULATOR_UDID> <PATH_TO_App.app>
|
||||
xcrun simctl launch <SIMULATOR_UDID> com.example.plugin
|
||||
```
|
||||
|
||||
> **Note**: The example app already includes the required location permission keys in its Info.plist. If you add a new app target, ensure those keys exist.
|
||||
|
||||
### Scripts
|
||||
|
||||
#### `npm run build`
|
||||
|
||||
60
README.md
60
README.md
@ -1,6 +1,6 @@
|
||||
# dumon-geolocation
|
||||
|
||||
Capacitor Plugin untuk Android yang menyediakan **real-time high-accuracy positioning** menggunakan kombinasi:
|
||||
Capacitor plugin dengan target utama Android dan dukungan iOS (dengan keterbatasan), menyediakan **real-time high-accuracy positioning** menggunakan kombinasi:
|
||||
- 📡 **GNSS multi-konstelasi** (GPS, GLONASS, BeiDou, Galileo)
|
||||
- 📶 **Wi-Fi RTT / RSSI**
|
||||
- 🎯 **IMU Sensor** (Accelerometer, Gyroscope, Rotation)
|
||||
@ -29,6 +29,7 @@ npx cap sync
|
||||
|
||||
## Permissions
|
||||
|
||||
### Android
|
||||
Plugin ini memerlukan izin berikut:
|
||||
- ACCESS_FINE_LOCATION
|
||||
- ACCESS_COARSE_LOCATION
|
||||
@ -38,6 +39,9 @@ Plugin ini memerlukan izin berikut:
|
||||
|
||||
Gunakan API checkAndRequestPermissions() sebelum memulai positioning.
|
||||
|
||||
### iOS
|
||||
Tambahkan entri Info.plist (lihat bagian iOS Requirements).
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
@ -92,7 +96,7 @@ configureEdgeToEdge(options: {
|
||||
}): Promise<void>
|
||||
```
|
||||
|
||||
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. (Android-only, no-op di iOS)
|
||||
|
||||
### setGpsMode()
|
||||
|
||||
@ -121,6 +125,11 @@ setOptions(options: {
|
||||
emitGnssStatus?: boolean;
|
||||
suppressMockedUpdates?: boolean;
|
||||
keepScreenOn?: boolean;
|
||||
backgroundPollingIntervalMs?: number;
|
||||
backgroundPostMinDistanceMeters?: number;
|
||||
backgroundPostMinAccuracyMeters?: number;
|
||||
backgroundMinPostIntervalMs?: number;
|
||||
backgroundUseImuFallback?: boolean;
|
||||
}): Promise<void>
|
||||
```
|
||||
|
||||
@ -150,6 +159,36 @@ addListener('onGnssStatus', (data: SatelliteStatus) => void): PluginListenerHand
|
||||
|
||||
Menerima update status GNSS jika `emitGnssStatus: true` di setOptions.
|
||||
|
||||
### Background Tracking (Android + iOS)
|
||||
|
||||
```typescript
|
||||
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>
|
||||
```
|
||||
|
||||
Catatan: pada iOS, `title/text/channelId/channelName` diabaikan; `postUrl` tetap didukung.
|
||||
|
||||
### Auth & Post URL (Android + iOS)
|
||||
|
||||
```typescript
|
||||
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 }>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Interfaces
|
||||
@ -240,12 +279,27 @@ interface WifiAp {
|
||||
|
||||
## Catatan
|
||||
|
||||
- Hanya mendukung platform Android.
|
||||
- Android sebagai target utama, iOS tersedia dengan keterbatasan (lihat bagian iOS).
|
||||
- Gunakan bersama @capacitor/geolocation jika ingin membandingkan hasil native vs Dumon.
|
||||
- Posisi dipancarkan setiap 1000ms atau saat terjadi perubahan signifikan (jarak, kecepatan, arah).
|
||||
- directionRad dalam radian relatif terhadap utara.
|
||||
- Fitur Mock Location Detection aktif secara default.
|
||||
|
||||
## iOS (Status & Keterbatasan)
|
||||
|
||||
Implementasi iOS tersedia dengan API yang sama, tetapi ada keterbatasan platform:
|
||||
- Wi-Fi RTT/RSSI scan tidak tersedia untuk app iOS biasa, sehingga sumber posisi hanya GNSS.
|
||||
- GNSS status (satellite info) tidak tersedia di iOS; `getGnssStatus()` akan mengembalikan objek kosong.
|
||||
- Mock location detection terbatas; hanya tersedia pada iOS 15+ jika sistem menandai simulasi.
|
||||
- Background posting dijalankan best-effort saat background location aktif.
|
||||
|
||||
### iOS Requirements
|
||||
|
||||
Tambahkan entri Info.plist di aplikasi yang memakai plugin:
|
||||
- `NSLocationWhenInUseUsageDescription`
|
||||
- `NSLocationAlwaysAndWhenInUseUsageDescription`
|
||||
- `UIBackgroundModes` dengan value `location` (jika memakai background tracking)
|
||||
|
||||
---
|
||||
|
||||
## Testing (example-app)
|
||||
|
||||
@ -6,7 +6,6 @@ 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
|
||||
@ -22,7 +21,6 @@ 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
|
||||
@ -31,8 +29,6 @@ import android.content.Intent
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.dumon.plugin.geolocation.bg.BackgroundLocationService
|
||||
import com.dumon.plugin.geolocation.utils.BgPrefs
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import kotlin.math.*
|
||||
import android.location.Location
|
||||
import com.dumon.plugin.geolocation.utils.AuthStore
|
||||
@ -80,7 +76,6 @@ class DumonGeolocation : Plugin() {
|
||||
|
||||
private var latestImu: ImuData? = null
|
||||
private var satelliteStatus: SatelliteStatus? = null
|
||||
private var wifiScanResult: WifiScanResult? = null
|
||||
|
||||
private var isMockedLocation = false
|
||||
private var lastEmitTimestamp: Long = 0L
|
||||
@ -100,8 +95,6 @@ class DumonGeolocation : Plugin() {
|
||||
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
|
||||
@ -166,7 +159,6 @@ class DumonGeolocation : Plugin() {
|
||||
wifiManager = WifiPositioningManager(
|
||||
context,
|
||||
onWifiPositioningUpdate = {
|
||||
wifiScanResult = it
|
||||
emitPositionUpdate()
|
||||
}
|
||||
)
|
||||
@ -646,13 +638,6 @@ class DumonGeolocation : Plugin() {
|
||||
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) {
|
||||
|
||||
@ -27,11 +27,4 @@ object PermissionUtils {
|
||||
}
|
||||
}
|
||||
|
||||
fun hasLocationAndWifiPermissions(context: Context): Boolean {
|
||||
return hasLocationPermissions(context) && hasWifiScanPermissions(context)
|
||||
}
|
||||
|
||||
fun getPermissionStatus(granted: Int): String {
|
||||
return if (granted == PackageManager.PERMISSION_GRANTED) "granted" else "denied"
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,5 +45,13 @@
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>We use your location to provide real-time positioning.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>We use your location to provide real-time positioning, even in the background.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>location</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -1,8 +1,459 @@
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
import CoreMotion
|
||||
import UIKit
|
||||
|
||||
@objc public class DumonGeolocation: NSObject {
|
||||
@objc public func echo(_ value: String) -> String {
|
||||
print(value)
|
||||
return value
|
||||
@objc public class DumonGeolocation: NSObject, CLLocationManagerDelegate {
|
||||
public var onPositionUpdate: (([String: Any]) -> Void)?
|
||||
public var onGnssStatus: (([String: Any]) -> Void)?
|
||||
public var onAuthorizationChange: ((CLAuthorizationStatus) -> Void)?
|
||||
|
||||
private let locationManager = CLLocationManager()
|
||||
private let motionManager = CMMotionManager()
|
||||
|
||||
private var latestLocation: CLLocation?
|
||||
private var latestTimestampMs: Double = 0
|
||||
private var latestAcceleration: Double = 0
|
||||
private var latestDirectionRad: Double = 0
|
||||
|
||||
private var prevLocation: CLLocation?
|
||||
private var prevSpeed: Double = 0
|
||||
private var prevDirection: Double = 0
|
||||
private var lastEmitTimestampMs: Double = 0
|
||||
|
||||
private var distanceThresholdMeters: Double = 7.0
|
||||
private var speedChangeThreshold: Double = 0.5
|
||||
private var directionChangeThreshold: Double = 0.17
|
||||
private var emitDebounceMs: Double = 1000
|
||||
private var drivingEmitIntervalMs: Double = 1600
|
||||
|
||||
private var enableForwardPrediction = false
|
||||
private var maxPredictionSeconds: Double = 1.0
|
||||
private var suppressMockedUpdates = false
|
||||
private var keepScreenOn = false
|
||||
|
||||
private var currentMode: String = "normal"
|
||||
private var drivingTimer: Timer?
|
||||
|
||||
private var backgroundTrackingActive = false
|
||||
private var foregroundTrackingActive = false
|
||||
private var backgroundLatestLocation: CLLocation?
|
||||
|
||||
private var backgroundPostMinDistanceMeters: Double = 10.0
|
||||
private var backgroundPostMinAccuracyMeters: Double = Double.greatestFiniteMagnitude
|
||||
private var backgroundMinPostIntervalMs: Double = 10_000
|
||||
private var lastPostAttemptMs: Double = 0
|
||||
private var lastPostedLocation: CLLocation?
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
private let accessTokenKey = "dumon_geo_access_token"
|
||||
private let refreshTokenKey = "dumon_geo_refresh_token"
|
||||
private let backgroundPostUrlKey = "dumon_geo_post_url"
|
||||
|
||||
public override init() {
|
||||
super.init()
|
||||
locationManager.delegate = self
|
||||
locationManager.desiredAccuracy = kCLLocationAccuracyBest
|
||||
locationManager.distanceFilter = distanceThresholdMeters
|
||||
locationManager.activityType = .other
|
||||
locationManager.pausesLocationUpdatesAutomatically = true
|
||||
}
|
||||
|
||||
public func authorizationStatus() -> CLAuthorizationStatus {
|
||||
if #available(iOS 14.0, *) {
|
||||
return locationManager.authorizationStatus
|
||||
}
|
||||
return CLLocationManager.authorizationStatus()
|
||||
}
|
||||
|
||||
public func requestWhenInUsePermission() {
|
||||
locationManager.requestWhenInUseAuthorization()
|
||||
}
|
||||
|
||||
public func requestAlwaysPermission() {
|
||||
locationManager.requestAlwaysAuthorization()
|
||||
}
|
||||
|
||||
public func startPositioning() {
|
||||
foregroundTrackingActive = true
|
||||
applyKeepScreenOn(keepScreenOn)
|
||||
startMotionUpdates()
|
||||
locationManager.startUpdatingLocation()
|
||||
}
|
||||
|
||||
public func stopPositioning() {
|
||||
foregroundTrackingActive = false
|
||||
locationManager.stopUpdatingLocation()
|
||||
stopMotionUpdates()
|
||||
stopDrivingEmitLoop()
|
||||
applyKeepScreenOn(false)
|
||||
}
|
||||
|
||||
public func setOptions(_ options: [String: Any]) {
|
||||
if let v = options["distanceThresholdMeters"] as? Double {
|
||||
distanceThresholdMeters = v
|
||||
if currentMode == "normal" {
|
||||
locationManager.distanceFilter = v
|
||||
}
|
||||
}
|
||||
if let v = options["speedChangeThreshold"] as? Double { speedChangeThreshold = v }
|
||||
if let v = options["directionChangeThreshold"] as? Double { directionChangeThreshold = v }
|
||||
if let v = options["emitDebounceMs"] as? Double { emitDebounceMs = max(0, v) }
|
||||
if let v = options["drivingEmitIntervalMs"] as? Double {
|
||||
drivingEmitIntervalMs = max(200, v)
|
||||
if currentMode == "driving" {
|
||||
stopDrivingEmitLoop()
|
||||
startDrivingEmitLoop()
|
||||
}
|
||||
}
|
||||
if let v = options["enableForwardPrediction"] as? Bool { enableForwardPrediction = v }
|
||||
if let v = options["maxPredictionSeconds"] as? Double { maxPredictionSeconds = min(max(v, 0.0), 5.0) }
|
||||
if let v = options["suppressMockedUpdates"] as? Bool { suppressMockedUpdates = v }
|
||||
if let v = options["keepScreenOn"] as? Bool {
|
||||
keepScreenOn = v
|
||||
applyKeepScreenOn(v)
|
||||
}
|
||||
if let v = options["backgroundPostMinDistanceMeters"] as? Double {
|
||||
backgroundPostMinDistanceMeters = max(0.0, v)
|
||||
}
|
||||
if let v = options["backgroundPostMinAccuracyMeters"] as? Double {
|
||||
backgroundPostMinAccuracyMeters = max(0.0, v)
|
||||
}
|
||||
if let v = options["backgroundMinPostIntervalMs"] as? Double {
|
||||
backgroundMinPostIntervalMs = max(0.0, v)
|
||||
}
|
||||
}
|
||||
|
||||
public func setGpsMode(_ mode: String) {
|
||||
currentMode = mode == "driving" ? "driving" : "normal"
|
||||
if currentMode == "driving" {
|
||||
locationManager.desiredAccuracy = kCLLocationAccuracyBestForNavigation
|
||||
locationManager.distanceFilter = kCLDistanceFilterNone
|
||||
locationManager.activityType = .automotiveNavigation
|
||||
startDrivingEmitLoop()
|
||||
} else {
|
||||
locationManager.desiredAccuracy = kCLLocationAccuracyBest
|
||||
locationManager.distanceFilter = distanceThresholdMeters
|
||||
locationManager.activityType = .other
|
||||
stopDrivingEmitLoop()
|
||||
}
|
||||
}
|
||||
|
||||
public func getLatestPosition() -> [String: Any] {
|
||||
if let location = latestLocation {
|
||||
return buildPositionData(from: location)
|
||||
}
|
||||
return buildDefaultPosition()
|
||||
}
|
||||
|
||||
public func getBackgroundLatestPosition() -> [String: Any]? {
|
||||
guard let location = backgroundLatestLocation else { return nil }
|
||||
return buildPositionData(from: location)
|
||||
}
|
||||
|
||||
public func getGnssStatus() -> [String: Any]? {
|
||||
return nil
|
||||
}
|
||||
|
||||
public func getLocationServicesStatus() -> [String: Any] {
|
||||
let enabled = CLLocationManager.locationServicesEnabled()
|
||||
return ["gpsEnabled": enabled, "networkEnabled": enabled]
|
||||
}
|
||||
|
||||
public func startBackgroundTracking() {
|
||||
backgroundTrackingActive = true
|
||||
if #available(iOS 9.0, *) {
|
||||
locationManager.allowsBackgroundLocationUpdates = true
|
||||
}
|
||||
if #available(iOS 11.0, *) {
|
||||
locationManager.showsBackgroundLocationIndicator = true
|
||||
}
|
||||
locationManager.pausesLocationUpdatesAutomatically = false
|
||||
locationManager.startUpdatingLocation()
|
||||
}
|
||||
|
||||
public func stopBackgroundTracking() {
|
||||
backgroundTrackingActive = false
|
||||
if #available(iOS 9.0, *) {
|
||||
locationManager.allowsBackgroundLocationUpdates = false
|
||||
}
|
||||
if !foregroundTrackingActive {
|
||||
locationManager.pausesLocationUpdatesAutomatically = true
|
||||
locationManager.stopUpdatingLocation()
|
||||
}
|
||||
}
|
||||
|
||||
public func isBackgroundTrackingActive() -> Bool {
|
||||
return backgroundTrackingActive
|
||||
}
|
||||
|
||||
public func setAuthTokens(accessToken: String, refreshToken: String) {
|
||||
defaults.set(accessToken, forKey: accessTokenKey)
|
||||
defaults.set(refreshToken, forKey: refreshTokenKey)
|
||||
}
|
||||
|
||||
public func clearAuthTokens() {
|
||||
defaults.removeObject(forKey: accessTokenKey)
|
||||
defaults.removeObject(forKey: refreshTokenKey)
|
||||
}
|
||||
|
||||
public func getAuthState() -> Bool {
|
||||
return defaults.string(forKey: accessTokenKey) != nil
|
||||
}
|
||||
|
||||
public func setBackgroundPostUrl(_ url: String?) {
|
||||
if let url = url, !url.isEmpty {
|
||||
defaults.set(url, forKey: backgroundPostUrlKey)
|
||||
} else {
|
||||
defaults.removeObject(forKey: backgroundPostUrlKey)
|
||||
}
|
||||
}
|
||||
|
||||
public func getBackgroundPostUrl() -> String? {
|
||||
return defaults.string(forKey: backgroundPostUrlKey)
|
||||
}
|
||||
|
||||
// MARK: - CLLocationManagerDelegate
|
||||
|
||||
public func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
|
||||
onAuthorizationChange?(status)
|
||||
}
|
||||
|
||||
public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard let location = locations.last else { return }
|
||||
latestLocation = location
|
||||
latestTimestampMs = location.timestamp.timeIntervalSince1970 * 1000.0
|
||||
backgroundLatestLocation = location
|
||||
|
||||
if backgroundTrackingActive {
|
||||
postFixIfNeeded(location)
|
||||
}
|
||||
|
||||
emitPositionUpdate(from: location, forceEmit: false)
|
||||
}
|
||||
|
||||
public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
// No-op: keep last known fix
|
||||
_ = error
|
||||
}
|
||||
|
||||
// MARK: - Internal
|
||||
|
||||
private func emitPositionUpdate(from location: CLLocation, forceEmit: Bool) {
|
||||
let nowMs = Date().timeIntervalSince1970 * 1000.0
|
||||
if !forceEmit && (nowMs - lastEmitTimestampMs) < emitDebounceMs {
|
||||
return
|
||||
}
|
||||
|
||||
let isMocked = isSimulated(location)
|
||||
if suppressMockedUpdates && isMocked {
|
||||
return
|
||||
}
|
||||
|
||||
let speedNow = resolvedSpeed(from: location).speed
|
||||
let directionNow = resolvedDirection(from: location)
|
||||
|
||||
let distance = prevLocation.map { location.distance(from: $0) } ?? Double.greatestFiniteMagnitude
|
||||
let isSignificantChange = distance >= distanceThresholdMeters
|
||||
let speedChanged = abs(speedNow - prevSpeed) > speedChangeThreshold
|
||||
let directionChanged = abs(directionNow - prevDirection) > directionChangeThreshold
|
||||
|
||||
if forceEmit || isSignificantChange || speedChanged || directionChanged {
|
||||
prevLocation = location
|
||||
prevSpeed = speedNow
|
||||
prevDirection = directionNow
|
||||
lastEmitTimestampMs = nowMs
|
||||
|
||||
let data = buildPositionData(from: location)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onPositionUpdate?(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func buildDefaultPosition() -> [String: Any] {
|
||||
let nowMs = Date().timeIntervalSince1970 * 1000.0
|
||||
return [
|
||||
"source": "GNSS",
|
||||
"timestamp": nowMs,
|
||||
"latitude": 0.0,
|
||||
"longitude": 0.0,
|
||||
"accuracy": 999.0,
|
||||
"speed": 0.0,
|
||||
"acceleration": latestAcceleration,
|
||||
"directionRad": latestDirectionRad,
|
||||
"isMocked": false,
|
||||
"mode": currentMode,
|
||||
"predicted": false,
|
||||
"speedSource": "NONE"
|
||||
]
|
||||
}
|
||||
|
||||
private func buildPositionData(from location: CLLocation) -> [String: Any] {
|
||||
let nowMs = Date().timeIntervalSince1970 * 1000.0
|
||||
let timestampMs = location.timestamp.timeIntervalSince1970 * 1000.0
|
||||
let fixAgeMs = max(0.0, nowMs - timestampMs)
|
||||
|
||||
let speedInfo = resolvedSpeed(from: location)
|
||||
let directionRad = resolvedDirection(from: location)
|
||||
let accel = latestAcceleration
|
||||
|
||||
var outLat = location.coordinate.latitude
|
||||
var outLon = location.coordinate.longitude
|
||||
var predicted = false
|
||||
|
||||
if enableForwardPrediction && fixAgeMs > 0 {
|
||||
let dtSec = min(fixAgeMs / 1000.0, maxPredictionSeconds)
|
||||
if dtSec > 0 {
|
||||
let speed = speedInfo.speed
|
||||
let dNorth = speed * dtSec * cos(directionRad)
|
||||
let dEast = speed * dtSec * sin(directionRad)
|
||||
let earthRadius = 6371000.0
|
||||
let latRad = outLat * Double.pi / 180.0
|
||||
let dLat = (dNorth / earthRadius) * (180.0 / Double.pi)
|
||||
let dLon = (dEast / (earthRadius * cos(latRad))) * (180.0 / Double.pi)
|
||||
outLat += dLat
|
||||
outLon += dLon
|
||||
predicted = true
|
||||
}
|
||||
}
|
||||
|
||||
var data: [String: Any] = [
|
||||
"source": isSimulated(location) ? "MOCK" : "GNSS",
|
||||
"timestamp": timestampMs,
|
||||
"latitude": outLat,
|
||||
"longitude": outLon,
|
||||
"accuracy": location.horizontalAccuracy,
|
||||
"speed": speedInfo.speed,
|
||||
"acceleration": accel,
|
||||
"directionRad": directionRad,
|
||||
"isMocked": isSimulated(location),
|
||||
"mode": currentMode,
|
||||
"predicted": predicted,
|
||||
"speedSource": speedInfo.source
|
||||
]
|
||||
|
||||
if let gnss = speedInfo.speedGnss {
|
||||
data["speedGnss"] = gnss
|
||||
}
|
||||
if let derived = speedInfo.speedDerived {
|
||||
data["speedDerived"] = derived
|
||||
}
|
||||
|
||||
latestDirectionRad = directionRad
|
||||
return data
|
||||
}
|
||||
|
||||
private func resolvedSpeed(from location: CLLocation) -> (speed: Double, source: String, speedGnss: Double?, speedDerived: Double?) {
|
||||
let nowMs = Date().timeIntervalSince1970 * 1000.0
|
||||
let timestampMs = location.timestamp.timeIntervalSince1970 * 1000.0
|
||||
let fixAgeMs = max(0.0, nowMs - timestampMs)
|
||||
|
||||
let gnssSpeed = location.speed >= 0 ? location.speed : -1
|
||||
let validGnss = gnssSpeed >= 0 && fixAgeMs <= 3000
|
||||
|
||||
let derivedSpeed: Double? = {
|
||||
guard let prev = prevLocation else { return nil }
|
||||
let dtMs = timestampMs - prev.timestamp.timeIntervalSince1970 * 1000.0
|
||||
let dtSec = dtMs / 1000.0
|
||||
if dtSec >= 3.0 && dtSec <= 30.0 {
|
||||
let dist = location.distance(from: prev)
|
||||
return dist / dtSec
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
if validGnss {
|
||||
return (gnssSpeed, "GNSS", gnssSpeed, nil)
|
||||
}
|
||||
if let derived = derivedSpeed {
|
||||
return (derived, "DELTA", nil, derived)
|
||||
}
|
||||
return (0.0, "NONE", nil, nil)
|
||||
}
|
||||
|
||||
private func resolvedDirection(from location: CLLocation) -> Double {
|
||||
if location.course >= 0 {
|
||||
return location.course * Double.pi / 180.0
|
||||
}
|
||||
return latestDirectionRad
|
||||
}
|
||||
|
||||
private func isSimulated(_ location: CLLocation) -> Bool {
|
||||
if #available(iOS 15.0, *) {
|
||||
return location.sourceInformation?.isSimulatedBySoftware ?? false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func startMotionUpdates() {
|
||||
guard motionManager.isDeviceMotionAvailable else { return }
|
||||
motionManager.deviceMotionUpdateInterval = 0.2
|
||||
motionManager.startDeviceMotionUpdates(to: OperationQueue()) { [weak self] motion, _ in
|
||||
guard let motion = motion else { return }
|
||||
let a = motion.userAcceleration
|
||||
let magnitude = sqrt(a.x * a.x + a.y * a.y + a.z * a.z)
|
||||
self?.latestAcceleration = magnitude
|
||||
}
|
||||
}
|
||||
|
||||
private func stopMotionUpdates() {
|
||||
motionManager.stopDeviceMotionUpdates()
|
||||
}
|
||||
|
||||
private func startDrivingEmitLoop() {
|
||||
if drivingTimer != nil { return }
|
||||
let interval = drivingEmitIntervalMs / 1000.0
|
||||
drivingTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
guard let self = self, let location = self.latestLocation else { return }
|
||||
self.emitPositionUpdate(from: location, forceEmit: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopDrivingEmitLoop() {
|
||||
drivingTimer?.invalidate()
|
||||
drivingTimer = nil
|
||||
}
|
||||
|
||||
private func applyKeepScreenOn(_ enabled: Bool) {
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.isIdleTimerDisabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
private func postFixIfNeeded(_ location: CLLocation) {
|
||||
guard let urlString = getBackgroundPostUrl(), let url = URL(string: urlString) else { return }
|
||||
|
||||
let nowMs = Date().timeIntervalSince1970 * 1000.0
|
||||
if nowMs - lastPostAttemptMs < backgroundMinPostIntervalMs {
|
||||
return
|
||||
}
|
||||
|
||||
if location.horizontalAccuracy > backgroundPostMinAccuracyMeters {
|
||||
return
|
||||
}
|
||||
|
||||
if let lastPosted = lastPostedLocation {
|
||||
let dist = location.distance(from: lastPosted)
|
||||
if dist < backgroundPostMinDistanceMeters {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
lastPostAttemptMs = nowMs
|
||||
let payload = buildPositionData(from: location)
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
if let token = defaults.string(forKey: accessTokenKey) {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
request.httpBody = try? JSONSerialization.data(withJSONObject: payload, options: [])
|
||||
|
||||
URLSession.shared.dataTask(with: request) { [weak self] _, _, _ in
|
||||
self?.lastPostedLocation = location
|
||||
}.resume()
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,23 +1,223 @@
|
||||
import Foundation
|
||||
import Capacitor
|
||||
import CoreLocation
|
||||
import UIKit
|
||||
|
||||
/**
|
||||
* Please read the Capacitor iOS Plugin Development Guide
|
||||
* here: https://capacitorjs.com/docs/plugins/ios
|
||||
*/
|
||||
@objc(DumonGeolocationPlugin)
|
||||
public class DumonGeolocationPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public let identifier = "DumonGeolocationPlugin"
|
||||
public let jsName = "DumonGeolocation"
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "echo", returnType: CAPPluginReturnPromise)
|
||||
CAPPluginMethod(name: "startPositioning", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "stopPositioning", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "getLatestPosition", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "checkAndRequestPermissions", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "setOptions", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "getGnssStatus", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "getLocationServicesStatus", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "startBackgroundTracking", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "stopBackgroundTracking", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "isBackgroundTrackingActive", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "getBackgroundLatestPosition", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "openBackgroundPermissionSettings", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "openNotificationPermissionSettings", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "setAuthTokens", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "clearAuthTokens", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "getAuthState", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "setBackgroundPostUrl", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "getBackgroundPostUrl", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "configureEdgeToEdge", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "setGpsMode", returnType: CAPPluginReturnPromise)
|
||||
]
|
||||
private let implementation = DumonGeolocation()
|
||||
|
||||
@objc func echo(_ call: CAPPluginCall) {
|
||||
let value = call.getString("value") ?? ""
|
||||
call.resolve([
|
||||
"value": implementation.echo(value)
|
||||
])
|
||||
private let implementation = DumonGeolocation()
|
||||
private var pendingPermissionCall: CAPPluginCall?
|
||||
private var pendingBackgroundCall: CAPPluginCall?
|
||||
|
||||
public override func load() {
|
||||
implementation.onPositionUpdate = { [weak self] data in
|
||||
self?.notifyListeners("onPositionUpdate", data: data)
|
||||
}
|
||||
implementation.onGnssStatus = { [weak self] data in
|
||||
self?.notifyListeners("onGnssStatus", data: data)
|
||||
}
|
||||
implementation.onAuthorizationChange = { [weak self] status in
|
||||
self?.handleAuthorizationChange(status)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func startPositioning(_ call: CAPPluginCall) {
|
||||
let status = implementation.authorizationStatus()
|
||||
if !isLocationAuthorized(status) {
|
||||
call.reject("Location permission not granted")
|
||||
return
|
||||
}
|
||||
implementation.startPositioning()
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func stopPositioning(_ call: CAPPluginCall) {
|
||||
implementation.stopPositioning()
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func getLatestPosition(_ call: CAPPluginCall) {
|
||||
call.resolve(implementation.getLatestPosition())
|
||||
}
|
||||
|
||||
@objc func checkAndRequestPermissions(_ call: CAPPluginCall) {
|
||||
let status = implementation.authorizationStatus()
|
||||
if status == .notDetermined {
|
||||
pendingPermissionCall = call
|
||||
implementation.requestWhenInUsePermission()
|
||||
return
|
||||
}
|
||||
call.resolve(permissionStatusDict(status))
|
||||
}
|
||||
|
||||
@objc func setOptions(_ call: CAPPluginCall) {
|
||||
var options: [String: Any] = [:]
|
||||
for (key, value) in call.options {
|
||||
if let key = key as? String {
|
||||
options[key] = value
|
||||
}
|
||||
}
|
||||
implementation.setOptions(options)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func getGnssStatus(_ call: CAPPluginCall) {
|
||||
if let data = implementation.getGnssStatus() {
|
||||
call.resolve(data)
|
||||
} else {
|
||||
call.resolve([:])
|
||||
}
|
||||
}
|
||||
|
||||
@objc func getLocationServicesStatus(_ call: CAPPluginCall) {
|
||||
call.resolve(implementation.getLocationServicesStatus())
|
||||
}
|
||||
|
||||
@objc func startBackgroundTracking(_ call: CAPPluginCall) {
|
||||
if let postUrl = call.getString("postUrl") {
|
||||
implementation.setBackgroundPostUrl(postUrl)
|
||||
}
|
||||
let status = implementation.authorizationStatus()
|
||||
if status == .authorizedAlways {
|
||||
implementation.startBackgroundTracking()
|
||||
call.resolve()
|
||||
return
|
||||
}
|
||||
pendingBackgroundCall = call
|
||||
implementation.requestAlwaysPermission()
|
||||
}
|
||||
|
||||
@objc func stopBackgroundTracking(_ call: CAPPluginCall) {
|
||||
implementation.stopBackgroundTracking()
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func isBackgroundTrackingActive(_ call: CAPPluginCall) {
|
||||
call.resolve(["active": implementation.isBackgroundTrackingActive()])
|
||||
}
|
||||
|
||||
@objc func getBackgroundLatestPosition(_ call: CAPPluginCall) {
|
||||
if let data = implementation.getBackgroundLatestPosition() {
|
||||
call.resolve(data)
|
||||
} else {
|
||||
call.resolve([:])
|
||||
}
|
||||
}
|
||||
|
||||
@objc func openBackgroundPermissionSettings(_ call: CAPPluginCall) {
|
||||
openAppSettings(call)
|
||||
}
|
||||
|
||||
@objc func openNotificationPermissionSettings(_ call: CAPPluginCall) {
|
||||
openAppSettings(call)
|
||||
}
|
||||
|
||||
@objc func setAuthTokens(_ call: CAPPluginCall) {
|
||||
guard let access = call.getString("accessToken"), !access.isEmpty else {
|
||||
call.reject("accessToken is required")
|
||||
return
|
||||
}
|
||||
guard let refresh = call.getString("refreshToken"), !refresh.isEmpty else {
|
||||
call.reject("refreshToken is required")
|
||||
return
|
||||
}
|
||||
implementation.setAuthTokens(accessToken: access, refreshToken: refresh)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func clearAuthTokens(_ call: CAPPluginCall) {
|
||||
implementation.clearAuthTokens()
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func getAuthState(_ call: CAPPluginCall) {
|
||||
call.resolve(["present": implementation.getAuthState()])
|
||||
}
|
||||
|
||||
@objc func setBackgroundPostUrl(_ call: CAPPluginCall) {
|
||||
let url = call.getString("url")
|
||||
implementation.setBackgroundPostUrl(url)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func getBackgroundPostUrl(_ call: CAPPluginCall) {
|
||||
let url = implementation.getBackgroundPostUrl()
|
||||
call.resolve(["url": url ?? NSNull()])
|
||||
}
|
||||
|
||||
@objc func configureEdgeToEdge(_ call: CAPPluginCall) {
|
||||
// No-op on iOS; UI handled by app.
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func setGpsMode(_ call: CAPPluginCall) {
|
||||
let mode = call.getString("mode") ?? "normal"
|
||||
implementation.setGpsMode(mode)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
private func handleAuthorizationChange(_ status: CLAuthorizationStatus) {
|
||||
if let call = pendingPermissionCall {
|
||||
pendingPermissionCall = nil
|
||||
call.resolve(permissionStatusDict(status))
|
||||
}
|
||||
|
||||
if let call = pendingBackgroundCall {
|
||||
pendingBackgroundCall = nil
|
||||
if status == .authorizedAlways {
|
||||
implementation.startBackgroundTracking()
|
||||
call.resolve()
|
||||
} else {
|
||||
call.reject("Background location permission not granted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func permissionStatusDict(_ status: CLAuthorizationStatus) -> [String: String] {
|
||||
let granted = isLocationAuthorized(status)
|
||||
return [
|
||||
"location": granted ? "granted" : "denied",
|
||||
"wifi": "granted"
|
||||
]
|
||||
}
|
||||
|
||||
private func isLocationAuthorized(_ status: CLAuthorizationStatus) -> Bool {
|
||||
return status == .authorizedAlways || status == .authorizedWhenInUse
|
||||
}
|
||||
|
||||
private func openAppSettings(_ call: CAPPluginCall) {
|
||||
guard let url = URL(string: UIApplication.openSettingsURLString) else {
|
||||
call.reject("Unable to open settings")
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "dumon-geolocation",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dumon-geolocation",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.5",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@capacitor/android": "^7.0.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dumon-geolocation",
|
||||
"version": "1.0.5",
|
||||
"version": "1.1.0",
|
||||
"description": "Implement manager GNSS, Wi‑Fi RTT, IMU, Kalman fusion, event emitter",
|
||||
"main": "dist/plugin.cjs.js",
|
||||
"module": "dist/esm/index.js",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user