From 2575acb553ba5b0edf771166c95d7533ac228932 Mon Sep 17 00:00:00 2001 From: wengki81 Date: Thu, 8 Jan 2026 09:20:20 +0800 Subject: [PATCH] 20260108-01 --- .gitignore | 2 + CONTRIBUTING.md | 32 ++ README.md | 60 ++- .../plugin/geolocation/DumonGeolocation.kt | 15 - .../geolocation/utils/PermissionUtils.kt | 7 - example-app/ios/App/App/Info.plist | 8 + .../DumonGeolocation.swift | 459 +++++++++++++++++- .../DumonGeolocationPlugin.swift | 222 ++++++++- package-lock.json | 4 +- package.json | 2 +- 10 files changed, 768 insertions(+), 43 deletions(-) diff --git a/.gitignore b/.gitignore index 2c6f035..be12e63 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3f87518..5cb737f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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=" build + ``` + +1. Install and launch on the simulator. + + ```shell + xcrun simctl boot + xcrun simctl install + xcrun simctl launch 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` diff --git a/README.md b/README.md index e0a390d..662b3c4 100644 --- a/README.md +++ b/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 ``` -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 ``` @@ -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 + +stopBackgroundTracking(): Promise +isBackgroundTrackingActive(): Promise<{ active: boolean }> +getBackgroundLatestPosition(): Promise +openBackgroundPermissionSettings(): Promise +openNotificationPermissionSettings(): Promise +``` + +Catatan: pada iOS, `title/text/channelId/channelName` diabaikan; `postUrl` tetap didukung. + +### Auth & Post URL (Android + iOS) + +```typescript +setAuthTokens(tokens: { accessToken: string; refreshToken: string }): Promise +clearAuthTokens(): Promise +getAuthState(): Promise<{ present: boolean }> +setBackgroundPostUrl(options: { url?: string }): Promise +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) diff --git a/android/src/main/java/com/dumon/plugin/geolocation/DumonGeolocation.kt b/android/src/main/java/com/dumon/plugin/geolocation/DumonGeolocation.kt index f11223c..eef5ac6 100644 --- a/android/src/main/java/com/dumon/plugin/geolocation/DumonGeolocation.kt +++ b/android/src/main/java/com/dumon/plugin/geolocation/DumonGeolocation.kt @@ -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) { diff --git a/android/src/main/java/com/dumon/plugin/geolocation/utils/PermissionUtils.kt b/android/src/main/java/com/dumon/plugin/geolocation/utils/PermissionUtils.kt index a1873db..1f1088a 100644 --- a/android/src/main/java/com/dumon/plugin/geolocation/utils/PermissionUtils.kt +++ b/android/src/main/java/com/dumon/plugin/geolocation/utils/PermissionUtils.kt @@ -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" - } } diff --git a/example-app/ios/App/App/Info.plist b/example-app/ios/App/App/Info.plist index cf1affd..5ae6701 100644 --- a/example-app/ios/App/App/Info.plist +++ b/example-app/ios/App/App/Info.plist @@ -45,5 +45,13 @@ UIViewControllerBasedStatusBarAppearance + NSLocationWhenInUseUsageDescription + We use your location to provide real-time positioning. + NSLocationAlwaysAndWhenInUseUsageDescription + We use your location to provide real-time positioning, even in the background. + UIBackgroundModes + + location + diff --git a/ios/Sources/DumonGeolocationPlugin/DumonGeolocation.swift b/ios/Sources/DumonGeolocationPlugin/DumonGeolocation.swift index 855eba2..ac78d9b 100644 --- a/ios/Sources/DumonGeolocationPlugin/DumonGeolocation.swift +++ b/ios/Sources/DumonGeolocationPlugin/DumonGeolocation.swift @@ -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() } } diff --git a/ios/Sources/DumonGeolocationPlugin/DumonGeolocationPlugin.swift b/ios/Sources/DumonGeolocationPlugin/DumonGeolocationPlugin.swift index 79e19d6..adc1cb6 100644 --- a/ios/Sources/DumonGeolocationPlugin/DumonGeolocationPlugin.swift +++ b/ios/Sources/DumonGeolocationPlugin/DumonGeolocationPlugin.swift @@ -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() + } } } diff --git a/package-lock.json b/package-lock.json index 3b9c61c..cad572f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5aa0dea..2d60747 100644 --- a/package.json +++ b/package.json @@ -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",