Sunday, May 24, 2026

How to Build a Location-Based Android App


A practical walkthrough — from setting up Google Maps and requesting permissions to running a background foreground service that triggers an alarm when you arrive at or leave a destination. Based on the real-world InOutAlarm app.


1. What We Are Building

geofencing alarm app that monitors the user's GPS position in real time and fires an alarm — vibration, sound, and a push notification — the moment they either enter or leave a radius around a map pin they dropped.

Think of it as a smarter alarm clock: instead of going off at a time, it goes off at a place. Common uses include waking up on public transport, reminding yourself to get off at the right stop, or alerting you when you've wandered too far from a meeting point.

Core features we will cover:

  • Embedding a live Google Map and letting the user long-press to drop a destination pin
  • Drawing a adjustable-radius circle around the pin
  • Obtaining the device's precise GPS location both in the foreground and in the background
  • Running a Foreground Service so tracking continues when the screen is off
  • Calculating distance to the destination and triggering an alarm when the threshold is crossed
  • Posting high-priority notifications that appear on the lock screen
  • Handling Android 10+ background location permission and the mandatory disclosure dialog
  User drops pin          User taps Start
       │                        │
       ▼                        ▼
  [Destination set]    [Foreground Service starts]
                               │
                    GPS fix every 1 second
                               │
                    distance to destination?
                      ┌────────┴────────┐
                   < radius           > radius
                  (Arrival)           (Leaving)
                      │                   │
                  ALARM ◀─────────────────┘
                 vibrate + sound + notification

2. Project Setup & Dependencies

Create the project

In Android Studio, choose New Project → Google Maps Activity. This scaffolds the map fragment and the API key placeholder for you automatically.

app/build.gradle

Set a minimum SDK of 26 (Android 8.0) so you can use VibrationEffectNotificationChannel, and the modern PendingIntent.FLAG_IMMUTABLE flag without workarounds.

app/build.gradle
android {
    compileSdkVersion 35
    defaultConfig {
        applicationId "com.example.locationalarm"
        minSdkVersion 26        // Android 8.0 — required for NotificationChannel
        targetSdkVersion 35
        versionCode 1
        versionName "1.0"
    }
}

dependencies {
    // Google Maps + Fused Location Provider
    implementation 'com.google.android.gms:play-services-maps:19.0.0'
    implementation 'com.google.android.gms:play-services-location:21.3.0'

    // AndroidX
    implementation 'androidx.appcompat:appcompat:1.7.0'
    implementation 'androidx.core:core:1.13.1'
    implementation 'androidx.fragment:fragment:1.8.1'
}

Google Maps API key

Go to the Google Cloud Console, enable the Maps SDK for Android, create an API key restricted to your app's package name and signing certificate, then paste it in res/values/google_maps_api.xml:

res/values/google_maps_api.xml
<resources>
    <string name="google_maps_key" translatable="false">YOUR_API_KEY_HERE</string>
</resources>
Never commit your API key to a public repository. Add google_maps_api.xml to .gitignore, or use Android Secrets Gradle Plugin to load it from a local local.properties file.

3. Declaring and Requesting Permissions

Declare in AndroidManifest.xml

AndroidManifest.xml
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

Also mark your service with the foregroundServiceType attribute:

AndroidManifest.xml — service declaration
<service
    android:name=".TrackLocationService"
    android:enabled="true"
    android:exported="false"
    android:foregroundServiceType="location" />

Request at runtime

Location and notification permissions must be requested at runtime. Do it in two stages:

1
Fine location — request when the Activity starts.MapsActivity.java
private static final int REQUEST_CODE = 200;

