diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 06e439b..0924736 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -54,6 +54,31 @@ jobs:
flags: unittests
fail_ci_if_error: false
+ test-android:
+ runs-on: ubuntu-latest
+ needs: [lint, test]
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Setup
+ uses: ./.github/actions/setup
+
+ - name: Install JDK
+ uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ with:
+ distribution: 'zulu'
+ java-version: '17'
+
+ - name: Finalize Android SDK
+ run: |
+ /bin/bash -c "yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null"
+
+ - name: Run Android unit tests
+ run: ./gradlew testDebugUnitTest
+ working-directory: android
+
build-library:
runs-on: ubuntu-latest
@@ -129,7 +154,7 @@ jobs:
runs-on: macos-latest
env:
- XCODE_VERSION: 16.4
+ XCODE_VERSION: 26.0.1
TURBO_CACHE_DIR: .turbo/ios
RCT_USE_RN_DEP: 1
RCT_USE_PREBUILT_RNCORE: 1
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/CHANGELOG.md b/CHANGELOG.md
index 54dad53..bd8840d 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`. Uses polling-based `requestActivityUpdates` with confidence-based filtering.
+- **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 serial queue with confidence-based filtering.
+- **`ActivityReceiver.kt`**: Manifest-registered `BroadcastReceiver` that captures activity recognition `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` with high confidence (if `pauseLocationWhenStill` is enabled) and resumes when polling indicates movement, 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..b5d97d3 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` (high confidence), 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,22 @@ 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`. Without it, activity tracking will gracefully fall back to standard GPS (no battery optimization) instead of crashing.
+>
+> ```xml
+> NSMotionUsageDescription
+> This app requires motion data to optimize location tracking battery usage.
+> ```
+>
+> **App Store disclosure:** Apps using CoreMotion must disclose motion data collection in their App Store privacy nutrition labels. This library does not persist motion data — it is processed in-memory only — but the consuming app is responsible for accurate privacy disclosure.
+
+> **Activity Recognition on Android:** On Android 10+ (API 29), you must add `android.permission.ACTIVITY_RECOGNITION` to your app's `AndroidManifest.xml` and request it at runtime before enabling activity tracking:
+> ```xml
+>
+> ```
+
+> **Note:** On iOS, activity tracking state is not persisted across crash recovery. If the app is killed and restarted by the system, activity-based GPS pausing will be re-enabled only if `activityTrackingEnabled: true` is passed again. On Android, this state is persisted in Room and survives crash recovery automatically.
+
## Quick Start
```tsx
@@ -63,7 +80,11 @@ function App() {
return (
startTracking({ tripId: 'trip-1', distanceFilter: 10 })}
+ onPress={() =>
+ startTracking('my-trip', {
+ distanceFilter: 10,
+ })
+ }
/>
);
}
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..2c35211
Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..09523c0
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/android/gradlew b/android/gradlew
new file mode 100644
index 0000000..2977f0c
--- /dev/null
+++ b/android/gradlew
@@ -0,0 +1,169 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced functionality shells and target
+# temporary focusing, currentShell=$currentShell
+# temporary focusing, currentShell=$currentShell
+# temporary focusing, currentShell=$currentShell
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld -- "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NonStop* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1 ; then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ ;;
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ ;;
+ esac
+fi
+
+# Collect all arguments for the java command, stracks://issues.gradle.org/browse/GRADLE-2360
+# are resolved.
+#
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+fi
+
+# Collect all arguments for the java command;
+# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
+# shell script including quotes and/or backslashes, so put them in
+# temporary variables so that they are expanded atomically.
+# * Put options and arguments for the java command itself before the class
+# name; put arguments for the Gradle main class after the class name.
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xeli" is not available.
+if ! "$cygwin" && ! "$msys" && ! "$nonstop" ; then
+ case $( set --; ulimit -e 2>/dev/null ) in #(
+ '') : ;; #(
+ * ) : ;;
+ esac
+fi
+
+exec "$JAVACMD" "$@"
diff --git a/android/gradlew.bat b/android/gradlew.bat
new file mode 100644
index 0000000..c7056b9
--- /dev/null
+++ b/android/gradlew.bat
@@ -0,0 +1,81 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %OS%==Windows_NT endlocal
+
+:omega
diff --git a/android/schemas/com.backgroundlocation.database.LocationDatabase/2.json b/android/schemas/com.backgroundlocation.database.LocationDatabase/2.json
new file mode 100644
index 0000000..76ca2f4
--- /dev/null
+++ b/android/schemas/com.backgroundlocation.database.LocationDatabase/2.json
@@ -0,0 +1,366 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 2,
+ "identityHash": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
+ "entities": [
+ {
+ "tableName": "locations",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tripId` TEXT NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `timestamp` INTEGER NOT NULL, `accuracy` REAL, `altitude` REAL, `speed` REAL, `bearing` REAL, `verticalAccuracyMeters` REAL, `speedAccuracyMetersPerSecond` REAL, `bearingAccuracyDegrees` REAL, `elapsedRealtimeNanos` INTEGER, `provider` TEXT, `isFromMockProvider` INTEGER)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tripId",
+ "columnName": "tripId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accuracy",
+ "columnName": "accuracy",
+ "affinity": "REAL",
+ "notNull": false
+ },
+ {
+ "fieldPath": "altitude",
+ "columnName": "altitude",
+ "affinity": "REAL",
+ "notNull": false
+ },
+ {
+ "fieldPath": "speed",
+ "columnName": "speed",
+ "affinity": "REAL",
+ "notNull": false
+ },
+ {
+ "fieldPath": "bearing",
+ "columnName": "bearing",
+ "affinity": "REAL",
+ "notNull": false
+ },
+ {
+ "fieldPath": "verticalAccuracyMeters",
+ "columnName": "verticalAccuracyMeters",
+ "affinity": "REAL",
+ "notNull": false
+ },
+ {
+ "fieldPath": "speedAccuracyMetersPerSecond",
+ "columnName": "speedAccuracyMetersPerSecond",
+ "affinity": "REAL",
+ "notNull": false
+ },
+ {
+ "fieldPath": "bearingAccuracyDegrees",
+ "columnName": "bearingAccuracyDegrees",
+ "affinity": "REAL",
+ "notNull": false
+ },
+ {
+ "fieldPath": "elapsedRealtimeNanos",
+ "columnName": "elapsedRealtimeNanos",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "provider",
+ "columnName": "provider",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "isFromMockProvider",
+ "columnName": "isFromMockProvider",
+ "affinity": "INTEGER",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_locations_tripId",
+ "unique": false,
+ "columnNames": [
+ "tripId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_locations_tripId` ON `${TABLE_NAME}` (`tripId`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "tracking_state",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isActive` INTEGER NOT NULL, `tripId` TEXT, `updateInterval` INTEGER, `fastestInterval` INTEGER, `maxWaitTime` INTEGER, `accuracy` TEXT, `waitForAccurateLocation` INTEGER, `foregroundOnly` INTEGER, `activityTrackingEnabled` INTEGER, `pauseLocationWhenStill` INTEGER, `activityUpdateInterval` INTEGER, `notificationOptionsJson` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isActive",
+ "columnName": "isActive",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "tripId",
+ "columnName": "tripId",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "updateInterval",
+ "columnName": "updateInterval",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "fastestInterval",
+ "columnName": "fastestInterval",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "maxWaitTime",
+ "columnName": "maxWaitTime",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "accuracy",
+ "columnName": "accuracy",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "waitForAccurateLocation",
+ "columnName": "waitForAccurateLocation",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "foregroundOnly",
+ "columnName": "foregroundOnly",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "activityTrackingEnabled",
+ "columnName": "activityTrackingEnabled",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "pauseLocationWhenStill",
+ "columnName": "pauseLocationWhenStill",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "activityUpdateInterval",
+ "columnName": "activityUpdateInterval",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "notificationOptionsJson",
+ "columnName": "notificationOptionsJson",
+ "affinity": "TEXT",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "geofences",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identifier` TEXT NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `radius` REAL NOT NULL, `transitionTypes` INTEGER NOT NULL, `loiteringDelay` INTEGER NOT NULL, `expirationDuration` INTEGER, `metadata` TEXT, `createdAt` INTEGER NOT NULL, `notificationConfig` TEXT, PRIMARY KEY(`identifier`))",
+ "fields": [
+ {
+ "fieldPath": "identifier",
+ "columnName": "identifier",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "radius",
+ "columnName": "radius",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "transitionTypes",
+ "columnName": "transitionTypes",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "loiteringDelay",
+ "columnName": "loiteringDelay",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "expirationDuration",
+ "columnName": "expirationDuration",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "metadata",
+ "columnName": "metadata",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "createdAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "notificationConfig",
+ "columnName": "notificationConfig",
+ "affinity": "TEXT",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "identifier"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "geofence_transitions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `geofenceId` TEXT NOT NULL, `transitionType` TEXT NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `distanceFromCenter` REAL NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "geofenceId",
+ "columnName": "geofenceId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "transitionType",
+ "columnName": "transitionType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "distanceFromCenter",
+ "columnName": "distanceFromCenter",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "metadata",
+ "columnName": "metadata",
+ "affinity": "TEXT",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_geofence_transitions_geofenceId",
+ "unique": false,
+ "columnNames": [
+ "geofenceId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_geofence_transitions_geofenceId` ON `${TABLE_NAME}` (`geofenceId`)"
+ }
+ ],
+ "foreignKeys": []
+ }
+ ],
+ "views": [],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6')"
+ ]
+ }
+}
diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml
index fff9b5e..e202565 100644
--- a/android/src/main/AndroidManifest.xml
+++ b/android/src/main/AndroidManifest.xml
@@ -29,6 +29,12 @@
android:foregroundServiceType="location"
android:stopWithTask="false" />
+
+
+
+ android.util.Log.d("ActivityReceiver", "Received Activity Update: ${getActivityString(activity.type)} (${activity.confidence}%)")
+ LocationService.handleActivityStateChanged(activity.type, activity.confidence)
+ }
+ return
+ }
+ }
+
+ 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/LocationAccuracy.kt b/android/src/main/java/com/backgroundlocation/LocationAccuracy.kt
index b2ebe69..c2c5df2 100644
--- a/android/src/main/java/com/backgroundlocation/LocationAccuracy.kt
+++ b/android/src/main/java/com/backgroundlocation/LocationAccuracy.kt
@@ -3,13 +3,16 @@ package com.backgroundlocation
/**
* Enum representing location accuracy priority levels
* Maps to Android LocationRequest Priority constants
+ *
+ * Uses Kotlin's built-in [Enum.name] for serialization — the enum constant
+ * name IS the string passed across the bridge (e.g. "HIGH_ACCURACY").
*/
-enum class LocationAccuracy(val value: String) {
- HIGH_ACCURACY("HIGH_ACCURACY"),
- BALANCED_POWER_ACCURACY("BALANCED_POWER_ACCURACY"),
- LOW_POWER("LOW_POWER"),
- NO_POWER("NO_POWER"),
- PASSIVE("PASSIVE");
+enum class LocationAccuracy {
+ HIGH_ACCURACY,
+ BALANCED_POWER_ACCURACY,
+ LOW_POWER,
+ NO_POWER,
+ PASSIVE;
companion object {
/**
@@ -17,7 +20,7 @@ enum class LocationAccuracy(val value: String) {
* Returns HIGH_ACCURACY as default if value is invalid
*/
fun fromString(value: String?): LocationAccuracy {
- return values().find { it.value == value } ?: HIGH_ACCURACY
+ return entries.find { it.name == value } ?: HIGH_ACCURACY
}
}
}
diff --git a/android/src/main/java/com/backgroundlocation/LocationService.kt b/android/src/main/java/com/backgroundlocation/LocationService.kt
index c5700e6..c2f799e 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,11 +32,17 @@ 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 val activityResumeGracePeriodMs = 30_000L // 30s grace after resume
+ private var lastResumeTimestampMs: Long = 0L
+ private var activityPendingIntent: PendingIntent? = null
+
// Flag to prevent location events after stop is requested
@Volatile
private var isStopRequested: Boolean = false
@@ -53,6 +61,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 +133,20 @@ class LocationService : Service() {
// Check last known location to verify GPS is working
checkLastKnownLocation()
+ if (trackingOptions.getPauseLocationWhenStillOrDefault() && !trackingOptions.getActivityTrackingEnabledOrDefault()) {
+ android.util.Log.w("LocationService", "pauseLocationWhenStill is enabled but activityTrackingEnabled is false. GPS will NOT pause when stationary. Enable activityTrackingEnabled to use this feature.")
+ emitServiceWarning(currentTripId ?: "", "INVALID_CONFIG", "pauseLocationWhenStill requires activityTrackingEnabled to be true. GPS pausing is disabled.")
+ }
+
+ if (trackingOptions.getActivityTrackingEnabledOrDefault()) {
+ if (activityProvider.isAvailable()) {
+ startActivityUpdates()
+ } else {
+ android.util.Log.w("LocationService", "Activity recognition not available on this device (Play Services missing or outdated). GPS pausing when stationary will not work.")
+ emitServiceWarning(currentTripId ?: "", "ACTIVITY_RECOGNITION_UNAVAILABLE", "Activity recognition not available. GPS pausing when stationary is disabled.")
+ }
+ }
+
// Start location updates
startLocationUpdates()
@@ -156,7 +179,10 @@ class LocationService : Service() {
waitForAccurateLocation = if (bundle.containsKey("waitForAccurateLocation")) bundle.getBoolean("waitForAccurateLocation") else null,
foregroundOnly = if (bundle.containsKey("foregroundOnly")) bundle.getBoolean("foregroundOnly") else null,
distanceFilter = if (bundle.containsKey("distanceFilter")) bundle.getFloat("distanceFilter") else null,
- notificationOptions = notificationOptions
+ notificationOptions = notificationOptions,
+ activityTrackingEnabled = if (bundle.containsKey("activityTrackingEnabled")) bundle.getBoolean("activityTrackingEnabled") else null,
+ pauseLocationWhenStill = if (bundle.containsKey("pauseLocationWhenStill")) bundle.getBoolean("pauseLocationWhenStill") else null,
+ activityUpdateInterval = if (bundle.containsKey("activityUpdateInterval")) bundle.getLong("activityUpdateInterval") else null
)
}
@@ -263,7 +289,7 @@ class LocationService : Service() {
android.util.Log.e("LocationService", "Exception checking last known location", e)
}
}
-
+
@SuppressLint("MissingPermission")
private fun startLocationUpdates() {
val priority = when (trackingOptions.getAccuracyOrDefault()) {
@@ -331,6 +357,59 @@ 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, confidence: Int = 0) {
+ if (!trackingOptions.getActivityTrackingEnabledOrDefault()) return
+ if (isStopRequested) return
+
+ val state = ActivityState(
+ activityType = activityType,
+ confidence = confidence,
+ isCurrentlyPaused = isLocationPausedDueToActivity,
+ isPauseEnabled = trackingOptions.getPauseLocationWhenStillOrDefault(),
+ lastResumeTimestampMs = lastResumeTimestampMs,
+ currentTimeMs = System.currentTimeMillis()
+ )
+
+ when (decidePauseResume(state)) {
+ PauseDecision.PAUSE -> {
+ android.util.Log.d("LocationService", "User is stationary. Pausing GPS updates to save battery.")
+ emitServiceWarning(currentTripId ?: "", "LOCATION_PAUSED_STILL", "Device is stationary. GPS paused to save battery.")
+ locationProvider.removeLocationUpdates()
+ isLocationPausedDueToActivity = true
+ }
+ PauseDecision.RESUME -> {
+ android.util.Log.d("LocationService", "User is moving again. Resuming GPS updates.")
+ emitServiceWarning(currentTripId ?: "", "LOCATION_RESUMED", "Device is moving again. GPS resumed.")
+ lastResumeTimestampMs = System.currentTimeMillis()
+ startLocationUpdates()
+ isLocationPausedDueToActivity = false
+ }
+ PauseDecision.NO_CHANGE -> {}
+ }
+ }
+
/**
* Handles a single location update with processor filtering
*/
@@ -412,28 +491,28 @@ class LocationService : Service() {
val altitude = if (location.hasAltitude()) location.altitude else null
val speed = if (location.hasSpeed()) location.speed else null
val bearing = if (location.hasBearing()) location.bearing else null
-
+
// API 26+ fields - check if values are valid (not NaN)
val verticalAccuracyMeters = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val value = location.verticalAccuracyMeters
if (!value.isNaN()) value else null
} else null
-
+
val speedAccuracyMetersPerSecond = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val value = location.speedAccuracyMetersPerSecond
if (!value.isNaN()) value else null
} else null
-
+
val bearingAccuracyDegrees = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val value = location.bearingAccuracyDegrees
if (!value.isNaN()) value else null
} else null
-
+
val elapsedRealtimeNanos = location.elapsedRealtimeNanos
val provider = location.provider
-
+
val isFromMockProvider = location.isMockLocation()
-
+
storage.saveLocation(
tripId = tripId,
latitude = location.latitude,
@@ -450,12 +529,12 @@ class LocationService : Service() {
provider = provider,
isFromMockProvider = isFromMockProvider
)
-
+
// Emit location update event to React Native
sendLocationUpdateEvent(tripId, location)
}
}
-
+
/**
* Sends a location update event via SharedFlow
*/
@@ -464,7 +543,7 @@ class LocationService : Service() {
LocationEventEmitter.emitLocationUpdate(tripId, locationBundle)
android.util.Log.d("LocationService", "Location event emitted for tripId: $tripId")
}
-
+
/**
* Creates a minimal notification for immediate startForeground() call
@@ -624,6 +703,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 +817,15 @@ class LocationService : Service() {
@Volatile
private var activeInstance: LocationService? = null
+ /**
+ * Routes activity state changes to the active instance securely
+ */
+ fun handleActivityStateChanged(activityType: Int, confidence: Int = 0) {
+ synchronized(instanceLock) {
+ activeInstance?.onActivityStateChanged(activityType, confidence)
+ }
+ }
+
/**
* Sets a stop token to prevent RecoveryWorker from restarting tracking
* Uses SharedPreferences for synchronous, cross-process communication
@@ -812,10 +908,13 @@ class LocationService : Service() {
if (options.updateInterval != null) putLong("updateInterval", options.updateInterval)
if (options.fastestInterval != null) putLong("fastestInterval", options.fastestInterval)
if (options.maxWaitTime != null) putLong("maxWaitTime", options.maxWaitTime)
- if (options.accuracy != null) putString("accuracy", options.accuracy.value)
+ if (options.accuracy != null) putString("accuracy", options.accuracy.name)
if (options.waitForAccurateLocation != null) putBoolean("waitForAccurateLocation", options.waitForAccurateLocation)
if (options.foregroundOnly != null) putBoolean("foregroundOnly", options.foregroundOnly)
if (options.distanceFilter != null) putFloat("distanceFilter", options.distanceFilter)
+ if (options.activityTrackingEnabled != null) putBoolean("activityTrackingEnabled", options.activityTrackingEnabled)
+ if (options.pauseLocationWhenStill != null) putBoolean("pauseLocationWhenStill", options.pauseLocationWhenStill)
+ if (options.activityUpdateInterval != null) putLong("activityUpdateInterval", options.activityUpdateInterval)
options.notificationOptions?.let { putString("notificationOptions", it.toJsonString()) }
}
@@ -838,6 +937,62 @@ class LocationService : Service() {
val intent = Intent(context, LocationService::class.java)
context.stopService(intent)
}
+
+ enum class PauseDecision {
+ PAUSE,
+ RESUME,
+ NO_CHANGE
+ }
+
+ data class ActivityState(
+ val activityType: Int,
+ val confidence: Int,
+ val isCurrentlyPaused: Boolean,
+ val isPauseEnabled: Boolean,
+ val lastResumeTimestampMs: Long,
+ val currentTimeMs: Long
+ ) {
+ companion object {
+ const val CONFIDENCE_THRESHOLD = 70
+ const val RESUME_GRACE_PERIOD_MS = 30_000L
+ }
+ }
+
+ fun decidePauseResume(state: ActivityState): PauseDecision {
+ // Never pause while automotive
+ if (state.activityType == DetectedActivity.IN_VEHICLE) return PauseDecision.NO_CHANGE
+
+ // Only STILL with high confidence counts as stationary
+ val isStationary = state.activityType == DetectedActivity.STILL
+ && state.confidence >= ActivityState.CONFIDENCE_THRESHOLD
+
+ // Exclude TILTING and UNKNOWN from pause trigger
+ val isExcludedType = state.activityType == DetectedActivity.TILTING
+ || state.activityType == DetectedActivity.UNKNOWN
+
+ val shouldPause = isStationary && state.isPauseEnabled
+
+ // Already paused and stationary -> no change
+ if (isStationary && state.isCurrentlyPaused) return PauseDecision.NO_CHANGE
+ // Already paused and excluded type -> no change
+ if (isExcludedType && state.isCurrentlyPaused) return PauseDecision.NO_CHANGE
+
+ // Not paused and not stationary -> no change
+ if (!isStationary && !state.isCurrentlyPaused) return PauseDecision.NO_CHANGE
+ // Not paused and excluded type -> no change
+ if (isExcludedType && !state.isCurrentlyPaused) return PauseDecision.NO_CHANGE
+
+ // Resume grace period: if we recently resumed, don't pause again
+ if (shouldPause && !state.isCurrentlyPaused) {
+ val timeSinceResume = state.currentTimeMs - state.lastResumeTimestampMs
+ if (timeSinceResume < ActivityState.RESUME_GRACE_PERIOD_MS) return PauseDecision.NO_CHANGE
+ }
+
+ if (shouldPause && !state.isCurrentlyPaused) return PauseDecision.PAUSE
+ if (!shouldPause && state.isCurrentlyPaused) return PauseDecision.RESUME
+
+ return PauseDecision.NO_CHANGE
+ }
}
}
diff --git a/android/src/main/java/com/backgroundlocation/LocationStorage.kt b/android/src/main/java/com/backgroundlocation/LocationStorage.kt
index 02a5933..35af027 100644
--- a/android/src/main/java/com/backgroundlocation/LocationStorage.kt
+++ b/android/src/main/java/com/backgroundlocation/LocationStorage.kt
@@ -202,9 +202,12 @@ class LocationStorage(context: Context) {
updateInterval = options?.updateInterval,
fastestInterval = options?.fastestInterval,
maxWaitTime = options?.maxWaitTime,
- accuracy = options?.accuracy?.value,
+ accuracy = options?.accuracy?.name,
waitForAccurateLocation = options?.waitForAccurateLocation,
foregroundOnly = options?.foregroundOnly,
+ activityTrackingEnabled = options?.activityTrackingEnabled,
+ pauseLocationWhenStill = options?.pauseLocationWhenStill,
+ activityUpdateInterval = options?.activityUpdateInterval,
notificationOptionsJson = options?.notificationOptions?.toJsonString()
)
trackingStateDao.upsert(entity)
@@ -228,9 +231,12 @@ class LocationStorage(context: Context) {
updateInterval = options?.updateInterval,
fastestInterval = options?.fastestInterval,
maxWaitTime = options?.maxWaitTime,
- accuracy = options?.accuracy?.value,
+ accuracy = options?.accuracy?.name,
waitForAccurateLocation = options?.waitForAccurateLocation,
foregroundOnly = options?.foregroundOnly,
+ activityTrackingEnabled = options?.activityTrackingEnabled,
+ pauseLocationWhenStill = options?.pauseLocationWhenStill,
+ activityUpdateInterval = options?.activityUpdateInterval,
notificationOptionsJson = options?.notificationOptions?.toJsonString()
)
trackingStateDao.upsert(entity)
@@ -272,6 +278,9 @@ class LocationStorage(context: Context) {
accuracy = entity.accuracy?.let { LocationAccuracy.fromString(it) },
waitForAccurateLocation = entity.waitForAccurateLocation,
foregroundOnly = entity.foregroundOnly,
+ activityTrackingEnabled = entity.activityTrackingEnabled,
+ pauseLocationWhenStill = entity.pauseLocationWhenStill,
+ activityUpdateInterval = entity.activityUpdateInterval,
notificationOptions = notificationOptions
)
} else null
diff --git a/android/src/main/java/com/backgroundlocation/TrackingOptions.kt b/android/src/main/java/com/backgroundlocation/TrackingOptions.kt
index c8c8192..ad9a6ad 100644
--- a/android/src/main/java/com/backgroundlocation/TrackingOptions.kt
+++ b/android/src/main/java/com/backgroundlocation/TrackingOptions.kt
@@ -2,6 +2,12 @@ package com.backgroundlocation
/**
* Data class representing tracking configuration options.
+ *
+ * Default values are inlined as safety-net fallbacks in `get*OrDefault()`
+ * methods. The authoritative source of defaults is
+ * `src/utils/trackingOptionsDefaults.ts` — the JS mapper always sends
+ * every field explicitly, so the `?:` branch here is only reached during
+ * recovery/storage paths where the field was not persisted.
*/
data class TrackingOptions(
val updateInterval: Long? = null,
@@ -11,23 +17,11 @@ 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
- const val DEFAULT_UPDATE_INTERVAL = 5000L // 5 seconds
- const val DEFAULT_FASTEST_INTERVAL = 3000L // 3 seconds
- const val DEFAULT_MAX_WAIT_TIME = 10000L // 10 seconds
- const val DEFAULT_WAIT_FOR_ACCURATE_LOCATION = false
- const val DEFAULT_NOTIFICATION_TITLE = "Location Tracking"
- const val DEFAULT_NOTIFICATION_TEXT = "Tracking your location in background"
- const val DEFAULT_NOTIFICATION_CHANNEL_NAME = "Background Location"
- const val DEFAULT_NOTIFICATION_PRIORITY = "LOW"
- const val DEFAULT_FOREGROUND_ONLY = false
- const val DEFAULT_DISTANCE_FILTER = 0f // No distance filter
- const val DEFAULT_NOTIFICATION_SHOW_TIMESTAMP = false
- }
-
// --- Computed property accessors for fields that LocationService.kt accesses directly ---
val notificationSmallIcon: String? get() = notificationOptions?.smallIcon
@@ -37,65 +31,22 @@ data class TrackingOptions(
val notificationActions: String? get() = notificationOptions?.actions
val notificationChannelId: String? get() = notificationOptions?.channelId
- // --- Default-fallback accessors ---
-
- /**
- * Gets the update interval with default fallback
- */
- fun getUpdateIntervalOrDefault(): Long = updateInterval ?: DEFAULT_UPDATE_INTERVAL
-
- /**
- * Gets the fastest interval with default fallback
- */
- fun getFastestIntervalOrDefault(): Long = fastestInterval ?: DEFAULT_FASTEST_INTERVAL
+ // --- Safety-net default-fallback accessors ---
+ // (Authoritative defaults live in src/utils/trackingOptionsDefaults.ts)
- /**
- * Gets the max wait time with default fallback
- */
- fun getMaxWaitTimeOrDefault(): Long = maxWaitTime ?: DEFAULT_MAX_WAIT_TIME
-
- /**
- * Gets the accuracy with default fallback
- */
+ fun getUpdateIntervalOrDefault(): Long = updateInterval ?: 5000L
+ fun getFastestIntervalOrDefault(): Long = fastestInterval ?: 3000L
+ fun getMaxWaitTimeOrDefault(): Long = maxWaitTime ?: 10000L
fun getAccuracyOrDefault(): LocationAccuracy = accuracy ?: LocationAccuracy.HIGH_ACCURACY
-
- /**
- * Gets waitForAccurateLocation with default fallback
- */
- fun getWaitForAccurateLocationOrDefault(): Boolean = waitForAccurateLocation ?: DEFAULT_WAIT_FOR_ACCURATE_LOCATION
-
- /**
- * Gets the notification title with default fallback
- */
- fun getNotificationTitleOrDefault(): String = notificationOptions?.title ?: DEFAULT_NOTIFICATION_TITLE
-
- /**
- * Gets the notification text with default fallback
- */
- fun getNotificationTextOrDefault(): String = notificationOptions?.text ?: DEFAULT_NOTIFICATION_TEXT
-
- /**
- * Gets the notification channel name with default fallback
- */
- fun getNotificationChannelNameOrDefault(): String = notificationOptions?.channelName ?: DEFAULT_NOTIFICATION_CHANNEL_NAME
-
- /**
- * Gets the notification priority with default fallback
- */
- fun getNotificationPriorityOrDefault(): String = notificationOptions?.priority ?: DEFAULT_NOTIFICATION_PRIORITY
-
- /**
- * Gets foregroundOnly with default fallback
- */
- fun getForegroundOnlyOrDefault(): Boolean = foregroundOnly ?: DEFAULT_FOREGROUND_ONLY
-
- /**
- * Gets the distance filter with default fallback
- */
- fun getDistanceFilterOrDefault(): Float = distanceFilter ?: DEFAULT_DISTANCE_FILTER
-
- /**
- * Gets notificationShowTimestamp with default fallback
- */
- fun getNotificationShowTimestampOrDefault(): Boolean = notificationOptions?.showTimestamp ?: DEFAULT_NOTIFICATION_SHOW_TIMESTAMP
+ fun getWaitForAccurateLocationOrDefault(): Boolean = waitForAccurateLocation ?: false
+ fun getNotificationTitleOrDefault(): String = notificationOptions?.title ?: "Location Tracking"
+ fun getNotificationTextOrDefault(): String = notificationOptions?.text ?: "Tracking your location in background"
+ fun getNotificationChannelNameOrDefault(): String = notificationOptions?.channelName ?: "Background Location"
+ fun getNotificationPriorityOrDefault(): String = notificationOptions?.priority ?: "LOW"
+ fun getForegroundOnlyOrDefault(): Boolean = foregroundOnly ?: false
+ fun getDistanceFilterOrDefault(): Float = distanceFilter ?: 0f
+ fun getNotificationShowTimestampOrDefault(): Boolean = notificationOptions?.showTimestamp ?: false
+ fun getActivityTrackingEnabledOrDefault(): Boolean = activityTrackingEnabled ?: false
+ fun getPauseLocationWhenStillOrDefault(): Boolean = pauseLocationWhenStill ?: false
+ fun getActivityUpdateIntervalOrDefault(): Long = activityUpdateInterval ?: 60000L
}
diff --git a/android/src/main/java/com/backgroundlocation/database/LocationDatabase.kt b/android/src/main/java/com/backgroundlocation/database/LocationDatabase.kt
index 2073221..e58dd39 100644
--- a/android/src/main/java/com/backgroundlocation/database/LocationDatabase.kt
+++ b/android/src/main/java/com/backgroundlocation/database/LocationDatabase.kt
@@ -4,6 +4,8 @@ import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
/**
* Room database for location storage and tracking state
@@ -16,7 +18,7 @@ import androidx.room.RoomDatabase
GeofenceEntity::class,
GeofenceTransitionEntity::class
],
- version = 1,
+ version = 2,
exportSchema = true
)
abstract class LocationDatabase : RoomDatabase() {
@@ -31,9 +33,19 @@ abstract class LocationDatabase : RoomDatabase() {
private const val DATABASE_NAME = "background_location_db"
+ /**
+ * Migration from v1 to v2: add activity-tracking columns to tracking_state.
+ * All columns are nullable so existing rows are preserved.
+ */
+ private val MIGRATION_1_2 = Migration(1, 2) { database ->
+ database.execSQL("ALTER TABLE tracking_state ADD COLUMN activityTrackingEnabled INTEGER")
+ database.execSQL("ALTER TABLE tracking_state ADD COLUMN pauseLocationWhenStill INTEGER")
+ database.execSQL("ALTER TABLE tracking_state ADD COLUMN activityUpdateInterval INTEGER")
+ }
+
/**
* Get database instance (singleton)
- * Uses destructive migration since DB data is transient and rebuilt at runtime
+ * Uses destructive migration only as last-resort safety net.
*/
fun getInstance(context: Context): LocationDatabase {
return INSTANCE ?: synchronized(this) {
@@ -49,6 +61,7 @@ abstract class LocationDatabase : RoomDatabase() {
LocationDatabase::class.java,
DATABASE_NAME
)
+ .addMigrations(MIGRATION_1_2)
.fallbackToDestructiveMigration()
.build()
}
diff --git a/android/src/main/java/com/backgroundlocation/database/TrackingStateEntity.kt b/android/src/main/java/com/backgroundlocation/database/TrackingStateEntity.kt
index 2d7846b..cec4767 100644
--- a/android/src/main/java/com/backgroundlocation/database/TrackingStateEntity.kt
+++ b/android/src/main/java/com/backgroundlocation/database/TrackingStateEntity.kt
@@ -22,6 +22,9 @@ data class TrackingStateEntity(
val accuracy: String? = null,
val waitForAccurateLocation: Boolean? = null,
val foregroundOnly: Boolean? = null,
+ val activityTrackingEnabled: Boolean? = null,
+ val pauseLocationWhenStill: Boolean? = null,
+ val activityUpdateInterval: Long? = null,
// Notification options as a single JSON string
val notificationOptionsJson: String? = null
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..4d5347c
--- /dev/null
+++ b/android/src/main/java/com/backgroundlocation/provider/ActivityProvider.kt
@@ -0,0 +1,39 @@
+package com.backgroundlocation.provider
+
+import android.app.PendingIntent
+import android.content.Context
+
+/**
+ * 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)
+
+ /**
+ * 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..634be54
--- /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
+ }
+}
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..afbbd1e
--- /dev/null
+++ b/android/src/main/java/com/backgroundlocation/provider/ActivityRecognitionProvider.kt
@@ -0,0 +1,68 @@
+package com.backgroundlocation.provider
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.app.PendingIntent
+import android.content.Context
+import android.content.pm.PackageManager
+import androidx.core.content.ContextCompat
+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
+
+/**
+ * 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) {
+ val ctx = context ?: return
+
+ // Check if ACTIVITY_RECOGNITION permission is granted
+ if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.ACTIVITY_RECOGNITION) != PackageManager.PERMISSION_GRANTED) {
+ android.util.Log.w("ActivityRecognitionProvider", "ACTIVITY_RECOGNITION permission not granted. Activity recognition will not work. Please request this permission at runtime before enabling activity tracking.")
+ return
+ }
+
+ activityRecognitionClient?.requestActivityUpdates(intervalMs, pendingIntent)
+ ?.addOnSuccessListener {
+ android.util.Log.d("ActivityRecognitionProvider", "Successfully registered for continuous activity updates")
+ }
+ ?.addOnFailureListener { e ->
+ android.util.Log.e("ActivityRecognitionProvider", "Failed to register for continuous activity updates", e)
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ override fun removeActivityUpdates(pendingIntent: PendingIntent) {
+ activityRecognitionClient?.removeActivityUpdates(pendingIntent)
+ ?.addOnSuccessListener {
+ android.util.Log.d("ActivityRecognitionProvider", "Successfully removed continuous activity updates")
+ }
+ ?.addOnFailureListener { e ->
+ android.util.Log.e("ActivityRecognitionProvider", "Failed to remove continuous activity 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/ActivityStateTest.kt b/android/src/test/java/com/backgroundlocation/ActivityStateTest.kt
new file mode 100644
index 0000000..eb085a8
--- /dev/null
+++ b/android/src/test/java/com/backgroundlocation/ActivityStateTest.kt
@@ -0,0 +1,209 @@
+package com.backgroundlocation
+
+import com.backgroundlocation.LocationService.ActivityState
+import com.backgroundlocation.LocationService.PauseDecision
+import com.google.android.gms.location.DetectedActivity
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ActivityStateTest {
+
+ @Test
+ fun `STILL with confidence >= 70 triggers PAUSE when not currently paused and pause enabled`() {
+ val state = ActivityState(
+ activityType = DetectedActivity.STILL,
+ confidence = 75,
+ isCurrentlyPaused = false,
+ isPauseEnabled = true,
+ lastResumeTimestampMs = 0L,
+ currentTimeMs = 100_000L
+ )
+ assertEquals(PauseDecision.PAUSE, LocationService.decidePauseResume(state))
+ }
+
+ @Test
+ fun `STILL with confidence exactly 70 triggers PAUSE`() {
+ val state = ActivityState(
+ activityType = DetectedActivity.STILL,
+ confidence = 70,
+ isCurrentlyPaused = false,
+ isPauseEnabled = true,
+ lastResumeTimestampMs = 0L,
+ currentTimeMs = 100_000L
+ )
+ assertEquals(PauseDecision.PAUSE, LocationService.decidePauseResume(state))
+ }
+
+ @Test
+ fun `STILL with confidence below 70 returns NO_CHANGE`() {
+ val state = ActivityState(
+ activityType = DetectedActivity.STILL,
+ confidence = 50,
+ isCurrentlyPaused = false,
+ isPauseEnabled = true,
+ lastResumeTimestampMs = 0L,
+ currentTimeMs = 100_000L
+ )
+ assertEquals(PauseDecision.NO_CHANGE, LocationService.decidePauseResume(state))
+ }
+
+ @Test
+ fun `IN_VEHICLE returns NO_CHANGE regardless of other parameters`() {
+ val state = ActivityState(
+ activityType = DetectedActivity.IN_VEHICLE,
+ confidence = 99,
+ isCurrentlyPaused = false,
+ isPauseEnabled = true,
+ lastResumeTimestampMs = 0L,
+ currentTimeMs = 100_000L
+ )
+ assertEquals(PauseDecision.NO_CHANGE, LocationService.decidePauseResume(state))
+ }
+
+ @Test
+ fun `TILTING returns NO_CHANGE`() {
+ val state = ActivityState(
+ activityType = DetectedActivity.TILTING,
+ confidence = 90,
+ isCurrentlyPaused = false,
+ isPauseEnabled = true,
+ lastResumeTimestampMs = 0L,
+ currentTimeMs = 100_000L
+ )
+ assertEquals(PauseDecision.NO_CHANGE, LocationService.decidePauseResume(state))
+ }
+
+ @Test
+ fun `UNKNOWN returns NO_CHANGE`() {
+ val state = ActivityState(
+ activityType = DetectedActivity.UNKNOWN,
+ confidence = 50,
+ isCurrentlyPaused = false,
+ isPauseEnabled = true,
+ lastResumeTimestampMs = 0L,
+ currentTimeMs = 100_000L
+ )
+ assertEquals(PauseDecision.NO_CHANGE, LocationService.decidePauseResume(state))
+ }
+
+ @Test
+ fun `already paused with STILL returns NO_CHANGE`() {
+ val state = ActivityState(
+ activityType = DetectedActivity.STILL,
+ confidence = 95,
+ isCurrentlyPaused = true,
+ isPauseEnabled = true,
+ lastResumeTimestampMs = 0L,
+ currentTimeMs = 100_000L
+ )
+ assertEquals(PauseDecision.NO_CHANGE, LocationService.decidePauseResume(state))
+ }
+
+ @Test
+ fun `already paused with TILTING returns NO_CHANGE`() {
+ val state = ActivityState(
+ activityType = DetectedActivity.TILTING,
+ confidence = 90,
+ isCurrentlyPaused = true,
+ isPauseEnabled = true,
+ lastResumeTimestampMs = 0L,
+ currentTimeMs = 100_000L
+ )
+ assertEquals(PauseDecision.NO_CHANGE, LocationService.decidePauseResume(state))
+ }
+
+ @Test
+ fun `not paused and not stationary returns NO_CHANGE`() {
+ val state = ActivityState(
+ activityType = DetectedActivity.WALKING,
+ confidence = 80,
+ isCurrentlyPaused = false,
+ isPauseEnabled = true,
+ lastResumeTimestampMs = 0L,
+ currentTimeMs = 100_000L
+ )
+ assertEquals(PauseDecision.NO_CHANGE, LocationService.decidePauseResume(state))
+ }
+
+ @Test
+ fun `resume grace period: within 30s of resume, NO_CHANGE`() {
+ val resumeTime = 100_000L
+ val state = ActivityState(
+ activityType = DetectedActivity.STILL,
+ confidence = 85,
+ isCurrentlyPaused = false,
+ isPauseEnabled = true,
+ lastResumeTimestampMs = resumeTime,
+ currentTimeMs = resumeTime + 10_000L // 10 seconds after resume
+ )
+ assertEquals(PauseDecision.NO_CHANGE, LocationService.decidePauseResume(state))
+ }
+
+ @Test
+ fun `resume grace period: exactly at 30s boundary, NO_CHANGE`() {
+ val resumeTime = 100_000L
+ val state = ActivityState(
+ activityType = DetectedActivity.STILL,
+ confidence = 85,
+ isCurrentlyPaused = false,
+ isPauseEnabled = true,
+ lastResumeTimestampMs = resumeTime,
+ currentTimeMs = resumeTime + 29_999L // 29999ms < 30000ms
+ )
+ assertEquals(PauseDecision.NO_CHANGE, LocationService.decidePauseResume(state))
+ }
+
+ @Test
+ fun `resume grace period: after 30s of resume, PAUSE`() {
+ val resumeTime = 100_000L
+ val state = ActivityState(
+ activityType = DetectedActivity.STILL,
+ confidence = 85,
+ isCurrentlyPaused = false,
+ isPauseEnabled = true,
+ lastResumeTimestampMs = resumeTime,
+ currentTimeMs = resumeTime + 35_000L // 35 seconds after resume
+ )
+ assertEquals(PauseDecision.PAUSE, LocationService.decidePauseResume(state))
+ }
+
+ @Test
+ fun `STILL but pause not enabled returns NO_CHANGE`() {
+ val state = ActivityState(
+ activityType = DetectedActivity.STILL,
+ confidence = 95,
+ isCurrentlyPaused = false,
+ isPauseEnabled = false,
+ lastResumeTimestampMs = 0L,
+ currentTimeMs = 100_000L
+ )
+ assertEquals(PauseDecision.NO_CHANGE, LocationService.decidePauseResume(state))
+ }
+
+ @Test
+ fun `not paused but moving triggers RESUME`() {
+ val state = ActivityState(
+ activityType = DetectedActivity.WALKING,
+ confidence = 80,
+ isCurrentlyPaused = true,
+ isPauseEnabled = true,
+ lastResumeTimestampMs = 0L,
+ currentTimeMs = 100_000L
+ )
+ assertEquals(PauseDecision.RESUME, LocationService.decidePauseResume(state))
+ }
+
+ @Test
+ fun `STILL with lastResumeTimestampMs at 0 returns PAUSE`() {
+ val state = ActivityState(
+ activityType = DetectedActivity.STILL,
+ confidence = 80,
+ isCurrentlyPaused = false,
+ isPauseEnabled = true,
+ lastResumeTimestampMs = 0L,
+ currentTimeMs = 100_000L
+ )
+ // currentTimeMs - lastResumeTimestampMs = 100000 >= 30000, outside grace period
+ assertEquals(PauseDecision.PAUSE, LocationService.decidePauseResume(state))
+ }
+}
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..179e158
--- /dev/null
+++ b/android/src/test/java/com/backgroundlocation/provider/ActivityRecognitionProviderTest.kt
@@ -0,0 +1,78 @@
+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.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)
+ 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 `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/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml
index d91f274..b970a70 100644
--- a/example/android/app/src/main/AndroidManifest.xml
+++ b/example/android/app/src/main/AndroidManifest.xml
@@ -10,6 +10,8 @@
+
+
+
+
diff --git a/example/ios/BackgroundLocationExample/Info.plist b/example/ios/BackgroundLocationExample/Info.plist
index a83ec6e..25d1593 100644
--- a/example/ios/BackgroundLocationExample/Info.plist
+++ b/example/ios/BackgroundLocationExample/Info.plist
@@ -37,6 +37,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
diff --git a/ios/ActivityProvider.swift b/ios/ActivityProvider.swift
new file mode 100644
index 0000000..735f7fb
--- /dev/null
+++ b/ios/ActivityProvider.swift
@@ -0,0 +1,71 @@
+import Foundation
+import CoreMotion
+
+@objc public protocol ActivityProviderDelegate: AnyObject {
+ func onActivityStateChanged(isStationary: Bool, activityDescription: String, confidence: CMMotionActivityConfidence)
+}
+
+@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
+ }
+
+ guard Bundle.main.object(forInfoDictionaryKey: "NSMotionUsageDescription") != nil else {
+ NSLog("[BackgroundLocation] CRITICAL WARNING: Cannot start Activity Tracking because NSMotionUsageDescription is missing from Info.plist. GPS will not be paused when stationary.")
+ return
+ }
+
+ let authStatus = CMMotionActivityManager.authorizationStatus()
+ if authStatus == .denied || authStatus == .restricted {
+ NSLog("[BackgroundLocation] Motion activity authorization denied or restricted. Activity recognition will not work. Please grant Motion & Fitness permission in Settings.")
+ 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, confidence: activity.confidence)
+ }
+ }
+
+ @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..0637265 100644
--- a/ios/LocationManagerWrapper.swift
+++ b/ios/LocationManagerWrapper.swift
@@ -1,7 +1,8 @@
import Foundation
import CoreLocation
+import CoreMotion
-@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 +13,12 @@ import CoreLocation
private var _currentTripId: String?
private var _currentOptions: TrackingOptions?
+ private var activityProvider: ActivityProvider?
+ private var isLocationPausedDueToActivity = false
+ private let activityQueue = DispatchQueue(label: "com.backgroundlocation.activity", qos: .userInitiated)
+ private var lastResumeTimestamp: Date?
+ private let resumeGracePeriodSeconds: TimeInterval = 30
+
// MARK: - Event Emission Closures
@objc public var onLocationUpdate: (([String: Any]) -> Void)?
@objc public var onLocationWarning: (([String: Any]) -> Void)?
@@ -87,6 +94,27 @@ import CoreLocation
configureAndStart(options: opts)
+ if opts.shouldPauseLocationWhenStill && !opts.isActivityTrackingEnabled {
+ NSLog("[BackgroundLocation] WARNING: pauseLocationWhenStill is enabled but activityTrackingEnabled is false. GPS will NOT pause when stationary.")
+ var warningData: [String: Any] = [
+ "type": "INVALID_CONFIG",
+ "message": "pauseLocationWhenStill requires activityTrackingEnabled to be true. GPS pausing is disabled.",
+ ]
+ warningData["tripId"] = effectiveTripId
+ self.onLocationWarning?(warningData)
+ }
+
+ 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()
@@ -108,6 +136,11 @@ import CoreLocation
LocationStorage.shared.setStopToken()
LocationStorage.shared.saveTrackingStateSync(tripId: nil, isActive: false, options: nil)
+ // Stop activity provider on serial queue (not main)
+ activityProvider?.stopTracking()
+ activityProvider = nil
+ isLocationPausedDueToActivity = false
+
DispatchQueue.main.async { [weak self] in
self?.locationManager?.stopUpdatingLocation()
self?.locationManager?.stopMonitoringSignificantLocationChanges()
@@ -419,6 +452,67 @@ import CoreLocation
}
}
+ // MARK: - ActivityProviderDelegate
+
+ public func onActivityStateChanged(isStationary: Bool, activityDescription: String, confidence: CMMotionActivityConfidence) {
+ queue.async { [weak self] in
+ guard let self = self, self._isTracking, let options = self._currentOptions else { return }
+
+ let shouldPause = isStationary && options.shouldPauseLocationWhenStill
+
+ // Only stationary with medium or high confidence counts
+ let isConfident = confidence != .low
+ let effectiveIsStationary = isStationary && isConfident
+
+ // Early return if state hasn't changed
+ if effectiveIsStationary && self.isLocationPausedDueToActivity { return }
+ if !effectiveIsStationary && !self.isLocationPausedDueToActivity { return }
+
+ // Resume grace period: if we recently resumed, don't pause again
+ if effectiveIsStationary && !self.isLocationPausedDueToActivity {
+ if let lastResume = self.lastResumeTimestamp {
+ let elapsed = Date().timeIntervalSince(lastResume)
+ if elapsed < self.resumeGracePeriodSeconds { return }
+ }
+ }
+
+ if shouldPause && !self.isLocationPausedDueToActivity {
+ NSLog("[BackgroundLocation] User is stationary (\(activityDescription), confidence=\(confidence.rawValue)). Pausing GPS updates.")
+
+ // Emit warning
+ var eventData: [String: Any] = [
+ "type": "LOCATION_PAUSED_STILL",
+ "message": "Device is stationary. GPS paused to save battery.",
+ ]
+ if let tripId = self._currentTripId { eventData["tripId"] = tripId }
+ self.onLocationWarning?(eventData)
+
+ DispatchQueue.main.async {
+ self.locationManager?.stopUpdatingLocation()
+ }
+ self.isLocationPausedDueToActivity = true
+ } else if !shouldPause && self.isLocationPausedDueToActivity {
+ NSLog("[BackgroundLocation] User is moving again (\(activityDescription)). Resuming GPS updates.")
+ self.lastResumeTimestamp = Date()
+
+ // Emit warning
+ var eventData: [String: Any] = [
+ "type": "LOCATION_RESUMED",
+ "message": "Device is moving again. GPS resumed.",
+ ]
+ if let tripId = self._currentTripId { eventData["tripId"] = tripId }
+ self.onLocationWarning?(eventData)
+
+ DispatchQueue.main.async {
+ 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..66a12c7 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
@@ -21,6 +24,9 @@ import CoreLocation
self.updateInterval = nil
self.foregroundOnly = nil
self.waitForAccurateLocation = nil
+ self.activityTrackingEnabled = nil
+ self.pauseLocationWhenStill = nil
+ self.activityUpdateInterval = nil
self.notificationOptions = nil
super.init()
return
@@ -32,6 +38,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 +128,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 +181,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/package.json b/package.json
index 6f4b6a5..dc3527b 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "@gabriel-sisjr/react-native-background-location",
- "version": "1.0.0",
- "description": "React Native library for background location tracking using TurboModules. Track user location even when the app is minimized or in the background.",
+ "version": "1.1.0",
+ "description": "React Native library for background location tracking using TurboModules. Track user location even when the app is minimized or in the background. Includes battery-efficient Activity Recognition for dynamic GPS throttling.",
"main": "./lib/module/index.js",
"types": "./lib/typescript/src/index.d.ts",
"exports": {
@@ -73,7 +73,12 @@
"event-driven",
"location-updates",
"persistent-storage",
- "location-permissions"
+ "location-permissions",
+ "activity-recognition",
+ "motion-detection",
+ "battery-optimization",
+ "still-detection",
+ "gps-throttling"
],
"repository": {
"type": "git",
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..cd6eba5 100644
--- a/src/types/tracking.ts
+++ b/src/types/tracking.ts
@@ -159,7 +159,9 @@ export interface LocationUpdateEvent {
export type LocationWarningType =
| 'SERVICE_TIMEOUT'
| 'TASK_REMOVED'
- | 'LOCATION_UNAVAILABLE';
+ | 'LOCATION_UNAVAILABLE'
+ | 'LOCATION_PAUSED_STILL'
+ | 'LOCATION_RESUMED';
/**
* Warning event emitted by the location service
@@ -175,6 +177,8 @@ export interface LocationWarningEvent {
* - SERVICE_TIMEOUT: Android 15+ foreground service timeout reached, service is restarting
* - TASK_REMOVED: App was swiped from recents, tracking continues in background
* - LOCATION_UNAVAILABLE: GPS signal lost or location services disabled
+ * - LOCATION_PAUSED_STILL: Activity recognition paused GPS because device is stationary
+ * - LOCATION_RESUMED: Activity recognition resumed GPS because device is moving again
*/
type: LocationWarningType;
/**
@@ -299,6 +303,26 @@ 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)
+ * @platform Android
+ */
+ 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/trackingOptionsDefaults.ts b/src/utils/trackingOptionsDefaults.ts
new file mode 100644
index 0000000..5ac4bb3
--- /dev/null
+++ b/src/utils/trackingOptionsDefaults.ts
@@ -0,0 +1,26 @@
+/**
+ * Single source of truth for all tracking option defaults.
+ *
+ * EVERY new tracking option MUST be added here.
+ * Native platforms (Android / iOS) keep backup defaults for recovery paths
+ * but should reference this file as the authoritative source.
+ *
+ * Values here are in the final `TrackingOptionsSpec` format
+ * (strings for enums, primitives for everything else) so the
+ * mapping function can apply them directly.
+ *
+ * @internal
+ */
+export const TRACKING_OPTIONS_DEFAULTS = {
+ updateInterval: 5000,
+ fastestInterval: 3000,
+ maxWaitTime: 10000,
+ accuracy: 'HIGH_ACCURACY',
+ activityType: 'OTHER',
+ waitForAccurateLocation: false,
+ foregroundOnly: false,
+ distanceFilter: 0,
+ activityTrackingEnabled: false,
+ pauseLocationWhenStill: false,
+ activityUpdateInterval: 60000,
+} as const;
diff --git a/src/utils/trackingOptionsMapper.ts b/src/utils/trackingOptionsMapper.ts
index d14a3cb..24cff5f 100644
--- a/src/utils/trackingOptionsMapper.ts
+++ b/src/utils/trackingOptionsMapper.ts
@@ -7,13 +7,23 @@ import type { TrackingOptionsSpec } from '../NativeBackgroundLocation';
* expected by the TurboModule Codegen contract (strings + JSON-stringified
* notification options).
*
+ * Fields not provided by the caller are left as `undefined` so native
+ * platforms can apply their own fallback defaults.
+ *
* @internal
*/
export function toTrackingOptionsSpec(
options?: TrackingOptions | null
): TrackingOptionsSpec {
if (!options) {
- return {};
+ return {} as TrackingOptionsSpec;
+ }
+
+ if (options.pauseLocationWhenStill && !options.activityTrackingEnabled) {
+ console.warn(
+ '[react-native-background-location] pauseLocationWhenStill requires activityTrackingEnabled to be true. ' +
+ 'Without activityTrackingEnabled, pauseLocationWhenStill has no effect.'
+ );
}
return {
@@ -27,6 +37,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,