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: registerHeadlessTask does 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 package line must be your application ID. That's the applicationId in android/app/build.gradle, plus any applicationIdSuffix that 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 MainActivity is in, and not the Gradle namespace. 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 becomes com.example.myapp.debug, and the plugin looks for com.example.myapp.debug.BackgroundGeolocationHeadlessTask. A class in com.example.myapp is not found in that build. Each build variant looks for the class under its own application ID.
  • If you changed applicationId after the project was created, MainActivity and the namespace still 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 the app module's java folder to the package containing MainActivity (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.java with the following code, but keep the package line 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 public and have a public no-argument constructor (the default constructor is fine).
  • It must have a public method annotated with EventBus's @Subscribe that takes a single HeadlessEvent argument.
  • It needs no AndroidManifest.xml entry 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).