if (ActivityCompat.checkSelfPermission(this,
        Manifest.permission.ACCESS_FINE_LOCATION)
        != PackageManager.PERMISSION_GRANTED) {
    ActivityCompat.requestPermissions(this,
        new String[]{ Manifest.permission.ACCESS_FINE_LOCATION },
        REQUEST_CODE);
}
2
Background location (Android 10+) — Google Play policy requires you to show an in-app disclosure before requesting this. Show an AlertDialog that explains exactly why background location is needed, then request it only if the user agrees.MapsActivity.java
private void requestBackgroundLocationWithDisclosure() {
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return;
    if (ActivityCompat.checkSelfPermission(this,
            Manifest.permission.ACCESS_BACKGROUND_LOCATION)
            == PackageManager.PERMISSION_GRANTED) return;

    new AlertDialog.Builder(this)
        .setTitle("Allow all-the-time location for trip alarm")
        .setMessage(
            "This app collects location data to monitor your distance to the " +
            "destination and trigger an arrival or leaving alarm, even when " +
            "the app is closed or the screen is off. Your location data stays " +
            "on your device and is never shared with third parties.")
        .setCancelable(false)
        .setPositiveButton("Continue to permission", (d, w) ->
            ActivityCompat.requestPermissions(this,
                new String[]{ Manifest.permission.ACCESS_BACKGROUND_LOCATION },
                REQUEST_CODE_BACKGROUND_LOCATION))
        .setNegativeButton("Not now", null)
        .show();
}
3
Notification permission (Android 13+) — handled in onCreate.MapsActivity.java
private void requestNotificationPermission() {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        if (ActivityCompat.checkSelfPermission(this,
                Manifest.permission.POST_NOTIFICATIONS)
                != PackageManager.PERMISSION_GRANTED) {
            ActivityCompat.requestPermissions(this,
                new String[]{ Manifest.permission.POST_NOTIFICATIONS },
                REQUEST_CODE);
        }
    }
}
Tip: Handle onRequestPermissionsResult() to chain permission requests. After fine location is granted, immediately check for background location if the user intends to start monitoring.

4. Embedding Google Maps

Layout — FrameLayout overlay pattern

Use a FrameLayout as the root so that control buttons float on top of the map.

res/layout/activity_maps.xml
<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <!-- Full-screen map behind everything -->
    <fragment
        android:id="@+id/map"
        android:name="com.google.android.gms.maps.SupportMapFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    <!-- Control panel floating on top-left -->
    <GridLayout
        android:layout_gravity="start|top"
        android:layout_marginTop="112dp"
        android:columnCount="2"
        ... />

</FrameLayout>

Initialise the map in code

MapsActivity.java
public class MapsActivity extends FragmentActivity
        implements OnMapReadyCallback, GoogleMap.OnCameraIdleListener {

    private GoogleMap mMap;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_maps);

        SupportMapFragment mapFragment = (SupportMapFragment)
            getSupportFragmentManager().findFragmentById(R.id.map);
        mapFragment.getMapAsync(this);   // calls onMapReady when ready
    }

    @Override
    public void onMapReady(GoogleMap googleMap) {
        mMap = googleMap;
        mMap.setMyLocationEnabled(true);       // blue dot for current position
        mMap.getUiSettings().setZoomControlsEnabled(true);
        mMap.getUiSettings().setCompassEnabled(true);
        mMap.setOnCameraIdleListener(this);    // to track zoom level changes
        mMap.setOnMapLongClickListener(this::onMapLongClick);
    }
}

Auto-scaling reference circle on camera change

Show a grey reference circle whose real-world radius matches the visible area, so users always have a scale reference regardless of zoom level.

MapsActivity.java — onCameraIdle()
@Override
public void onCameraIdle() {
    int zoom = (int) mMap.getCameraPosition().zoom;
    int radius;
    if      (zoom >= 19) radius = 20;
    else if (zoom == 17) radius = 100;
    else if (zoom == 15) radius = 300;
    else if (zoom == 13) radius = 1000;
    else                 radius = 5000;   // and so on …

    LatLng centre = mMap.getCameraPosition().target;
    if (referenceCircle != null) referenceCircle.remove();
    referenceCircle = mMap.addCircle(new CircleOptions()
        .center(centre)
        .radius(radius)
        .strokeColor(Color.GRAY)
        .fillColor(Color.TRANSPARENT));
}

5. Getting the Device Location

Use the Fused Location Provider for foreground location — it automatically picks the best available source (GPS, Wi-Fi, cell). Use a raw LocationManager inside the foreground service for background tracking.

Fused Location Provider — last known location

MapsActivity.java
private FusedLocationProviderClient fusedLocationClient;

// In onCreate:
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this);

// Fetch last known fix (fast, uses cached value):
private void fetchLastLocation() {
    fusedLocationClient.getLastLocation()
        .addOnSuccessListener(location -> {
            if (location != null) {
                currentLocation = location;
                mMap.moveCamera(CameraUpdateFactory.newLatLngZoom(
                    new LatLng(location.getLatitude(),
                               location.getLongitude()), 15));
            }
        });
}

