DEV Community

Ion Samoil
Ion Samoil

Posted on

How Hybrid Frameworks Handle Background Location: From Flutter/Ionic to Native iOS and Android

Background Location in Hybrid Apps: Going Native on iOS

Hybrid frameworks such as Ionic, Flutter, and React Native cover most mobile application needs without requiring much native code.

But background location is one of those features where going native can make sense.

In my case, I needed an Ionic + Angular + Capacitor application to continue tracking location when:

the app is in the background;
the screen is locked;
the app is no longer actively running in the foreground.

Instead of trying to keep the Angular/WebView layer alive, I moved location tracking to native iOS code.

The resulting architecture is simple:

Ionic / Angular

│ configuration

Capacitor Storage


Native Swift


Core Location


Backend API

Let's look at the implementation.

  1. Store the configuration from Ionic

The Angular application stores the information required by the native layer:

async setUserData(userData: any | null): Promise {
if (!userData) {
await this.clear(this._locationDataKey);
return this.clear(this._userKey);
}

const id = await Device.getId();

await this.store(
this._locationDataKey,
JSON.stringify({
apiUrl: environment.apiUrl,
userId: userData.userId,
on_duty: true,
identifier: id.identifier,
platform: Capacitor.getPlatform(),
})
);

await this.store(this._userKey, JSON.stringify(userData));
}

The important idea here is that Angular doesn't process the background locations.

It only shares configuration with the native layer.

  1. Enable background location on iOS

Add the location permissions to Info.plist:

NSLocationWhenInUseUsageDescription

We need your location while using the app.

NSLocationAlwaysAndWhenInUseUsageDescription

We need your location while the app is in the background.

UIBackgroundModes

location

You should of course replace these descriptions with the actual reason your application needs the user's location.

  1. Create the native LocationManager

Now we can move location tracking into Swift.

import CoreLocation

class LocationManager: NSObject, CLLocationManagerDelegate {

private let locationManager = CLLocationManager()

func startTrackingLocation() {
    let locationData = getLocationData() ?? [:]
    let onDuty = locationData["on_duty"] as? Bool ?? false

    guard onDuty else { return }

    locationManager.delegate = self

    locationManager.requestAlwaysAuthorization()
    locationManager.pausesLocationUpdatesAutomatically = false
    locationManager.allowsBackgroundLocationUpdates = true

    locationManager.startMonitoringSignificantLocationChanges()
    locationManager.startUpdatingLocation()
}
Enter fullscreen mode Exit fullscreen mode

}

There are two location mechanisms here:

locationManager.startUpdatingLocation()

provides standard location updates, including while the application is backgrounded when correctly configured.

And:

locationManager.startMonitoringSignificantLocationChanges()

asks iOS to monitor significant changes in location.

This second mechanism is particularly interesting because iOS can relaunch an authorized application in the background when a significant-location event occurs.

It doesn't mean that arbitrary application code runs continuously after termination. The location lifecycle is still controlled by iOS.

  1. Read the Ionic configuration from Swift

The native implementation can access the configuration previously stored by Capacitor:

func getLocationData() -> [String: Any]? {
let defaults = UserDefaults.standard
let key = "CapacitorStorage.app|location-data"

guard let jsonString = defaults.string(forKey: key),
      let data = jsonString.data(using: .utf8) else {
    return nil
}

do {
    return try JSONSerialization.jsonObject(
        with: data
    ) as? [String: Any]
} catch {
    print("Unable to decode location configuration: \(error)")
    return nil
}
Enter fullscreen mode Exit fullscreen mode

}

This gives native Swift access to values such as:

userId
apiUrl
on_duty
identifier
platform

without depending on Angular being active.

  1. Receive location updates

Implement CLLocationManagerDelegate:

func locationManager(
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
guard let location = locations.last else {
return
}

let latitude = location.coordinate.latitude
let longitude = location.coordinate.longitude

let locationData = getLocationData() ?? [:]

guard let userId = locationData["userId"] as? Int,
      let apiUrl = locationData["apiUrl"] as? String else {
    return
}

postLocation(
    lat: latitude,
    long: longitude,
    apiUrl: apiUrl,
    userId: userId
)
Enter fullscreen mode Exit fullscreen mode

}

At this point the WebView isn't involved:

Core Location

LocationManager.swift

Native HTTP request

Backend

  1. Send the location directly from Swift

For example:

func postLocation(
lat: Double,
long: Double,
apiUrl: String,
userId: Int
) {
guard let url = URL(string: apiUrl + "/locations") else {
return
}

var request = URLRequest(url: url)
request.httpMethod = "POST"

request.addValue(
    "application/json",
    forHTTPHeaderField: "Content-Type"
)

let body: [String: Any] = [
    "latitude": lat,
    "longitude": long,
    "userId": userId
]

request.httpBody = try? JSONSerialization.data(
    withJSONObject: body
)

URLSession.shared
    .dataTask(with: request)
    .resume()
Enter fullscreen mode Exit fullscreen mode

}

For a real application, authentication, offline retries, secure storage, and error handling should obviously be added.

  1. Start tracking from AppDelegate

Finally, initialize the native manager from the iOS application:

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

var window: UIWindow?

let locationManager = LocationManager()

func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions:
        [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {

    locationManager.startTrackingLocation()

    return true
}

func applicationDidEnterBackground(
    _ application: UIApplication
) {
    locationManager.startTrackingLocation()
}

func applicationWillEnterForeground(
    _ application: UIApplication
) {
    locationManager.startTrackingLocation()
}
Enter fullscreen mode Exit fullscreen mode

}

The important part isn't repeatedly calling the method from every lifecycle callback.

The important part is that Core Location is configured at the native level and doesn't depend on Angular's execution lifecycle.

What does "closed" actually mean?

One final warning: don't treat "background", "locked", "terminated by iOS", and "force-closed by the user" as the same state.

Background location is controlled by iOS, and different Core Location services have different lifecycle behavior.

In particular, startMonitoringSignificantLocationChanges() can allow iOS to relaunch the application for a qualifying location event, but this isn't the same as keeping your application continuously alive.

That's it

We still have an Ionic application:

Ionic / Angular

Capacitor Storage

Swift

CLLocationManager

URLSession

Backend

We simply moved the responsibility that needs native lifecycle support into the native layer.

And this pattern isn't limited to Ionic.

The same principle can be used with Flutter, React Native, or another cross-platform framework: keep the shared application shared, and go native for the small pieces that genuinely need native platform capabilities.
Background Location in Hybrid Apps: Going Native on Android

In [Part 1], we implemented background location tracking on iOS by moving the responsibility from Ionic/Angular into native Swift and Core Location.

Now let's do the same thing on Android.

The goal stays the same:

Ionic/Angular manages the application and configuration
native Android code manages background location
location updates don't depend on the Angular WebView being active

On Android, however, the implementation is different.

We'll use a Foreground Service + Fused Location Provider:

Ionic / Angular

│ configuration

Capacitor Storage


Native Android


Foreground Service


Fused Location Provider


Backend API

Let's implement it.

  1. Add the Android permissions

First, add the required permissions to AndroidManifest.xml:

android:name="android.permission.ACCESS_FINE_LOCATION" />

android:name="android.permission.ACCESS_COARSE_LOCATION" />

android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />

android:name="android.permission.FOREGROUND_SERVICE" />

android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />

ACCESS_BACKGROUND_LOCATION is required for background location access on Android versions where that permission model applies.

Modern Android versions also require the appropriate foreground-service permission/type for location services.

Remember that declaring permissions in the manifest is only one part of the process. Location permissions still need to be requested from the user at runtime.

  1. Register the native LocationService

Inside :

android:name=".LocationService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="location"
/>

The important part is:

android:foregroundServiceType="location"

