20260108-01

This commit is contained in:
wengki81 2026-01-08 09:20:20 +08:00
parent 0c141c88ad
commit 2575acb553
10 changed files with 768 additions and 43 deletions

2
.gitignore vendored
View File

@ -14,6 +14,8 @@ xcuserdata/
DerivedData/ DerivedData/
.swiftpm/configuration/registries.json .swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
example-app/ios/App/App/public
example-app/ios/App/App/capacitor.config.json
.netrc .netrc
.history .history

View File

@ -13,12 +13,44 @@ This guide provides instructions for contributing to this Capacitor plugin.
npm install npm install
``` ```
1. Install iOS dependencies (macOS only).
```shell
cd example-app/ios/App
pod install
```
1. Install SwiftLint if you're on macOS. 1. Install SwiftLint if you're on macOS.
```shell ```shell
brew install swiftlint 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 ### Scripts
#### `npm run build` #### `npm run build`

View File

@ -1,6 +1,6 @@
# dumon-geolocation # 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) - 📡 **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)
@ -29,6 +29,7 @@ npx cap sync
## Permissions ## Permissions
### Android
Plugin ini memerlukan izin berikut: Plugin ini memerlukan izin berikut:
- ACCESS_FINE_LOCATION - ACCESS_FINE_LOCATION
- ACCESS_COARSE_LOCATION - ACCESS_COARSE_LOCATION
@ -38,6 +39,9 @@ Plugin ini memerlukan izin berikut:
Gunakan API checkAndRequestPermissions() sebelum memulai positioning. Gunakan API checkAndRequestPermissions() sebelum memulai positioning.
### iOS
Tambahkan entri Info.plist (lihat bagian iOS Requirements).
--- ---
## API ## API
@ -92,7 +96,7 @@ configureEdgeToEdge(options: {
}): Promise<void> }): 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() ### setGpsMode()
@ -121,6 +125,11 @@ setOptions(options: {
emitGnssStatus?: boolean; emitGnssStatus?: boolean;
suppressMockedUpdates?: boolean; suppressMockedUpdates?: boolean;
keepScreenOn?: boolean; keepScreenOn?: boolean;
backgroundPollingIntervalMs?: number;
backgroundPostMinDistanceMeters?: number;
backgroundPostMinAccuracyMeters?: number;
backgroundMinPostIntervalMs?: number;
backgroundUseImuFallback?: boolean;
}): Promise<void> }): Promise<void>
``` ```
@ -150,6 +159,36 @@ addListener('onGnssStatus', (data: SatelliteStatus) => void): PluginListenerHand
Menerima update status GNSS jika `emitGnssStatus: true` di setOptions. 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 ## Interfaces
@ -240,12 +279,27 @@ interface WifiAp {
## Catatan ## 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. - Gunakan bersama @capacitor/geolocation jika ingin membandingkan hasil native vs Dumon.
- Posisi dipancarkan setiap 1000ms atau saat terjadi perubahan signifikan (jarak, kecepatan, arah). - Posisi dipancarkan setiap 1000ms atau saat terjadi perubahan signifikan (jarak, kecepatan, arah).
- directionRad dalam radian relatif terhadap utara. - directionRad dalam radian relatif terhadap utara.
- Fitur Mock Location Detection aktif secara default. - 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) ## Testing (example-app)

View File