One-shot fresh fix on startup

getLastLocation() can return null or a stale fix. Use getCurrentLocation() first; fall back to a one-shot LocationRequest if it times out.

MapsActivity.java
CancellationTokenSource tokenSource = new CancellationTokenSource();
fusedLocationClient
    .getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, tokenSource.getToken())
    .addOnSuccessListener(location -> {
        if (location != null) {
            currentLocation = location;
            mMap.animateCamera(CameraUpdateFactory.newLatLngZoom(
                new LatLng(location.getLatitude(), location.getLongitude()), 16));
        } else {
            requestSingleFixAndCenter();   // GPS fallback
        }
    });

private void requestSingleFixAndCenter() {
    LocationRequest oneShotRequest = new LocationRequest.Builder(
            Priority.PRIORITY_HIGH_ACCURACY, 1000)
        .setMaxUpdates(1)
        .setWaitForAccurateLocation(true)
        .setDurationMillis(15_000)        // give up after 15 s
        .build();

    fusedLocationClient.requestLocationUpdates(oneShotRequest,
        singleFixCallback, Looper.getMainLooper());
}

6. Setting a Destination and Drawing a Geofence

Long-press to drop a pin

MapsActivity.java
mMap.setOnMapLongClickListener(latLng -> {
    // Convert LatLng to a Location object for distanceTo() later
    targetLocation = new Location(LocationManager.GPS_PROVIDER);
    targetLocation.setLatitude(latLng.latitude);
    targetLocation.setLongitude(latLng.longitude);

    // Drop a marker
    if (markerTarget != null) markerTarget.remove();
    markerTarget = mMap.addMarker(new MarkerOptions()
        .position(latLng)
        .title("Destination")
        .icon(BitmapDescriptorFactory.defaultMarker(
              BitmapDescriptorFactory.HUE_ROSE)));

    drawAlarmCircle();
    appState = STATE_READY;
    updateButtons();
});

Draw the alarm radius circle

MapsActivity.java
private Circle alarmCircle;
private int alarmRadiusMeters = 200;   // default 200 m

private void drawAlarmCircle() {
    if (targetLocation == null) return;
    if (alarmCircle != null) alarmCircle.remove();
    alarmCircle = mMap.addCircle(new CircleOptions()
        .center(new LatLng(targetLocation.getLatitude(),
                           targetLocation.getLongitude()))
        .radius(alarmRadiusMeters)
        .strokeColor(Color.RED)
        .fillColor(Color.TRANSPARENT));
}

// Range + / Range - buttons adjust alarmRadiusMeters by 50 m each tap
public void onRangeUp(View v)   { alarmRadiusMeters += 50; drawAlarmCircle(); }
public void onRangeDown(View v) { if (alarmRadiusMeters > 100) alarmRadiusMeters -= 50; drawAlarmCircle(); }

Polylines — visualise start and current distance

MapsActivity.java
// Dark-blue line: fixed line from start point to destination
private void drawOriginLine() {
    if (startLine != null) startLine.remove();
    startLine = mMap.addPolyline(new PolylineOptions()
        .add(new LatLng(originLocation.getLatitude(), originLocation.getLongitude()),
             new LatLng(targetLocation.getLatitude(), targetLocation.getLongitude()))
        .color(ContextCompat.getColor(this, R.color.colorPrimaryDark)));
}

// Teal line: live line that updates every location fix
private void drawCurrentLine() {
    if (currentLine != null) currentLine.remove();
    currentLine = mMap.addPolyline(new PolylineOptions()
        .add(new LatLng(targetLocation.getLatitude(),  targetLocation.getLongitude()),
             new LatLng(currentLocation.getLatitude(), currentLocation.getLongitude()))
        .color(ContextCompat.getColor(this, R.color.colorAccent)));
}

7. Background Tracking with a Foreground Service

When the app is minimised or the screen turns off, the Activity's location callbacks stop. A Foreground Service solves this — Android guarantees it stays alive as long as a visible notification is showing.