We're telling Android that this foreground service performs location-related work.

  1. Add Fused Location Provider

Add Google Play Services Location to build.gradle:

implementation "com.google.android.gms:play-services-location:$playServicesLocation"

I'm also using Retrofit to communicate with the backend:

implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'

The core dependency for location tracking is Google Play Services Location.

  1. Start the native service from MainActivity

The Ionic application stores its configuration in Capacitor storage.

Native Android can read that configuration and decide whether location tracking should run.

In MainActivity:

private void manageBackgroundLocationService() {
boolean onDuty =
StorageHelper.isOnDuty(getApplicationContext());

Intent serviceIntent =
    new Intent(this, LocationService.class);

if (onDuty && hasLocationPermissions()) {
    startForegroundService(serviceIntent);
} else {
    stopService(serviceIntent);
}
Enter fullscreen mode Exit fullscreen mode

}

This method is called when the application starts:

@override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

manageBackgroundLocationService();
Enter fullscreen mode Exit fullscreen mode

}

It can also react when the configuration stored by Ionic changes. In my implementation, MainActivity listens to the underlying SharedPreferences used by Capacitor.

Conceptually:

Angular

│ on_duty = true

Capacitor Storage


SharedPreferences


MainActivity


startForegroundService()

  1. Create the Foreground Service

Now comes the important part.

Create LocationService.java:

public class LocationService extends Service {

private static final String CHANNEL_ID = "LocationChannel";

private FusedLocationProviderClient fusedLocationClient;

@Override
public void onCreate() {
    super.onCreate();

    fusedLocationClient =
        LocationServices.getFusedLocationProviderClient(this);

    startForegroundService();

    if (StorageHelper.isOnDuty(getApplicationContext())) {
        requestLocationUpdates();
    } else {
        stopSelf();
    }
}
Enter fullscreen mode Exit fullscreen mode

}

The foreground service now owns the location lifecycle instead of Angular. Your actual implementation follows this pattern and also listens for on_duty changes so the service can stop itself.

  1. Foreground services require a notification

A foreground service must be visible to the user.

Create a notification channel and notification:

private void startForegroundService() {

ensureNotificationChannel();

Notification notification =
    new NotificationCompat.Builder(this, CHANNEL_ID)
        .setContentTitle("Location Tracking")
        .setContentText(
            "Tracking location in the background"
        )
        .setSmallIcon(
            android.R.drawable.ic_menu_mylocation
        )
        .setPriority(NotificationCompat.PRIORITY_LOW)
        .build();

startForeground(1, notification);
Enter fullscreen mode Exit fullscreen mode

}

Your service does exactly this before beginning its long-running work.

This is an important difference from iOS.

Android makes long-running foreground work explicitly visible:

LocationService

├── persistent notification

└── location updates

Trying to hide that notification isn't the solution—the foreground-service model intentionally makes this activity visible to the user.

  1. Request native location updates

Now configure FusedLocationProviderClient:

private static final long INTERVAL_MS =
60 * 60 * 1000; // 1 hour

private void requestLocationUpdates() {

LocationRequest request =
    LocationRequest.create();

request.setInterval(INTERVAL_MS);
request.setFastestInterval(INTERVAL_MS);
request.setPriority(
    LocationRequest.PRIORITY_HIGH_ACCURACY
);

fusedLocationClient.requestLocationUpdates(
    request,
    locationCallback,
    null
);
Enter fullscreen mode Exit fullscreen mode

}

The real implementation also verifies both fine and background location permissions before requesting updates.

Then receive the locations:

private final LocationCallback locationCallback =
new LocationCallback() {

    @Override
    public void onLocationResult(
        LocationResult locationResult
    ) {
        if (locationResult == null) {
            return;
        }

        for (Location location :
                locationResult.getLocations()) {

            saveLocation(location);
        }
    }
};
Enter fullscreen mode Exit fullscreen mode

Again, Angular isn't involved:

Android Location Services

FusedLocationProviderClient

LocationService

