Android Headless Mode - transistorsoft/capacitor-background-geolocation GitHub Wiki
BackgroundGeolocation Android "Headless" mode is the state where your app has been terminated while the plugin is configured with app.stopOnTerminate: false. In this state, your Capacitor JavaScript app no longer exists. Any JavaScript event-listeners you've registered with the plugin will no longer fire. Only the plugin's native Android service continues to run, tracking locations and posting them to your server through your configured http.url.
So what if you need to handle some business logic in this state?
If you're willing to get your feet wet with a bit of Android Java (or Kotlin) programming, BackgroundGeolocation lets you provide a native class of your own that receives the plugin's events while the app is headless. From that class you can call the plugin's native Android API, much as you would its JavaScript API, in addition to the entire Android API.
⚠️ Capacitor has no JavaScript headless API:
registerHeadlessTaskdoes not run your callback, so don't call it. On Capacitor, headless events are delivered only to the native Android class described on this page.
iOS has no headless mode. With
stopOnTerminate: false, iOS relaunches your terminated app in the background when the device exits the stationary geofence around its last position, and tracking resumes. Everything on this page is Android-only.
With stopOnTerminate: false, the plugin keeps recording locations and posting them to your http.url whether or not you enable headless mode. You need enableHeadless only when you want to run your own code in the headless state (for example, to post a local notification).
Android Headless Setup
Step 1 — app.enableHeadless: true
In your application code, configure BackgroundGeolocation.ready with enableHeadless: true and stopOnTerminate: false. Both live in the app group of the config:
import BackgroundGeolocation from '@transistorsoft/capacitor-background-geolocation';
BackgroundGeolocation.ready({
app: {
enableHeadless: true,
stopOnTerminate: false
}
// ...the rest of your config
});
Older versions of this page showed a flat config ({ enableHeadless: true, stopOnTerminate: false }). In TypeScript that no longer compiles, because the options now belong under app.
Step 2 — Where the class must live
The plugin finds your class by name at runtime. It looks for exactly this class:
<applicationId>.BackgroundGeolocationHeadlessTask
where <applicationId> is your app's application ID, the value Android's Context.getPackageName() returns at runtime. That means:
- The class must be named
BackgroundGeolocationHeadlessTask. - Its
packageline must be your application ID. That's theapplicationIdinandroid/app/build.gradle, plus anyapplicationIdSuffixthat your build type or product flavor adds.
In a new Capacitor project, the application ID is the appId from your capacitor.config, and MainActivity is declared in that same package. So by default the class goes right beside MainActivity:
android/app/src/main/java/com/example/myapp/MainActivity.java
android/app/src/main/java/com/example/myapp/BackgroundGeolocationHeadlessTask.java <-- package com.example.myapp;
⚠️ The package is your application ID, not the package
MainActivityis in, and not the Gradlenamespace. They start out identical in a Capacitor project, but they can drift apart:
- If a build type or product flavor sets
applicationIdSuffix(for example.debug), that build's application ID becomescom.example.myapp.debug, and the plugin looks forcom.example.myapp.debug.BackgroundGeolocationHeadlessTask. A class incom.example.myappis not found in that build. Each build variant looks for the class under its own application ID.- If you changed
applicationIdafter the project was created,MainActivityand thenamespacestill use the old package.When the class isn't found, the plugin drops every headless event, and logs the full class name it looked for. See Testing below.
To see the application ID of an installed build, you can run adb shell pm list packages | grep myapp (use part of your app's ID in place of myapp).
Step 3 — Create the class
- Open your app's Android project in Android Studio (
npx cap open android). In the Project view, browse theappmodule'sjavafolder to the package containingMainActivity(provided that package is your application ID; see Step 2). Right-click it and choose New > Java Class. - You MUST name the class
BackgroundGeolocationHeadlessTask. - Replace the contents of
BackgroundGeolocationHeadlessTask.javawith the following code, but keep thepackageline Android Studio generated at the top. Make sure that line names your application ID (see Step 2).
The plugin instantiates your class with Java reflection, so:
- The class must be
publicand have a public no-argument constructor (the default constructor is fine). - It must have a public method annotated with EventBus's
@Subscribethat takes a singleHeadlessEventargument. - It needs no
AndroidManifest.xmlentry and no interface. - You don't need a ProGuard / R8 rule: the plugin's own consumer rules already keep any class named
BackgroundGeolocationHeadlessTask.
package com.example.myapp; // <-- Your applicationId. See "Where the class must live" above.
import android.location.Location;
import android.util.Log;
import com.transistorsoft.locationmanager.adapter.BackgroundGeolocation;
import com.transistorsoft.locationmanager.event.ActivityChangeEvent;
import com.transistorsoft.locationmanager.event.AuthorizationEvent;
import com.transistorsoft.locationmanager.event.ConnectivityChangeEvent;
import com.transistorsoft.locationmanager.event.EventName;
import com.transistorsoft.locationmanager.event.GeofenceEvent;
import com.transistorsoft.locationmanager.event.GeofencesChangeEvent;
import com.transistorsoft.locationmanager.event.HeadlessEvent;
import com.transistorsoft.locationmanager.event.HeartbeatEvent;
import com.transistorsoft.locationmanager.event.LocationEvent;
import com.transistorsoft.locationmanager.event.LocationFilterEvent;
import com.transistorsoft.locationmanager.event.LocationProviderChangeEvent;
import com.transistorsoft.locationmanager.event.MotionChangeEvent;
import com.transistorsoft.locationmanager.http.HttpResponse;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.json.JSONObject;
/**
* Receives BackgroundGeolocation events on Android while the app is terminated
* (stopOnTerminate: false, enableHeadless: true). Only the SDK's native service is
* running, so these are the same events your JavaScript listeners would receive,
* delivered here instead.
*
* You might use it to:
* - fetch / post information to your server (eg: request a new API key)
* - call the SDK's native API (eg: getCurrentPosition, addGeofence, stop)
*/
public class BackgroundGeolocationHeadlessTask {
private static final String TAG = "TSLocationManager";
@Subscribe(threadMode = ThreadMode.MAIN)
public void onHeadlessTask(HeadlessEvent event) {
// The SDK's native API, should you need it (see "Using the native API" below).
BackgroundGeolocation bgGeo = BackgroundGeolocation.getInstance(event.getContext());
String name = event.getName();
Log.d(TAG, "💀 BackgroundGeolocationHeadlessTask: " + name);
switch (name) {
case EventName.BOOT: {
JSONObject state = event.getBootEvent();
break;
}
case EventName.TERMINATE: {
JSONObject state = event.getTerminateEvent();
break;
}
case EventName.LOCATION: {
LocationEvent location = event.getLocationEvent();
break;
}
case EventName.LOCATION_ERROR: {
Object error = event.getEvent(); // No typed getter for this event.
break;
}
case EventName.MOTIONCHANGE: {
MotionChangeEvent motionChange = event.getMotionChangeEvent();
boolean isMoving = motionChange.getIsMoving();
Location location = motionChange.getLocation();
break;
}
case EventName.HTTP: {
HttpResponse response = event.getHttpEvent();
int status = response.getStatus();
break;
}
case EventName.PROVIDERCHANGE: {
LocationProviderChangeEvent providerChange = event.getProviderChangeEvent();
break;
}
case EventName.ACTIVITYCHANGE: {
ActivityChangeEvent activityChange = event.getActivityChangeEvent();
break;
}
case EventName.SCHEDULE: {
JSONObject state = event.getScheduleEvent();
break;
}
case EventName.GEOFENCE: {
GeofenceEvent geofenceEvent = event.getGeofenceEvent();
break;
}
case EventName.GEOFENCESCHANGE: {
GeofencesChangeEvent geofencesChange = event.getGeofencesChangeEvent();
break;
}
case EventName.HEARTBEAT: {
HeartbeatEvent heartbeatEvent = event.getHeartbeatEvent();
break;
}
case EventName.ENABLEDCHANGE: {
boolean enabled = event.getEnabledChangeEvent();
break;
}
case EventName.CONNECTIVITYCHANGE: {
ConnectivityChangeEvent connectivityChange = event.getConnectivityChangeEvent();
break;
}
case EventName.POWERSAVECHANGE: {
boolean powerSaveEnabled = event.getPowerSaveChangeEvent().isPowerSaveMode();
break;
}
case EventName.NOTIFICATIONACTION: {
String buttonId = event.getNotificationEvent();
break;
}
case EventName.AUTHORIZATION: {
AuthorizationEvent authorization = event.getAuthorizationEvent();
break;
}
case EventName.LOCATIONFILTER: {
LocationFilterEvent locationFilter = event.getLocationFilterEvent();
break;
}
default:
Log.d(TAG, "Unknown headless event: " + name);
}
}
}
ThreadMode.MAIN runs your method on Android's main thread. Hand network requests and any other long-running work to a background thread: Android doesn't allow network access on the main thread.
Kotlin
Kotlin works too, provided your app module applies the Kotlin Android Gradle plugin (a Capacitor app's android/app module is Java-only out of the box). Name the file BackgroundGeolocationHeadlessTask.kt. The same rules apply: the class name, and a package equal to your application ID.
package com.example.myapp // <-- Your applicationId. See "Where the class must live" above.
import android.util.Log
import com.transistorsoft.locationmanager.adapter.BackgroundGeolocation
import com.transistorsoft.locationmanager.event.EventName
import com.transistorsoft.locationmanager.event.HeadlessEvent
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
class BackgroundGeolocationHeadlessTask {
companion object {
private const val TAG = "TSLocationManager"
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onHeadlessTask(event: HeadlessEvent) {
// The SDK's native API, should you need it.
val bgGeo = BackgroundGeolocation.getInstance(event.context)
Log.d(TAG, "💀 BackgroundGeolocationHeadlessTask: ${event.name}")
when (event.name) {
EventName.BOOT -> { val state = event.bootEvent } // JSONObject
EventName.TERMINATE -> { val state = event.terminateEvent } // JSONObject
EventName.LOCATION -> { val location = event.locationEvent } // LocationEvent
EventName.LOCATION_ERROR -> { val error = event.event } // No typed getter
EventName.MOTIONCHANGE -> {
val motionChange = event.motionChangeEvent // MotionChangeEvent
val isMoving = motionChange.isMoving
val location = motionChange.location // android.location.Location
}
EventName.HTTP -> { val status = event.httpEvent.status } // HttpResponse
EventName.PROVIDERCHANGE -> { val providerChange = event.providerChangeEvent }
EventName.ACTIVITYCHANGE -> { val activityChange = event.activityChangeEvent }
EventName.SCHEDULE -> { val state = event.scheduleEvent } // JSONObject
EventName.GEOFENCE -> { val geofenceEvent = event.geofenceEvent }
EventName.GEOFENCESCHANGE -> { val geofencesChange = event.geofencesChangeEvent }
EventName.HEARTBEAT -> { val heartbeatEvent = event.heartbeatEvent }
EventName.ENABLEDCHANGE -> { val enabled = event.enabledChangeEvent } // Boolean
EventName.CONNECTIVITYCHANGE -> { val connectivityChange = event.connectivityChangeEvent }
EventName.POWERSAVECHANGE -> { val powerSaveEnabled = event.powerSaveChangeEvent.isPowerSaveMode() }
EventName.NOTIFICATIONACTION -> { val buttonId = event.notificationEvent } // String
EventName.AUTHORIZATION -> { val authorization = event.authorizationEvent }
EventName.LOCATIONFILTER -> { val locationFilter = event.locationFilterEvent }
else -> Log.d(TAG, "Unknown headless event: ${event.name}")
}
}
}
Using the native API
The first step to interacting with the plugin's native Android API is to get a reference to it:
BackgroundGeolocation bgGeo = BackgroundGeolocation.getInstance(event.getContext());
From here you can call the plugin's native methods, such as getCurrentPosition, start, stop, changePace, addGeofence and sync. Their signatures are Java, not JavaScript. To see how a JavaScript method maps onto the native API, consult the Capacitor plugin's own Android bridge, BackgroundGeolocationPlugin.java. Most JavaScript methods are thin wrappers around the matching native call.
For example, the JavaScript getCurrentPosition builds a TSCurrentPositionRequest. To do the same from your headless task, add these imports:
import com.transistorsoft.locationmanager.adapter.callback.TSLocationCallback;
import com.transistorsoft.locationmanager.event.LocationEvent;
import com.transistorsoft.locationmanager.location.TSCurrentPositionRequest;
import org.json.JSONException;
and then:
BackgroundGeolocation bgGeo = BackgroundGeolocation.getInstance(event.getContext());
TSCurrentPositionRequest request = new TSCurrentPositionRequest.Builder(event.getContext())
.setSamples(1)
.setPersist(false) // Don't insert this location into the SDK's database.
.setCallback(new TSLocationCallback() {
@Override
public void onLocation(LocationEvent location) {
try {
Log.d(TAG, "- getCurrentPosition SUCCESS: " + location.toJson());
} catch (JSONException e) {
Log.e(TAG, "- getCurrentPosition: " + e.getMessage());
}
}
@Override
public void onError(Integer errorCode) {
Log.d(TAG, "- getCurrentPosition FAILURE: " + errorCode);
}
})
.build();
bgGeo.getCurrentPosition(request);
The builder also has setTimeout, setMaximumAge, setDesiredAccuracy and setExtras, matching the JavaScript options of the same names.
Yes, it's Java. The syntax is more chatty, but it's really very similar to the JavaScript API.
Testing
By default the plugin's log messages don't reach logcat (logger.logLevel defaults to Off). While testing, turn logging up:
BackgroundGeolocation.ready({
logger: {
logLevel: BackgroundGeolocation.LogLevel.Verbose
},
app: {
enableHeadless: true,
stopOnTerminate: false
}
});
Then start tracking, terminate the app (swipe it away in the recent-apps screen) and watch $ adb logcat. Every event the plugin sends to your class is logged just before delivery, prefixed with "💀" (as in dead / terminated). A terminate event arrives after you terminate the app, though not always immediately: the plugin schedules it with an alarm, which Android may delay. Other events follow as they occur.
When it's working, you'll see the plugin's line followed by your class's own Log.d (abridged):
$ adb logcat -s TSLocationManager
D TSLocationManager: [HeadlessEventTx fire] 🛜 💀⚡️ terminate
D TSLocationManager: 💀 BackgroundGeolocationHeadlessTask: terminate
D TSLocationManager: [HeadlessEventTx fire] 🛜 💀⚡️ location
D TSLocationManager: 💀 BackgroundGeolocationHeadlessTask: location
If enableHeadless is not true, each event is skipped instead:
D TSLocationManager: [HeadlessEventTx fire] 🛜 💀 ⏭️ skip location (enableHeadless=false)
If the plugin can't find your class, it logs the full class name it looked for (here, a build with applicationIdSuffix ".debug"), and then warns that the event has no listener. It does this for every event. The error message's wiki link points at the Cordova wiki; for Capacitor, this page is the one that applies. Abridged:
E TSLocationManager: [HeadlessEventTx ...] HeadlessTask failed to find com.example.myapp.debug.BackgroundGeolocationHeadlessTask.java. If you've configured enableHeadless: true, you must provide a custom BackgroundGeolocationHeadlessTask.java. See Wiki: ...
W TSLocationManager: [HeadlessEventTx fire]
W TSLocationManager: ⚠️ Attempted to post headless event location but there are no listeners (headlessJobService may be missing or failed to register).
Compare that class name with your class's package line (see Step 2).