A foreground service is not optional for this use case. Background location requires either a foreground service (with foregroundServiceType="location") or a Geofencing API call. The manual service approach gives you 1-second GPS polling, which is far more responsive than the Geofencing API's dwell-time transitions.

Service skeleton

TrackLocationService.java
public class TrackLocationService extends Service {

    // Broadcasts the service sends back to the Activity
    public static final String ACTION_ALARM_TRIGGERED =
            "com.example.locationalarm.ALARM_TRIGGERED";
    public static final String ACTION_LOCATION_UPDATED =
            "com.example.locationalarm.LOCATION_UPDATED";

    private final IBinder binder = new TrackerBinder();
    private LocationManager locationManager;
    private LocationListener listener;

    // Parameters set by the Activity via onStartCommand Intent extras
    private Location targetLocation;
    private int      alarmMode    = 0;   // 0 = arrival, 1 = leaving
    private int      rangeMeters  = 200;
    private boolean  monitoring   = false;
    private boolean  alarmFired   = false;

    public class TrackerBinder extends Binder {
        TrackLocationService getService() { return TrackLocationService.this; }
    }

    @Override public IBinder onBind(Intent intent) { return binder; }

    @Override
    public void onCreate() {
        super.onCreate();
        createNotificationChannels();
        startLocationUpdates();
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        if (intent != null) {
            alarmMode     = intent.getIntExtra("ALARM_MODE", 0);
            rangeMeters   = intent.getIntExtra("RANGE_METERS", 200);
            monitoring    = intent.getBooleanExtra("MONITORING", false);
            targetLocation = intent.getParcelableExtra("TARGET_LOCATION");
            alarmFired    = false;
        }
        startForeground(NOTIFICATION_ID_FG, buildForegroundNotification());
        return START_STICKY;
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        if (locationManager != null && listener != null)
            locationManager.removeUpdates(listener);
    }
}

Registering the GPS listener inside the service

TrackLocationService.java
private void startLocationUpdates() {
    listener = new LocationListener() {
        @Override
        public void onLocationChanged(Location location) {
            currentLocation = location;

            // Tell the Activity about the new fix
            sendBroadcast(new Intent(ACTION_LOCATION_UPDATED));

            // Check alarm condition independently of the Activity
            if (monitoring && targetLocation != null && !alarmFired)
                checkAlarmCondition();
        }
        @Override public void onProviderEnabled(String p)  {}
        @Override public void onProviderDisabled(String p) {}
    };

    locationManager = (LocationManager) getSystemService(LOCATION_SERVICE);
    locationManager.requestLocationUpdates(
        LocationManager.GPS_PROVIDER,
        1000,   // min time interval ms
        1,      // min distance metres
        listener);
}

Starting the service from the Activity

MapsActivity.java
private void startTrackingService() {
    Intent intent = new Intent(this, TrackLocationService.class);
    intent.putExtra("TARGET_LOCATION", (Parcelable) targetLocation);
    intent.putExtra("ALARM_MODE",     alarmMode);
    intent.putExtra("RANGE_METERS",   alarmRadiusMeters);
    intent.putExtra("MONITORING",     true);
    ContextCompat.startForegroundService(this, intent);
}

private void stopTrackingService() {
    stopService(new Intent(this, TrackLocationService.class));
}
Battery optimisation: On many devices (especially Samsung and Xiaomi) aggressive battery management kills foreground services. Request the exemption at startup:
PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE);
if (!pm.isIgnoringBatteryOptimizations(getPackageName())) {
    Intent i = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
                Uri.parse("package:" + getPackageName()));
    startActivity(i);
}

8. Checking the Alarm Condition and Triggering the Alarm

Android's Location.distanceTo() returns the distance in metres between two Location objects using the Haversine formula. Call it on every GPS fix.

TrackLocationService.java
private void checkAlarmCondition() {
    float distance = currentLocation.distanceTo(targetLocation);

    boolean triggered =
        (alarmMode == 0 && distance < rangeMeters) ||   // Arrival
        (alarmMode == 1 && distance > rangeMeters);    // Leaving

    if (triggered) {
        alarmFired = true;    // fire only once per session
        triggerAlarm();
    }
}