@ -6,7 +6,6 @@ import android.graphics.Color
import android.os.Build import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log
import android.view.View import android.view.View
import android.view.WindowInsetsController import android.view.WindowInsetsController
import android.view.WindowManager 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.ImuData
import com.dumon.plugin.geolocation.imu.ImuSensorManager import com.dumon.plugin.geolocation.imu.ImuSensorManager
import com.dumon.plugin.geolocation.wifi.WifiPositioningManager 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.fusion.SensorFusionManager
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
@ -31,8 +29,6 @@ import android.content.Intent
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.dumon.plugin.geolocation.bg.BackgroundLocationService import com.dumon.plugin.geolocation.bg.BackgroundLocationService
import com.dumon.plugin.geolocation.utils.BgPrefs import com.dumon.plugin.geolocation.utils.BgPrefs
import org.json.JSONArray
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 import com.dumon.plugin.geolocation.utils.AuthStore
@ -80,7 +76,6 @@ class DumonGeolocation : Plugin() {
private var latestImu: ImuData? = null private var latestImu: ImuData? = null
private var satelliteStatus: SatelliteStatus? = null private var satelliteStatus: SatelliteStatus? = null
private var wifiScanResult: WifiScanResult? = null
private var isMockedLocation = false private var isMockedLocation = false
private var lastEmitTimestamp: Long = 0L private var lastEmitTimestamp: Long = 0L
@ -100,8 +95,6 @@ class DumonGeolocation : Plugin() {
private var emitIntervalMs: Long = 1000L // hard debounce private var emitIntervalMs: Long = 1000L // hard debounce
// private val emitIntervalMs: Long = 500L // private val emitIntervalMs: Long = 500L
private var motionState: String = "idle" // 'idle', 'driving', 'mocked'
private var bufferedDrivingLocation: Location? = null private var bufferedDrivingLocation: Location? = null
private var drivingEmitHandler: Handler? = null private var drivingEmitHandler: Handler? = null
private var drivingEmitRunnable: Runnable? = null private var drivingEmitRunnable: Runnable? = null
@ -166,7 +159,6 @@ class DumonGeolocation : Plugin() {
wifiManager = WifiPositioningManager( wifiManager = WifiPositioningManager(
context, context,
onWifiPositioningUpdate = { onWifiPositioningUpdate = {
wifiScanResult = it
emitPositionUpdate() emitPositionUpdate()
} }
) )
@ -646,13 +638,6 @@ class DumonGeolocation : Plugin() {
val speedChanged = abs(speedNow - prevSpeed) > speedChangeThreshold val speedChanged = abs(speedNow - prevSpeed) > speedChangeThreshold
val directionChanged = abs(directionNow - prevDirection) > directionChangeThreshold val directionChanged = abs(directionNow - prevDirection) > directionChangeThreshold
// Tentukan motion state
motionState = when {
isMockedLocation -> "mocked"
speedNow > 1.0f -> "driving"
else -> "idle"
}
val shouldEmit = isSignificantChange || speedChanged || directionChanged val shouldEmit = isSignificantChange || speedChanged || directionChanged
if (forceEmit || shouldEmit) { if (forceEmit || shouldEmit) {

View File

@ -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"
}
} }

View File

@ -45,5 +45,13 @@
</array> </array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<true/> <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> </dict>
</plist> </plist>

View File

@ -1,8 +1,459 @@
import Foundation import Foundation
import CoreLocation
import CoreMotion
import UIKit
@objc public class DumonGeolocation: NSObject { @objc public class DumonGeolocation: NSObject, CLLocationManagerDelegate {
@objc public func echo(_ value: String) -> String { public var onPositionUpdate: (([String: Any]) -> Void)?
print(value) public var onGnssStatus: (([String: Any]) -> Void)?
return value 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()
} }
} }

View File

@ -1,23 +1,223 @@
import Foundation import Foundation
import Capacitor import Capacitor
import CoreLocation
import UIKit
/**
* Please read the Capacitor iOS Plugin Development Guide
* here: https://capacitorjs.com/docs/plugins/ios
*/
@objc(DumonGeolocationPlugin) @objc(DumonGeolocationPlugin)
public class DumonGeolocationPlugin: CAPPlugin, CAPBridgedPlugin { public class DumonGeolocationPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "DumonGeolocationPlugin" public let identifier = "DumonGeolocationPlugin"
public let jsName = "DumonGeolocation" public let jsName = "DumonGeolocation"
public let pluginMethods: [CAPPluginMethod] = [ 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) { private let implementation = DumonGeolocation()
let value = call.getString("value") ?? "" private var pendingPermissionCall: CAPPluginCall?
call.resolve([ private var pendingBackgroundCall: CAPPluginCall?
"value": implementation.echo(value)
]) 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
View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "dumon-geolocation", "name": "dumon-geolocation",
"version": "1.0.5", "version": "1.1.0",
"description": "Implement manager GNSS, WiFi RTT, IMU, Kalman fusion, event emitter", "description": "Implement manager GNSS, WiFi RTT, IMU, Kalman fusion, event emitter",
"main": "dist/plugin.cjs.js", "main": "dist/plugin.cjs.js",
"module": "dist/esm/index.js", "module": "dist/esm/index.js",