From 14a8e8df162823ee90a9699849886d86e9a40e78 Mon Sep 17 00:00:00 2001 From: Manoj Verma <71809372+manojvermamv@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:13:55 +0530 Subject: [PATCH 01/15] feat: integrate battery-efficient Activity Recognition (STILL/MOTION) GPS throttling across Android/iOS --- android/src/main/AndroidManifest.xml | 10 ++ .../backgroundlocation/ActivityReceiver.kt | 55 ++++++++++ .../BackgroundLocationModule.kt | 5 +- .../com/backgroundlocation/LocationService.kt | 69 ++++++++++++ .../com/backgroundlocation/TrackingOptions.kt | 23 +++- .../provider/ActivityRecognitionProvider.kt | 83 ++++++++++++++ .../backgroundlocation/TrackingOptionsTest.kt | 44 ++++++++ .../ActivityRecognitionProviderTest.kt | 102 ++++++++++++++++++ ios/ActivityProvider.swift | 60 +++++++++++ ios/BackgroundLocation.mm | 15 +++ ios/LocationManagerWrapper.swift | 50 ++++++++- ios/TrackingOptions.swift | 41 +++++++ src/NativeBackgroundLocation.ts | 3 + src/types/tracking.ts | 19 ++++ src/utils/trackingOptionsMapper.ts | 3 + 15 files changed, 577 insertions(+), 5 deletions(-) create mode 100644 android/src/main/java/com/backgroundlocation/ActivityReceiver.kt create mode 100644 android/src/main/java/com/backgroundlocation/provider/ActivityRecognitionProvider.kt create mode 100644 android/src/test/java/com/backgroundlocation/TrackingOptionsTest.kt create mode 100644 android/src/test/java/com/backgroundlocation/provider/ActivityRecognitionProviderTest.kt create mode 100644 ios/ActivityProvider.swift diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index fff9b5e..8954bed 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -20,6 +20,10 @@ + + + + + + + + android.util.Log.d("ActivityReceiver", "Received Activity Update: \${getActivityString(activity.type)} (\${activity.confidence}%)") + LocationService.handleActivityStateChanged(activity.type) + } + return + } + + // Handle Transition Updates + if (ActivityTransitionResult.hasResult(intent)) { + val result = ActivityTransitionResult.extractResult(intent) + result?.transitionEvents?.lastOrNull()?.let { event -> + android.util.Log.d("ActivityReceiver", "Received Activity Transition: \${getActivityString(event.activityType)}") + LocationService.handleActivityStateChanged(event.activityType) + } + } + } + + private fun getActivityString(type: Int): String { + return when (type) { + DetectedActivity.IN_VEHICLE -> "IN_VEHICLE" + DetectedActivity.ON_BICYCLE -> "ON_BICYCLE" + DetectedActivity.ON_FOOT -> "ON_FOOT" + DetectedActivity.RUNNING -> "RUNNING" + DetectedActivity.STILL -> "STILL" + DetectedActivity.TILTING -> "TILTING" + DetectedActivity.UNKNOWN -> "UNKNOWN" + DetectedActivity.WALKING -> "WALKING" + else -> "UNKNOWN (\$type)" + } + } +} diff --git a/android/src/main/java/com/backgroundlocation/BackgroundLocationModule.kt b/android/src/main/java/com/backgroundlocation/BackgroundLocationModule.kt index 3ee18af..30dcc31 100644 --- a/android/src/main/java/com/backgroundlocation/BackgroundLocationModule.kt +++ b/android/src/main/java/com/backgroundlocation/BackgroundLocationModule.kt @@ -397,7 +397,10 @@ class BackgroundLocationModule(reactContext: ReactApplicationContext) : waitForAccurateLocation = if (options.hasKey("waitForAccurateLocation")) options.getBoolean("waitForAccurateLocation") else null, foregroundOnly = if (options.hasKey("foregroundOnly")) options.getBoolean("foregroundOnly") else null, distanceFilter = if (options.hasKey("distanceFilter")) options.getDouble("distanceFilter").toFloat() else null, - notificationOptions = notificationOptions + notificationOptions = notificationOptions, + activityTrackingEnabled = if (options.hasKey("activityTrackingEnabled")) options.getBoolean("activityTrackingEnabled") else null, + pauseLocationWhenStill = if (options.hasKey("pauseLocationWhenStill")) options.getBoolean("pauseLocationWhenStill") else null, + activityUpdateInterval = if (options.hasKey("activityUpdateInterval")) options.getDouble("activityUpdateInterval").toLong() else null ) } diff --git a/android/src/main/java/com/backgroundlocation/LocationService.kt b/android/src/main/java/com/backgroundlocation/LocationService.kt index c5700e6..629b74b 100644 --- a/android/src/main/java/com/backgroundlocation/LocationService.kt +++ b/android/src/main/java/com/backgroundlocation/LocationService.kt @@ -18,6 +18,8 @@ import androidx.core.content.ContextCompat import com.google.android.gms.location.* import com.backgroundlocation.provider.LocationProvider import com.backgroundlocation.provider.LocationProviderFactory +import com.backgroundlocation.provider.ActivityProvider +import com.backgroundlocation.provider.ActivityProviderFactory import com.backgroundlocation.provider.LocationUpdateCallback import com.backgroundlocation.processor.LocationProcessor import com.backgroundlocation.processor.DefaultLocationProcessor @@ -30,10 +32,14 @@ import kotlinx.coroutines.runBlocking class LocationService : Service() { private lateinit var locationProvider: LocationProvider + private lateinit var activityProvider: ActivityProvider private var locationProcessor: LocationProcessor = DefaultLocationProcessor() private lateinit var storage: LocationStorage private var currentTripId: String? = null private var trackingOptions: TrackingOptions = TrackingOptions() + + private var isLocationPausedDueToActivity = false + private var activityPendingIntent: PendingIntent? = null // Flag to prevent location events after stop is requested @Volatile @@ -53,6 +59,7 @@ class LocationService : Service() { // Use factory to get best available provider locationProvider = LocationProviderFactory.create(this) + activityProvider = ActivityProviderFactory.create(this) android.util.Log.d("LocationService", "Location provider initialized") } @@ -124,6 +131,11 @@ class LocationService : Service() { // Check last known location to verify GPS is working checkLastKnownLocation() + // Configure Activity recognition constraints if enabled + if (trackingOptions.getActivityTrackingEnabledOrDefault()) { + startActivityUpdates() + } + // Start location updates startLocationUpdates() @@ -331,6 +343,46 @@ class LocationService : Service() { } } + private fun startActivityUpdates() { + val intent = Intent(this, ActivityReceiver::class.java).apply { + action = ActivityReceiver.ACTION_PROCESS_ACTIVITY_UPDATES + } + + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + + activityPendingIntent = PendingIntent.getBroadcast(this, 0, intent, flags) + + activityPendingIntent?.let { + android.util.Log.d("LocationService", "Starting continuous activity updates") + activityProvider.requestActivityUpdates( + trackingOptions.getActivityUpdateIntervalOrDefault(), + it + ) + } + } + + fun onActivityStateChanged(activityType: Int) { + if (!trackingOptions.getActivityTrackingEnabledOrDefault()) return + if (isStopRequested) return + + val isStill = (activityType == DetectedActivity.STILL) + val shouldPause = isStill && trackingOptions.getPauseLocationWhenStillOrDefault() + + if (shouldPause && !isLocationPausedDueToActivity) { + android.util.Log.d("LocationService", "User is stationary. Pausing GPS updates to save battery.") + locationProvider.removeLocationUpdates() + isLocationPausedDueToActivity = true + } else if (!shouldPause && isLocationPausedDueToActivity) { + android.util.Log.d("LocationService", "User is moving again. Resuming GPS updates.") + startLocationUpdates() + isLocationPausedDueToActivity = false + } + } + /** * Handles a single location update with processor filtering */ @@ -624,6 +676,14 @@ class LocationService : Service() { // Cleanup location provider locationProvider.cleanup() android.util.Log.d("LocationService", "Location provider cleaned up") + + // Cleanup activity provider + activityPendingIntent?.let { + activityProvider.removeActivityUpdates(it) + activityPendingIntent = null + } + activityProvider.cleanup() + android.util.Log.d("LocationService", "Activity provider cleaned up") } /** @@ -730,6 +790,15 @@ class LocationService : Service() { @Volatile private var activeInstance: LocationService? = null + /** + * Routes activity state changes to the active instance securely + */ + fun handleActivityStateChanged(activityType: Int) { + synchronized(instanceLock) { + activeInstance?.onActivityStateChanged(activityType) + } + } + /** * Sets a stop token to prevent RecoveryWorker from restarting tracking * Uses SharedPreferences for synchronous, cross-process communication diff --git a/android/src/main/java/com/backgroundlocation/TrackingOptions.kt b/android/src/main/java/com/backgroundlocation/TrackingOptions.kt index c8c8192..ac3cba2 100644 --- a/android/src/main/java/com/backgroundlocation/TrackingOptions.kt +++ b/android/src/main/java/com/backgroundlocation/TrackingOptions.kt @@ -11,7 +11,10 @@ data class TrackingOptions( val waitForAccurateLocation: Boolean? = null, val foregroundOnly: Boolean? = null, val distanceFilter: Float? = null, - val notificationOptions: NotificationOptions? = null + val notificationOptions: NotificationOptions? = null, + val activityTrackingEnabled: Boolean? = null, + val pauseLocationWhenStill: Boolean? = null, + val activityUpdateInterval: Long? = null ) { companion object { // Default values @@ -26,6 +29,9 @@ data class TrackingOptions( const val DEFAULT_FOREGROUND_ONLY = false const val DEFAULT_DISTANCE_FILTER = 0f // No distance filter const val DEFAULT_NOTIFICATION_SHOW_TIMESTAMP = false + const val DEFAULT_ACTIVITY_TRACKING_ENABLED = false + const val DEFAULT_PAUSE_LOCATION_WHEN_STILL = false + const val DEFAULT_ACTIVITY_UPDATE_INTERVAL = 60000L // 60 seconds } // --- Computed property accessors for fields that LocationService.kt accesses directly --- @@ -98,4 +104,19 @@ data class TrackingOptions( * Gets notificationShowTimestamp with default fallback */ fun getNotificationShowTimestampOrDefault(): Boolean = notificationOptions?.showTimestamp ?: DEFAULT_NOTIFICATION_SHOW_TIMESTAMP + + /** + * Gets activityTrackingEnabled with default fallback + */ + fun getActivityTrackingEnabledOrDefault(): Boolean = activityTrackingEnabled ?: DEFAULT_ACTIVITY_TRACKING_ENABLED + + /** + * Gets pauseLocationWhenStill with default fallback + */ + fun getPauseLocationWhenStillOrDefault(): Boolean = pauseLocationWhenStill ?: DEFAULT_PAUSE_LOCATION_WHEN_STILL + + /** + * Gets the activityUpdateInterval with default fallback + */ + fun getActivityUpdateIntervalOrDefault(): Long = activityUpdateInterval ?: DEFAULT_ACTIVITY_UPDATE_INTERVAL } diff --git a/android/src/main/java/com/backgroundlocation/provider/ActivityRecognitionProvider.kt b/android/src/main/java/com/backgroundlocation/provider/ActivityRecognitionProvider.kt new file mode 100644 index 0000000..8edcf83 --- /dev/null +++ b/android/src/main/java/com/backgroundlocation/provider/ActivityRecognitionProvider.kt @@ -0,0 +1,83 @@ +package com.backgroundlocation.provider + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Context +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import com.google.android.gms.location.ActivityRecognition +import com.google.android.gms.location.ActivityRecognitionClient +import com.google.android.gms.location.ActivityTransitionRequest + +/** + * Activity provider using Google Play Services ActivityRecognition API. + */ +class ActivityRecognitionProvider : ActivityProvider { + + private var context: Context? = null + private var activityRecognitionClient: ActivityRecognitionClient? = null + + override fun initialize(context: Context) { + this.context = context + this.activityRecognitionClient = ActivityRecognition.getClient(context) + } + + @SuppressLint("MissingPermission") + override fun requestActivityUpdates(intervalMs: Long, pendingIntent: PendingIntent) { + activityRecognitionClient?.requestActivityUpdates(intervalMs, pendingIntent) + ?.addOnSuccessListener { + android.util.Log.d("ActivityRecProvider", "Successfully registered for continuous activity updates") + } + ?.addOnFailureListener { e -> + android.util.Log.e("ActivityRecProvider", "Failed to register for continuous activity updates", e) + } + } + + @SuppressLint("MissingPermission") + override fun removeActivityUpdates(pendingIntent: PendingIntent) { + activityRecognitionClient?.removeActivityUpdates(pendingIntent) + ?.addOnSuccessListener { + android.util.Log.d("ActivityRecProvider", "Successfully removed continuous activity updates") + } + ?.addOnFailureListener { e -> + android.util.Log.e("ActivityRecProvider", "Failed to remove continuous activity updates", e) + } + } + + @SuppressLint("MissingPermission") + override fun requestActivityTransitionUpdates( + request: ActivityTransitionRequest, + pendingIntent: PendingIntent + ) { + activityRecognitionClient?.requestActivityTransitionUpdates(request, pendingIntent) + ?.addOnSuccessListener { + android.util.Log.d("ActivityRecProvider", "Successfully registered for activity transition updates") + } + ?.addOnFailureListener { e -> + android.util.Log.e("ActivityRecProvider", "Failed to register for activity transition updates", e) + } + } + + @SuppressLint("MissingPermission") + override fun removeActivityTransitionUpdates(pendingIntent: PendingIntent) { + activityRecognitionClient?.removeActivityTransitionUpdates(pendingIntent) + ?.addOnSuccessListener { + android.util.Log.d("ActivityRecProvider", "Successfully removed activity transition updates") + } + ?.addOnFailureListener { e -> + android.util.Log.e("ActivityRecProvider", "Failed to remove activity transition updates", e) + } + } + + override fun isAvailable(): Boolean { + val ctx = context ?: return false + val apiAvailability = GoogleApiAvailability.getInstance() + val resultCode = apiAvailability.isGooglePlayServicesAvailable(ctx) + return resultCode == ConnectionResult.SUCCESS + } + + override fun cleanup() { + this.activityRecognitionClient = null + this.context = null + } +} diff --git a/android/src/test/java/com/backgroundlocation/TrackingOptionsTest.kt b/android/src/test/java/com/backgroundlocation/TrackingOptionsTest.kt new file mode 100644 index 0000000..eacb651 --- /dev/null +++ b/android/src/test/java/com/backgroundlocation/TrackingOptionsTest.kt @@ -0,0 +1,44 @@ +package com.backgroundlocation + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TrackingOptionsTest { + + @Test + fun `default values are returned when properties are null`() { + val options = TrackingOptions() + + assertFalse(options.getActivityTrackingEnabledOrDefault()) + assertFalse(options.getPauseLocationWhenStillOrDefault()) + assertEquals(60000L, options.getActivityUpdateIntervalOrDefault()) + + // Check other existing defaults to ensure no regression + assertEquals(5000L, options.getUpdateIntervalOrDefault()) + assertEquals(3000L, options.getFastestIntervalOrDefault()) + assertEquals(10000L, options.getMaxWaitTimeOrDefault()) + assertFalse(options.getForegroundOnlyOrDefault()) + assertEquals(0f, options.getDistanceFilterOrDefault()) + } + + @Test + fun `custom values are returned when properties are provided`() { + val options = TrackingOptions( + activityTrackingEnabled = true, + pauseLocationWhenStill = true, + activityUpdateInterval = 30000L, + updateInterval = 10000L, + distanceFilter = 50f + ) + + assertTrue(options.getActivityTrackingEnabledOrDefault()) + assertTrue(options.getPauseLocationWhenStillOrDefault()) + assertEquals(30000L, options.getActivityUpdateIntervalOrDefault()) + + // Overridden values + assertEquals(10000L, options.getUpdateIntervalOrDefault()) + assertEquals(50f, options.getDistanceFilterOrDefault()) + } +} diff --git a/android/src/test/java/com/backgroundlocation/provider/ActivityRecognitionProviderTest.kt b/android/src/test/java/com/backgroundlocation/provider/ActivityRecognitionProviderTest.kt new file mode 100644 index 0000000..ef2a583 --- /dev/null +++ b/android/src/test/java/com/backgroundlocation/provider/ActivityRecognitionProviderTest.kt @@ -0,0 +1,102 @@ +package com.backgroundlocation.provider + +import android.app.PendingIntent +import android.content.Context +import com.google.android.gms.location.ActivityRecognition +import com.google.android.gms.location.ActivityRecognitionClient +import com.google.android.gms.location.ActivityTransitionRequest +import com.google.android.gms.tasks.Task +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Before +import org.junit.Test + +class ActivityRecognitionProviderTest { + + private lateinit var provider: ActivityRecognitionProvider + private lateinit var activityClient: ActivityRecognitionClient + private lateinit var context: Context + private lateinit var pendingIntent: PendingIntent + + @Before + fun setUp() { + context = mockk(relaxed = true) + activityClient = mockk(relaxed = true) + pendingIntent = mockk(relaxed = true) + + mockkStatic(ActivityRecognition::class) + every { ActivityRecognition.getClient(any()) } returns activityClient + + // Mock Task return types for the Play Services methods + every { activityClient.requestActivityUpdates(any(), any()) } returns mockk>(relaxed = true) + every { activityClient.removeActivityUpdates(any()) } returns mockk>(relaxed = true) + every { activityClient.requestActivityTransitionUpdates(any(), any()) } returns mockk>(relaxed = true) + every { activityClient.removeActivityTransitionUpdates(any()) } returns mockk>(relaxed = true) + + provider = ActivityRecognitionProvider() + provider.initialize(context) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `requestActivityUpdates passes interval and pending intent to client`() { + val intervalMs = 60000L + + provider.requestActivityUpdates(intervalMs, pendingIntent) + + verify(exactly = 1) { + activityClient.requestActivityUpdates(intervalMs, pendingIntent) + } + } + + @Test + fun `removeActivityUpdates passes pending intent to client`() { + provider.removeActivityUpdates(pendingIntent) + + verify(exactly = 1) { + activityClient.removeActivityUpdates(pendingIntent) + } + } + + @Test + fun `requestActivityTransitionUpdates passes request and pending intent to client`() { + val transitionRequest = mockk() + + provider.requestActivityTransitionUpdates(transitionRequest, pendingIntent) + + verify(exactly = 1) { + activityClient.requestActivityTransitionUpdates(transitionRequest, pendingIntent) + } + } + + @Test + fun `removeActivityTransitionUpdates passes pending intent to client`() { + provider.removeActivityTransitionUpdates(pendingIntent) + + verify(exactly = 1) { + activityClient.removeActivityTransitionUpdates(pendingIntent) + } + } + + @Test + fun `cleanup clears client reference safely`() { + provider.cleanup() + + // Ensure no exception is thrown and subsequent calls are null-safe + provider.requestActivityUpdates(1000L, pendingIntent) + + // Verification is that it didn't crash. + // It should NOT call the mock client because cleanup() cleared the reference + verify(exactly = 0) { + activityClient.requestActivityUpdates(any(), any()) + } + } +} diff --git a/ios/ActivityProvider.swift b/ios/ActivityProvider.swift new file mode 100644 index 0000000..704b186 --- /dev/null +++ b/ios/ActivityProvider.swift @@ -0,0 +1,60 @@ +import Foundation +import CoreMotion + +@objc public protocol ActivityProviderDelegate: AnyObject { + func onActivityStateChanged(isStationary: Bool, activityDescription: String) +} + +@objc public class ActivityProvider: NSObject { + + private let activityManager = CMMotionActivityManager() + private let activityQueue = OperationQueue() + + @objc public weak var delegate: ActivityProviderDelegate? + + public override init() { + super.init() + activityQueue.name = "com.backgroundlocation.activityQueue" + activityQueue.maxConcurrentOperationCount = 1 + } + + @objc public func startTracking() { + guard CMMotionActivityManager.isActivityAvailable() else { + NSLog("[BackgroundLocation] CoreMotion Activity is not available on this device.") + return + } + + NSLog("[BackgroundLocation] Starting CoreMotion Activity tracking...") + + activityManager.startActivityUpdates(to: activityQueue) { [weak self] activity in + guard let activity = activity else { return } + + let isStationary = activity.stationary + let desc = self?.describeActivity(activity) ?? "Unknown" + + NSLog("[BackgroundLocation] Detected Activity: \(desc) (Stationary: \(isStationary)) Confidence: \(activity.confidence.rawValue)") + + // Notify delegate about the state change + self?.delegate?.onActivityStateChanged(isStationary: isStationary, activityDescription: desc) + } + } + + @objc public func stopTracking() { + NSLog("[BackgroundLocation] Stopping CoreMotion Activity tracking...") + activityManager.stopActivityUpdates() + } + + private func describeActivity(_ activity: CMMotionActivity) -> String { + var types: [String] = [] + if activity.stationary { types.append("Stationary") } + if activity.walking { types.append("Walking") } + if activity.running { types.append("Running") } + if activity.automotive { types.append("Automotive") } + if activity.cycling { types.append("Cycling") } + + if types.isEmpty { + return "Unknown" + } + return types.joined(separator: ", ") + } +} diff --git a/ios/BackgroundLocation.mm b/ios/BackgroundLocation.mm index 6a2be70..43df90f 100644 --- a/ios/BackgroundLocation.mm +++ b/ios/BackgroundLocation.mm @@ -146,6 +146,21 @@ - (NSDictionary *)transportDictFromCodegenSpec:(JS::NativeBackgroundLocation::Tr dict[@"foregroundOnly"] = @(foregroundOnly.value()); } + auto activityTrackingEnabled = options.activityTrackingEnabled(); + if (activityTrackingEnabled.has_value()) { + dict[@"activityTrackingEnabled"] = @(activityTrackingEnabled.value()); + } + + auto pauseLocationWhenStill = options.pauseLocationWhenStill(); + if (pauseLocationWhenStill.has_value()) { + dict[@"pauseLocationWhenStill"] = @(pauseLocationWhenStill.value()); + } + + auto activityUpdateInterval = options.activityUpdateInterval(); + if (activityUpdateInterval.has_value()) { + dict[@"activityUpdateInterval"] = @(activityUpdateInterval.value()); + } + auto waitForAccurateLocation = options.waitForAccurateLocation(); if (waitForAccurateLocation.has_value()) { dict[@"waitForAccurateLocation"] = @(waitForAccurateLocation.value()); diff --git a/ios/LocationManagerWrapper.swift b/ios/LocationManagerWrapper.swift index 996b7e3..7ed611c 100644 --- a/ios/LocationManagerWrapper.swift +++ b/ios/LocationManagerWrapper.swift @@ -1,7 +1,7 @@ import Foundation import CoreLocation -@objc public class LocationManagerWrapper: NSObject, LocationManagerDelegateCallback { +@objc public class LocationManagerWrapper: NSObject, LocationManagerDelegateCallback, ActivityProviderDelegate { @objc public static let shared = LocationManagerWrapper() private var locationManager: CLLocationManager? @@ -12,6 +12,9 @@ import CoreLocation private var _currentTripId: String? private var _currentOptions: TrackingOptions? + private var activityProvider: ActivityProvider? + private var isLocationPausedDueToActivity = false + // MARK: - Event Emission Closures @objc public var onLocationUpdate: (([String: Any]) -> Void)? @objc public var onLocationWarning: (([String: Any]) -> Void)? @@ -87,6 +90,18 @@ import CoreLocation configureAndStart(options: opts) + // Initialize and start Activity Tracking if requested + if opts.isActivityTrackingEnabled { + if activityProvider == nil { + activityProvider = ActivityProvider() + activityProvider?.delegate = self + } + activityProvider?.startTracking() + } else { + activityProvider?.stopTracking() + activityProvider = nil + } + // Start significant location monitoring for crash recovery if !opts.isForegroundOnly { startSignificantLocationMonitoring() @@ -112,6 +127,10 @@ import CoreLocation self?.locationManager?.stopUpdatingLocation() self?.locationManager?.stopMonitoringSignificantLocationChanges() self?.locationManager = nil + + self?.activityProvider?.stopTracking() + self?.activityProvider = nil + self?.isLocationPausedDueToActivity = false } } } @@ -405,8 +424,6 @@ import CoreLocation } } - // MARK: - Significant Location Monitoring - private func startSignificantLocationMonitoring() { DispatchQueue.main.async { [weak self] in guard let self = self else { return } @@ -419,6 +436,33 @@ import CoreLocation } } + // MARK: - ActivityProviderDelegate + + public func onActivityStateChanged(isStationary: Bool, activityDescription: String) { + queue.async { [weak self] in + guard let self = self, self._isTracking, let options = self._currentOptions else { return } + + let shouldPause = isStationary && options.shouldPauseLocationWhenStill + + if shouldPause && !self.isLocationPausedDueToActivity { + NSLog("[BackgroundLocation] User is stationary (\(activityDescription)). Pausing GPS updates to save battery.") + DispatchQueue.main.async { + self.locationManager?.stopUpdatingLocation() + } + self.isLocationPausedDueToActivity = true + } else if !shouldPause && self.isLocationPausedDueToActivity { + NSLog("[BackgroundLocation] User is moving again (\(activityDescription)). Resuming GPS updates.") + DispatchQueue.main.async { + // If the original accuracy requested wasn't passive, restart it + if options.accuracy != "PASSIVE" && options.accuracy != "NO_POWER" { + self.locationManager?.startUpdatingLocation() + } + } + self.isLocationPausedDueToActivity = false + } + } + } + // MARK: - Private private func mapAuthorizationStatus(_ status: CLAuthorizationStatus) -> [String: Any] { diff --git a/ios/TrackingOptions.swift b/ios/TrackingOptions.swift index ba4203d..650e2da 100644 --- a/ios/TrackingOptions.swift +++ b/ios/TrackingOptions.swift @@ -8,6 +8,9 @@ import CoreLocation @objc public let updateInterval: NSNumber? @objc public let foregroundOnly: NSNumber? @objc public let waitForAccurateLocation: NSNumber? + @objc public let activityTrackingEnabled: NSNumber? + @objc public let pauseLocationWhenStill: NSNumber? + @objc public let activityUpdateInterval: NSNumber? // Notification options — no-op on iOS (no foreground service notification concept) // Stored as JSON string to allow cross-platform TrackingOptions without crashes @@ -32,6 +35,9 @@ import CoreLocation self.updateInterval = dict["updateInterval"] as? NSNumber self.foregroundOnly = dict["foregroundOnly"] as? NSNumber self.waitForAccurateLocation = dict["waitForAccurateLocation"] as? NSNumber + self.activityTrackingEnabled = dict["activityTrackingEnabled"] as? NSNumber + self.pauseLocationWhenStill = dict["pauseLocationWhenStill"] as? NSNumber + self.activityUpdateInterval = dict["activityUpdateInterval"] as? NSNumber // Notification options — parsed without error, unused on iOS self.notificationOptions = dict["notificationOptions"] as? String @@ -119,6 +125,33 @@ import CoreLocation } } + // activityTrackingEnabled: NSNumber (bool-bridged) + if let raw = dict["activityTrackingEnabled"] { + if let value = raw as? NSNumber, !(raw is NSNull) { + sanitized["activityTrackingEnabled"] = value + } else if !(raw is NSNull) { + wrongTypeKeys.append("'activityTrackingEnabled' (expected NSNumber)") + } + } + + // pauseLocationWhenStill: NSNumber (bool-bridged) + if let raw = dict["pauseLocationWhenStill"] { + if let value = raw as? NSNumber, !(raw is NSNull) { + sanitized["pauseLocationWhenStill"] = value + } else if !(raw is NSNull) { + wrongTypeKeys.append("'pauseLocationWhenStill' (expected NSNumber)") + } + } + + // activityUpdateInterval: NSNumber + if let raw = dict["activityUpdateInterval"] { + if let value = raw as? NSNumber, !(raw is NSNull) { + sanitized["activityUpdateInterval"] = value + } else if !(raw is NSNull) { + wrongTypeKeys.append("'activityUpdateInterval' (expected NSNumber)") + } + } + if !wrongTypeKeys.isEmpty { let joined = wrongTypeKeys.joined(separator: ", ") guardLogger("[BackgroundLocation] \(methodName) received wrong type for key(s) \(joined); falling back to defaults") @@ -145,4 +178,12 @@ import CoreLocation @objc public var isForegroundOnly: Bool { return foregroundOnly?.boolValue ?? false } + + @objc public var isActivityTrackingEnabled: Bool { + return activityTrackingEnabled?.boolValue ?? false + } + + @objc public var shouldPauseLocationWhenStill: Bool { + return pauseLocationWhenStill?.boolValue ?? false + } } diff --git a/src/NativeBackgroundLocation.ts b/src/NativeBackgroundLocation.ts index 0ff9c16..28ae1c0 100644 --- a/src/NativeBackgroundLocation.ts +++ b/src/NativeBackgroundLocation.ts @@ -30,6 +30,9 @@ export interface TrackingOptionsSpec { waitForAccurateLocation?: boolean; foregroundOnly?: boolean; distanceFilter?: number; + activityTrackingEnabled?: boolean; + pauseLocationWhenStill?: boolean; + activityUpdateInterval?: number; notificationOptions?: string; // JSON-serialized NotificationOptions - Codegen does not support complex objects } diff --git a/src/types/tracking.ts b/src/types/tracking.ts index d7bdd61..3883ce9 100644 --- a/src/types/tracking.ts +++ b/src/types/tracking.ts @@ -299,6 +299,25 @@ export interface TrackingOptions { */ foregroundOnly?: boolean; + /** + * Whether to actively monitor physical activity (STILL, WALKING, etc.) + * @default false + */ + activityTrackingEnabled?: boolean; + + /** + * Whether to pause GPS updates when the device is detected as STILL + * Requires activityTrackingEnabled to be true + * @default false + */ + pauseLocationWhenStill?: boolean; + + /** + * The interval at which to poll for activity updates + * @default 60000 (60 seconds) + */ + activityUpdateInterval?: number; + /** * Interval in milliseconds to throttle the onLocationUpdate callback execution * Locations are still collected at the updateInterval rate, but the callback diff --git a/src/utils/trackingOptionsMapper.ts b/src/utils/trackingOptionsMapper.ts index d14a3cb..dd859ec 100644 --- a/src/utils/trackingOptionsMapper.ts +++ b/src/utils/trackingOptionsMapper.ts @@ -27,6 +27,9 @@ export function toTrackingOptionsSpec( waitForAccurateLocation: options.waitForAccurateLocation, foregroundOnly: options.foregroundOnly, distanceFilter: options.distanceFilter, + activityTrackingEnabled: options.activityTrackingEnabled, + pauseLocationWhenStill: options.pauseLocationWhenStill, + activityUpdateInterval: options.activityUpdateInterval, notificationOptions: options.notificationOptions ? JSON.stringify(options.notificationOptions) : undefined, From cfbf79e935599a74b539f4ce9f3dd8c52a159ca4 Mon Sep 17 00:00:00 2001 From: Manoj Verma <71809372+manojvermamv@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:20:48 +0530 Subject: [PATCH 02/15] fix: include missing Android ActivityProvider interfaces and factory --- .../provider/ActivityProvider.kt | 53 +++++++++++++++++++ .../provider/ActivityProviderFactory.kt | 25 +++++++++ 2 files changed, 78 insertions(+) create mode 100644 android/src/main/java/com/backgroundlocation/provider/ActivityProvider.kt create mode 100644 android/src/main/java/com/backgroundlocation/provider/ActivityProviderFactory.kt diff --git a/android/src/main/java/com/backgroundlocation/provider/ActivityProvider.kt b/android/src/main/java/com/backgroundlocation/provider/ActivityProvider.kt new file mode 100644 index 0000000..b6864fc --- /dev/null +++ b/android/src/main/java/com/backgroundlocation/provider/ActivityProvider.kt @@ -0,0 +1,53 @@ +package com.backgroundlocation.provider + +import android.app.PendingIntent +import android.content.Context +import com.google.android.gms.location.ActivityTransitionRequest + +/** + * Abstract interface for activity recognition providers. + * Allows structured management of activity transitions and continuous updates. + */ +interface ActivityProvider { + + /** + * Initialize the provider with context + */ + fun initialize(context: Context) + + /** + * Request periodic updates on the user's current activity. + * Uses a polling-based approach. + * @param intervalMs The specified interval for updates (e.g., every 30 seconds). + * @param pendingIntent The intent that receives the activity updates. + */ + fun requestActivityUpdates(intervalMs: Long, pendingIntent: PendingIntent) + + /** + * Stop periodic activity updates. + */ + fun removeActivityUpdates(pendingIntent: PendingIntent) + + /** + * Request notifications only when there is a transition in the user's activity. + * Event-based and more battery-efficient. + * @param request Contains the list of ActivityTransitions. + * @param pendingIntent The intent that receives the transition events. + */ + fun requestActivityTransitionUpdates(request: ActivityTransitionRequest, pendingIntent: PendingIntent) + + /** + * Stop activity transition updates. + */ + fun removeActivityTransitionUpdates(pendingIntent: PendingIntent) + + /** + * Check if this provider is available on the device. + */ + fun isAvailable(): Boolean + + /** + * Cleanup resources. + */ + fun cleanup() +} diff --git a/android/src/main/java/com/backgroundlocation/provider/ActivityProviderFactory.kt b/android/src/main/java/com/backgroundlocation/provider/ActivityProviderFactory.kt new file mode 100644 index 0000000..4ce2fbd --- /dev/null +++ b/android/src/main/java/com/backgroundlocation/provider/ActivityProviderFactory.kt @@ -0,0 +1,25 @@ +package com.backgroundlocation.provider + +import android.content.Context + +/** + * Factory for creating the appropriate activity recognition provider + */ +object ActivityProviderFactory { + + /** + * Creates the ActivityRecognitionProvider for managing activity transitions and updates + */ + fun create(context: Context): ActivityProvider { + val provider = ActivityRecognitionProvider() + provider.initialize(context) + + if (provider.isAvailable()) { + android.util.Log.d("ActivityProviderFactory", "Google Play Services available, using ActivityRecognitionProvider") + } else { + android.util.Log.w("ActivityProviderFactory", "Google Play Services unavailable. Activity recognition may not work.") + } + + return provider + } +} From d71ea250a76bf2514d7800af597cb5133eebfea0 Mon Sep 17 00:00:00 2001 From: Manoj Verma <71809372+manojvermamv@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:34:41 +0530 Subject: [PATCH 03/15] build: update podspec and example app permissions for CoreMotion/ActivityRecognition --- BackgroundLocation.podspec | 2 +- example/android/app/src/main/AndroidManifest.xml | 2 ++ example/ios/BackgroundLocationExample/Info.plist | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/BackgroundLocation.podspec b/BackgroundLocation.podspec index ce381c6..5a1718c 100644 --- a/BackgroundLocation.podspec +++ b/BackgroundLocation.podspec @@ -16,7 +16,7 @@ Pod::Spec.new do |s| s.source_files = "ios/**/*.{h,m,mm,cpp,swift}" s.private_header_files = "ios/**/*.h" - s.frameworks = "CoreLocation", "CoreData" + s.frameworks = "CoreLocation", "CoreData", "CoreMotion" s.resource_bundles = { 'BackgroundLocationPrivacy' => ['ios/PrivacyInfo.xcprivacy'], diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index d91f274..c71ca62 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -10,6 +10,8 @@ + + This app needs access to your location in the background to continue tracking trips. NSLocationWhenInUseUsageDescription This app needs access to your location to track trips while in use. + NSMotionUsageDescription + This app requires motion data to optimize location tracking battery usage. RCTNewArchEnabled UIBackgroundModes From 584c9a4634212aa58e72408c4bb7ce5e3d13e55a Mon Sep 17 00:00:00 2001 From: Manoj Verma <71809372+manojvermamv@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:53:43 +0530 Subject: [PATCH 04/15] docs: update README, CHANGELOG, and package.json for v1.1.0 Activity Recognition release --- CHANGELOG.md | 39 +++++++++++++++++++++++++++++++++++++++ README.md | 18 +++++++++++++++++- package.json | 13 +++++++++---- 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54dad53..b72545a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # Changelog +## [1.1.0] - 2026-06-22 + +### Added + +- **Activity Recognition — Android** (`ActivityProvider.kt`, `ActivityProviderFactory.kt`, `ActivityRecognitionProvider.kt`): Implemented a battery-efficient activity recognition architecture using Google Play Services `ActivityRecognitionClient`. Supports both event-based `requestActivityTransitionUpdates` (STILL ↔ MOVING transitions) and polling-based `requestActivityUpdates`. +- **Activity Recognition — iOS** (`ActivityProvider.swift`): Equivalent `CMMotionActivityManager` wrapper for iOS. Detects `stationary`, `walking`, `running`, `automotive`, and `cycling` states via `CoreMotion` framework. Runs on a dedicated background `OperationQueue`. +- **`ActivityReceiver.kt`**: Manifest-registered `BroadcastReceiver` that captures activity transition `PendingIntent` events from Play Services and routes them safely to the `LocationService` singleton via a thread-safe `handleActivityStateChanged` method. +- **Dynamic GPS throttling**: `LocationService.kt` now pauses `requestLocationUpdates` when the device is detected as `STILL` (if `pauseLocationWhenStill` is enabled) and resumes immediately on any movement transition, cutting GPS battery drain to near-zero while stationary. +- **iOS dynamic GPS throttling**: `LocationManagerWrapper.swift` implements the same pause/resume logic driven by `ActivityProvider`'s `onActivityStateChanged` delegate callback. +- **Three new `TrackingOptions` fields** (Android, iOS, TypeScript — all platforms): + - `activityTrackingEnabled: boolean` — opt-in to activity recognition (default: `false`). + - `pauseLocationWhenStill: boolean` — pause GPS when device is stationary (requires `activityTrackingEnabled`, default: `false`). + - `activityUpdateInterval: number` — polling interval in milliseconds for Android `requestActivityUpdates` (default: `60000`). +- **TurboModule Codegen spec** (`src/NativeBackgroundLocation.ts`): Added the three new fields to `TrackingOptionsSpec` so Codegen generates the correct C++ struct accessors on both platforms. +- **TypeScript types** (`src/types/tracking.ts`): Full JSDoc-annotated `TrackingOptions` fields with platform tags. +- **TS options mapper** (`src/utils/trackingOptionsMapper.ts`): `toTrackingOptionsSpec()` now forwards the three new fields across the TurboModule bridge. +- **`BackgroundLocation.mm`** (iOS Objective-C++ bridge): `transportDictFromCodegenSpec:` maps `activityTrackingEnabled`, `pauseLocationWhenStill`, and `activityUpdateInterval` from the C++ struct into the `NSDictionary` delivered to Swift. +- **`CoreMotion` framework** added to `BackgroundLocation.podspec` (`s.frameworks`): CocoaPods now auto-links `CoreMotion` for consumers without any manual Xcode project changes. +- **Manifest permissions** (`android/src/main/AndroidManifest.xml`): Added `android.permission.ACTIVITY_RECOGNITION` (API 29+) and `com.google.android.gms.permission.ACTIVITY_RECOGNITION` (legacy Play Services). +- **Example app permissions** (`example/android/app/src/main/AndroidManifest.xml`): Mirrored same permissions for the bundled demo application. +- **Example app `Info.plist`** (`example/ios/BackgroundLocationExample/Info.plist`): Added `NSMotionUsageDescription` so the demo app can request CoreMotion access without crashing. +- **Android unit tests**: + - `ActivityRecognitionProviderTest.kt` — MockK-based tests validating client registration, `PendingIntent` delivery, and `cleanup()` resource release. + - `TrackingOptionsTest.kt` — Validates all new option fields parse correctly with expected defaults and custom values. + +### Changed + +- `LocationService.kt`: Integrated `ActivityProvider` lifecycle (start on `onStartCommand`, cleanup on `onDestroy`). Added `instanceLock`-guarded `handleActivityStateChanged` for safe cross-thread state transitions. +- `LocationManagerWrapper.swift`: Conforms to `ActivityProviderDelegate`; mounts/unmounts `ActivityProvider` alongside the `CLLocationManager` session. +- `BackgroundLocationModule.kt`: Parses `activityTrackingEnabled`, `pauseLocationWhenStill`, and `activityUpdateInterval` from the incoming `ReadableMap`. +- `TrackingOptions.kt` / `TrackingOptions.swift`: Extended with new fields, safe defaults, and computed boolean accessors (`isActivityTrackingEnabled`, `shouldPauseLocationWhenStill`). + +### Notes + +- **Non-breaking release.** All new `TrackingOptions` fields are optional and default to `false`/`0`, so existing call sites compile and behave identically without changes. +- **No new Android dependency required.** `ActivityRecognitionClient` is included in the existing `com.google.android.gms:play-services-location:21.3.0` dependency. +- **iOS requires `NSMotionUsageDescription`** in the host app's `Info.plist` when `activityTrackingEnabled: true` is used. Omitting it causes a runtime crash on iOS. +- Runtime permission for `ACTIVITY_RECOGNITION` must be requested on Android 10+ (API 29) before enabling activity tracking. The library manifest declares the permission; the host app is responsible for requesting it at runtime. + ## [1.0.0-rc] - 2026-05-27 > **First release candidate for the 1.x line.** From `1.0.0` forward, the library follows strict semver: breaking changes ship only on a major version bump. Three surfaces are explicitly frozen for the 1.x line: (1) the public TypeScript surface (named exports from `src/index.tsx`), (2) the TurboModule Codegen spec (`src/NativeBackgroundLocation.ts`), and (3) the native event names emitted via `NativeEventEmitter` (`onLocationUpdate`, `onLocationError`, `onLocationWarning`, `onNotificationAction`, `onGeofenceTransition`). No native (Android/iOS) code changes and no public TypeScript API changes since `0.17.0` this release candidate exists to declare API stability, not to introduce behavior. diff --git a/README.md b/README.md index cca0461..f7d7b09 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ A TurboModule for the React Native New Architecture. Drives a foreground service - Native geofencing (GeofencingClient on Android, CLCircularRegion on iOS) - Persistent location storage (Room on Android, Core Data on iOS) - Crash recovery via WorkManager and significant location monitoring +- **Battery-efficient Activity Recognition** — pauses GPS when device is `STILL`, resumes on motion (Android: Play Services `ActivityRecognitionClient`; iOS: `CoreMotion CMMotionActivityManager`) - React hooks: `useBackgroundLocation`, `useLocationPermissions`, `useLocationUpdates`, `useLocationTracking` - Expo config plugin for managed workflows @@ -49,6 +50,15 @@ cd ios && pod install Autolinking handles Android manifest merging and iOS pod registration. Bare iOS apps must still add `NSLocationWhenInUseUsageDescription`, `NSLocationAlwaysAndWhenInUseUsageDescription`, `NSLocationAlwaysUsageDescription`, and a `UIBackgroundModes` entry containing `location` to their `Info.plist`. See the [iOS setup guide](https://gabriel-sisjr.github.io/react-native-background-location/docs/getting-started/ios-setup) for full details. +> **Activity Recognition on iOS:** If you enable `activityTrackingEnabled: true`, you must also add `NSMotionUsageDescription` to your `Info.plist`, otherwise the app will crash on launch. +> +> ```xml +> NSMotionUsageDescription +> This app requires motion data to optimize location tracking battery usage. +> ``` + +> **Activity Recognition on Android:** `ACTIVITY_RECOGNITION` permission is automatically merged into your app's manifest by this library. On Android 10+ (API 29), you must request it at runtime before enabling activity tracking. + ## Quick Start ```tsx @@ -63,7 +73,13 @@ function App() { return (