private void triggerAlarm() {
    // 1. Vibrate
    Vibrator vibrator = (Vibrator) getSystemService(VIBRATOR_SERVICE);
    vibrator.vibrate(
        VibrationEffect.createOneShot(3000, VibrationEffect.DEFAULT_AMPLITUDE));

    // 2. Play sound
    MediaPlayer mp = MediaPlayer.create(this, R.raw.alarm_sound);
    if (mp != null) {
        mp.setVolume(0.8f, 0.8f);
        mp.setOnCompletionListener(MediaPlayer::release);
        mp.start();
    }

    // 3. High-priority notification  (see next section)
    sendAlarmNotification();

    // 4. Tell the Activity so it can update the UI
    sendBroadcast(new Intent(ACTION_ALARM_TRIGGERED));
}

In the Activity, register a BroadcastReceiver to catch the signal:

MapsActivity.java
private final BroadcastReceiver alarmReceiver = new BroadcastReceiver() {
    @Override
    public void onReceive(Context ctx, Intent intent) {
        if (ACTION_ALARM_TRIGGERED.equals(intent.getAction())) {
            appState = STATE_ALARM;
            updateButtons();   // turns Stop button red, hides others
        }
    }
};

@Override protected void onResume() {
    super.onResume();
    registerReceiver(alarmReceiver,
        new IntentFilter(TrackLocationService.ACTION_ALARM_TRIGGERED));
}
@Override protected void onPause() {
    super.onPause();
    unregisterReceiver(alarmReceiver);
}

9. Posting Notifications

Two notification channels are required: one silent channel for the foreground service (so the persistent icon does not make noise) and one high-importance channel for the alarm.

Create the channels once

TrackLocationService.java
private static final String CH_SERVICE = "ch_fg_service";
private static final String CH_ALARM   = "ch_alarm";

private void createNotificationChannels() {
    NotificationManager nm = getSystemService(NotificationManager.class);

    // Silent channel — foreground service heartbeat
    NotificationChannel svc = new NotificationChannel(
        CH_SERVICE, "Tracking active", NotificationManager.IMPORTANCE_LOW);
    nm.createNotificationChannel(svc);

    // Loud channel — alarm fires here
    NotificationChannel alarm = new NotificationChannel(
        CH_ALARM, "Alarm", NotificationManager.IMPORTANCE_HIGH);
    alarm.enableVibration(true);
    alarm.setLockscreenVisibility(NotificationCompat.VISIBILITY_PUBLIC);
    nm.createNotificationChannel(alarm);
}

Foreground service notification (silent)

TrackLocationService.java
private Notification buildForegroundNotification() {
    PendingIntent pi = PendingIntent.getActivity(this, 0,
        new Intent(this, MapsActivity.class)
            .setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
        PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);

    String modeText = alarmMode == 0 ? "Arrival" : "Leaving";
    return new NotificationCompat.Builder(this, CH_SERVICE)
        .setContentTitle("Alarm active — " + modeText + " mode")
        .setContentText("Monitoring within " + rangeMeters + " m")
        .setSmallIcon(R.drawable.ic_notification)
        .setContentIntent(pi)
        .setOngoing(true)       // cannot be swiped away
        .build();
}

Alarm notification (high-priority, lock-screen visible)

TrackLocationService.java
private void sendAlarmNotification() {
    String title = alarmMode == 0 ? "You have arrived!"  : "You have left!";
    String text  = alarmMode == 0
        ? "Inside "  + rangeMeters + " m of your destination"
        : "Outside " + rangeMeters + " m from your location";

    PendingIntent pi = PendingIntent.getActivity(this, 0,
        new Intent(this, MapsActivity.class)
            .setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
        PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);

    Notification n = new NotificationCompat.Builder(this, CH_ALARM)
        .setStyle(new NotificationCompat.BigTextStyle().bigText(text))
        .setContentTitle(title)
        .setContentText(text)
        .setSmallIcon(R.drawable.ic_notification)
        .setContentIntent(pi)
        .setDefaults(NotificationCompat.DEFAULT_ALL)
        .setPriority(NotificationCompat.PRIORITY_HIGH)
        .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
        .setCategory(Notification.CATEGORY_ALARM)
        .setAutoCancel(true)
        .build();

    getSystemService(NotificationManager.class).notify(888, n);
}

10. Managing App State

A single integer variable (appState) drives all UI decisions. Using an explicit state machine prevents UI bugs caused by buttons appearing at the wrong time.