Backend

  1. Read the Ionic configuration

Just like on iOS, the native layer can access data persisted by the Ionic application.

A small helper makes that easier:

public static JSONObject getLocationData(
Context context
) {
SharedPreferences preferences =
context.getSharedPreferences(
"CapacitorStorage",
Context.MODE_PRIVATE
);

String data = preferences.getString(
    "app|location-data",
    null
);

if (data == null) {
    return new JSONObject();
}

try {
    return new JSONObject(data);
} catch (JSONException e) {
    return new JSONObject();
}
Enter fullscreen mode Exit fullscreen mode

}

Your implementation uses the same mechanism to extract on_duty from the shared configuration.

This gives the native service access to:

apiUrl
userId
identifier
platform
on_duty

without needing Angular to be running.

  1. Send the location directly from native code

For the Android implementation, I'm using Retrofit.

Define the endpoint:

public interface ApiService {

@POST("locations")
Call<Void> createLocation(
    @Body UserLocation location
);
Enter fullscreen mode Exit fullscreen mode

}

This corresponds directly to the API interface used in the implementation.

Then when a location arrives:

private void saveLocation(
Location location,
JSONObject userData
) {

UserLocation userLocation =
    new UserLocation(
        location.getLatitude(),
        location.getLongitude(),
        userData.optString("userId"),
        userData.optString("identifier"),
        userData.optString("platform"),
        location.getAccuracy(),
        location.getSpeed(),
        location.getAltitude()
    );

apiService
    .createLocation(userLocation)
    .enqueue(new Callback<Void>() {

        @Override
        public void onResponse(
            Call<Void> call,
            Response<Void> response
        ) {
            // Location sent
        }

        @Override
        public void onFailure(
            Call<Void> call,
            Throwable throwable
        ) {
            // Retry/persist according to your needs
        }
    });
Enter fullscreen mode Exit fullscreen mode

}

The actual location payload includes latitude, longitude, user/device information, accuracy, speed and altitude.

So, once again:

Location event

Java LocationService

Retrofit

Backend

No WebView is required to process the event.

  1. What happens when the app is removed?

This is where Android gets interesting.

The service can return:

@override
public int onStartCommand(
Intent intent,
int flags,
int startId
) {
if (StorageHelper.isOnDuty(
getApplicationContext()
)) {
requestLocationUpdates();
} else {
stopSelf();
}

return START_STICKY;
Enter fullscreen mode Exit fullscreen mode

}

Your implementation uses START_STICKY and re-checks on_duty whenever Android recreates the service.

START_STICKY tells Android that if the service process is killed, the system should attempt to recreate the service later.

But don't interpret this as:

"Android guarantees my application will always run after being closed."

There are important differences between:

App in background
Screen locked
Activity removed from Recents
Process killed by Android
User explicitly force-stops app

These states don't have identical behavior.

A foreground service makes background location significantly more reliable, but it doesn't give an application permission to bypass Android's lifecycle and user controls.

The final architecture

That's basically it.

The complete Android architecture becomes:

        Ionic / Angular
               │
        Capacitor Storage
               │
               ▼
          MainActivity
               │
               ▼
        LocationService
               │
    ┌──────────┴──────────┐
    │                     │
Enter fullscreen mode Exit fullscreen mode

Foreground notification Fused Location


Retrofit


Backend

Compare that with the iOS implementation from Part 1:

iOS Android

Angular Angular
↓ ↓
Capacitor Storage Capacitor Storage
↓ ↓
Swift Java/Kotlin
↓ ↓
Core Location Foreground Service

Fused Location
↓ ↓
Backend Backend

The native implementations are completely different, but the architectural principle is the same:

Keep the shared application in your hybrid framework, but move platform-specific background responsibilities into the native layer.

And this approach isn't limited to Ionic.

The same idea can be applied to Flutter, React Native, or other cross-platform frameworks when you reach a feature that needs deeper integration with the native operating system.

Top comments (0)