ConstantValueMeaningVisible controls
STATE_IDLE0Waiting for destination to be setMeasure
STATE_READY1Destination set, ready to monitorStart, Arrival/Leaving, Range +/−, Measure
STATE_TRACING2Foreground service running, monitoring activeStop (yellow)
STATE_ALARM3Alarm has firedStop (red)
MapsActivity.java
private void updateButtons() {
    switch (appState) {
        case STATE_IDLE:
            btnStart.setVisibility(View.INVISIBLE);
            btnMode.setVisibility(View.INVISIBLE);
            btnRangeUp.setVisibility(View.INVISIBLE);
            btnRangeDown.setVisibility(View.INVISIBLE);
            break;

        case STATE_READY:
            btnStart.setText("Start");
            btnStart.setVisibility(View.VISIBLE);
            btnStart.setBackgroundColor(Color.GREEN);
            btnMode.setVisibility(View.VISIBLE);
            btnRangeUp.setVisibility(View.VISIBLE);
            btnRangeDown.setVisibility(View.VISIBLE);
            break;

        case STATE_TRACING:
            btnStart.setText("Stop");
            btnStart.setBackgroundColor(Color.YELLOW);
            btnMode.setVisibility(View.INVISIBLE);
            btnRangeUp.setVisibility(View.INVISIBLE);
            btnRangeDown.setVisibility(View.INVISIBLE);
            break;

        case STATE_ALARM:
            btnStart.setText("Stop");
            btnStart.setBackgroundColor(Color.RED);
            break;
    }
}

// Start / Stop button handler
public void onStartStopClicked(View v) {
    if (appState == STATE_TRACING || appState == STATE_ALARM) {
        stopTrackingService();
        appState = STATE_IDLE;
    } else {
        if (targetLocation == null) { showHint("Set destination first"); return; }
        startTrackingService();
        appState = STATE_TRACING;
    }
    updateButtons();
}

A 2-second Handler loop keeps the info bar updated during tracing without coupling the UI refresh rate to the GPS update rate:

MapsActivity.java
private void startInfoLoop() {
    Handler handler = new Handler(Looper.getMainLooper());
    handler.post(new Runnable() {
        @Override public void run() {
            if (appState == STATE_TRACING && currentLocation != null) {
                float dist = currentLocation.distanceTo(targetLocation);
                infoBar.setText("Remaining: " + formatDistance((int) dist));
            }
            handler.postDelayed(this, 2000);   // repeat every 2 s
        }
    });
}

11. Tips, Gotchas & Best Practices

TopicAdvice
One alarm per sessionUse a boolean flag (alarmFired) in the service and set it to true on first trigger. Reset it only when the user explicitly presses Stop. This prevents repeated alarms from GPS jitter near the boundary.
Screen rotationSave targetLocationappStatealarmMode, and alarmRadiusMeters in onSaveInstanceState() and restore them in onCreate(). Re-draw all map overlays in onMapReady().
GPS jitter at restGPS accuracy at rest is typically ±3–15 m. For an arrival alarm keep the radius ≥ 100 m to avoid false triggers. For leaving alarms consider adding a short dwell-time check (e.g., must be outside radius for 3 consecutive fixes).
Background service lifetimeRequest battery optimisation exemption. Instruct users to whitelist the app in device-specific battery settings (Samsung: Sleeping apps → Never; MIUI: Autostart + Battery saver).
API key securityRestrict your Maps API key to your app's SHA-1 signing certificate and package name. Never hardcode a key in a shared source file; use Android Secrets Gradle Plugin or local.properties.
Google Play policyACCESS_BACKGROUND_LOCATION requires a prominent in-app disclosure dialog before the system permission prompt. The disclosure must describe the specific feature using background location, not just a generic statement.
Testing without travellingUse the Android Emulator's GPS controls (Extended Controls → Location → Routes) to simulate movement. Create a GPX route from your current position to a point 300 m away to test the arrival alarm indoors.
LocalisationAll UI strings should go in res/values/strings.xml. Provide translations in res/values-XX/strings.xml for each locale. The system picks the right file automatically based on the device language.
Based on InOutAlarm v1.1  ·  May 2026

How to Build a Location-Based Android App

A practical walkthrough — from setting up Google Maps and requesting permissions to running a background foreground service that triggers an...