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

Saturday, January 17, 2026

Time-Offset Solution to Year 2038 Problem

 

Content List

About the Year 2038 Problem

Quick Check

Solution Approaches

Time Offset Approach

Algorithm Summary

Header File Components

How to Use

Verification and Validation

Limitations

Conclusion


About the Year 2038 Problem

The Year 2038 problem affects systems using 32-bit signed integers to store Unix timestamps (seconds since January 1, 1970, 00:00:00 UTC). The Critical Overflow Point is:

- Maximum value: `0x7FFFFFFF` = 2,147,483,647 seconds

- Overflow date: January 19, 2038, 03:14:07 UTC

- After overflow: timestamp wraps to `0x80000000` (negative), representing December 13, 1901

 

Impact

- Time calculations fail (dates revert to 1901)

- Logs become invalid

- Scheduling systems malfunction

- Database queries break

- Certificate validation fails

- Legal/compliance issues

 

Why Application-Level Solutions?

- Hardware constraints: Embedded systems, legacy 32-bit CPUs cannot be upgraded

- OS limitations: Cannot recompile kernel or modify system libraries

- Cost: Hardware replacement may be prohibitively expensive

- Isolation: Application can work correctly even if OS time is wrong

 

Quick Check

Login the system and change the time closing to 03:14:07 UTC on 19 January 2038, and then check if the OS can continue after this time, as shown below:



Solution Approaches

 

1. Operating System Time Offset

How it works:

- Set system clock back by ~68 years (0x7FFFFFFF seconds)

- Application adds offset back to get real time

- OS never sees timestamps > 0x7FFFFFFF

 

Pros:

- No kernel modifications needed

- Works with legacy binaries

- Transparent to OS-level functions

 

Cons:

- Requires coordination between system and application

- External time sources need handling

- Log correlation with non-offset systems

 

2. Custom Time Types

Replace `time_t` with `uint64_t` or custom struct.

 

Pros:

- True long-term solution (works beyond 2106)

- Clean architecture

 

Cons:

- Massive code refactoring

- Cannot use standard library functions (`strftime`, `localtime`, etc.)

- Third-party library compatibility issues

 

 3. Kernel/Library Upgrade

Recompile OS with 64-bit `time_t`.

 

Pros:

- Standard solution for modern systems

 

Cons:

- Not possible for embedded/legacy systems

- Requires full system rebuild

- Binary incompatibility

 

 

Time Offset Approach

 

The workaround uses a time offset strategy:

 

1. System clock is set back by ~68 years (0x7FFFFFFF seconds)

2. Application adds offset back to get real time

3. OS never sees timestamps > 0x7FFFFFFF, preventing overflow

 

┌──────────────────────────────────────────────────

│                                                             APPLICATION                                                       

│                                     Calls: GetRealTimeUL(), GetRealLocalTime()                             

│                                        Sees:  Real dates (2026, 2038, 2050...)                                       

└─────────────────────────────────────────────────

                                                                      │ + Y2038_TIME_OFFSET

                                                                      │   (when active)

┌──────────────────────────────────────────────────

│                                                      OPERATING SYSTEM                                                     

│                                          Clock set to: ~68 years in the past                                                 

│                                       Sees: Safe timestamps (never > 0x7FFFFFFF)                                

└───────────────────────────────────────────────────

 

Validation Status

 

Epoch start (1970)  

Pre-2038 dates  

Overflow point (2038-01-19 03:14:07)  

Post-2038 dates  

Leap year handling (Feb 29)  

Century boundaries (2100, 2000)  

Maximum value (2106)  


Algorithm Summary

 

Key Features

- Works for any `unsigned long` timestamp (0 to 4,294,967,295)

- Correctly handles Gregorian leap year rules

- No external library dependencies

 

Unix Timestamp to Date/Time Conversion Algorithm

Given the Unix Timestamp ulTime (unsigned long):

| Step | Operation                                                        | Output                       

|--------|-------------------------------------------------------------|------------------------

| 1      | `seconds = ulTime % 86400`                         | Time of day               

| 2      | `days = ulTime / 86400`                                | Days since epoch

| 3      | `(days + 4) % 7`                                             | Day of week             

| 4      | Approximate year, count leap years, refine   | Year                    

| 5      | `days - days_in_years`                                   | Day of year              

| 6      | Iterate through months                                   | Month and day     

 

Function Signature

static inline void UnixTimeToTm(unsigned long ulTime, struct tm* result)

 

Input:

- `ulTime`: Unix timestamp (seconds since Jan 1, 1970, 00:00:00 UTC)

- Can be any value from 0 to 0xFFFFFFFF (4,294,967,295)

 

Output:

- `result`: Populated `struct tm` with:

  - `tm_year`: Years since 1900

  - `tm_mon`: Month (0-11)

  - `tm_mday`: Day of month (1-31)

  - `tm_hour`: Hour (0-23)

  - `tm_min`: Minute (0-59)

  - `tm_sec`: Second (0-59)

  - `tm_wday`: Day of week (0-6, Sunday=0)

  - `tm_yday`: Day of year (0-365)

  - `tm_isdst`: DST flag (-1 = unknown)

 

Time-of-Day Extraction

- 1 day = 86,400 seconds (24 × 60 × 60)

- 1 hour = 3,600 seconds (60 × 60)

- 1 minute = 60 seconds

// Days since epoch and remaining seconds

days = ulTime / 86400;      // Integer division

seconds = ulTime % 86400;   // Remainder

 

// Time of day

result->tm_hour = seconds / 3600;

result->tm_min = (seconds % 3600) / 60;

result->tm_sec = seconds % 60;

 

Day-of-Week Calculation

 

Unix epoch starts on Thursday, January 1, 1970.

result->tm_wday = (days + 4) % 7;

Day-of-week values in `struct tm`:

0 = Sunday

1 = Monday

2 = Tuesday

3 = Wednesday

4 = Thursday  ← Jan 1, 1970

5 = Friday

6 = Saturday

 

Year Calculation

 

The year calculation uses an approximate-then-refine strategy:

- Assumes all years have 365 days

- Simple division gives rough estimate

- Will typically overshoot by 1-2 years due to ignored leap years

 

┌────────────────────────────────────────────

│  Step 1: Initial Approximation                                                                           

│  year ≈ 1970 + days/365                                                                                    

└───────────────────────────────────────────

                                 │  

┌──────────────────────────────────────────

│  Step 2: Count Leap Years Before 'year'                                                          

│  leap_years = f(year)                                                                                       

└──────────────────────────────────────────

                                 

┌──────────────────────────────────────────

│  Step 3: Calculate Total Days in Years                                                             

│  days_in_years = (year-1970)×365 + leap                                                       

└──────────────────────────────────────────

                                 

┌─────────────────────────────────────────

│  Step 4: Refine if Overshot                                                                            

│  while (days_in_years > days) { year-- }                                                       

└─────────────────────────────────────────

                                 

┌────────────────────────────────────────

│  Step 5: Calculate Day-of-Year                                                                   

│  yday = days - days_in_years                                                                      

└─────────────────────────────────────────

 

Step 1: Initial Approximation

year = 1970 + (days / 365);

Step 2: Leap Year Counting

unsigned long leap_years = ((year - 1) - 1968) / 4

                          - ((year - 1) - 1900) / 100

                          + ((year - 1) - 1600) / 400;

Based on Gregorian Calendar Leap Year Rules

Rule 1: Years divisible by 4 are leap years

Rule 2: Except years divisible by 100 are NOT leap years  

Rule 3: Except years divisible by 400 ARE leap years

 

Examples:

- 2024: divisible by 4 → **leap year**

- 2100: divisible by 100 → **not a leap year**

- 2000: divisible by 400 → **leap year**

 

Step 3: Calculate Days in Years

unsigned long days_in_years = (year - 1970) * 365 + leap_years;

 

Step 4: Refinement Loop

while (days_in_years > days) {

    year--;

    leap_years = ((year - 1) - 1968) / 4

               - ((year - 1) - 1900) / 100

               + ((year - 1) - 1600) / 400;

    days_in_years = (year - 1970) * 365 + leap_years;

}

Why overshoot occurs:

- Initial `days/365` ignores leap years

- Leap years add extra days

- Division by 365 makes us think we've passed more years than we have

 

Example: 1,460 days from epoch

Initial approximation:

year = 1970 + (1460 / 365) = 1974  // Assumes 4 full years

Reality check:

  • 1970: 365 days
  • 1971: 365 days
  • 1972: 366 days (leap year)
  • 1973: 365 days
  • Total through 1973: 1,461 days

So 1,460 days only gets us to December 30, 1973, not 1974!

 

Step 5: Day-of-Year Calculation

result->tm_year = year - 1900;

unsigned long yday = days - days_in_years;

result->tm_yday = yday;

 

Month and Day

unsigned long yday = days - days_in_years;  // Days into current year (0-365)

int is_leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);

 

Month Length Lookup Table:

static const int days_in_month[2][12] = {

    {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31},  // [0] = Non-leap year

    {31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}   // [1] = Leap year (Feb has 29)

};

mon = 0;

while (mon < 12 && yday >= (unsigned long)days_in_month[is_leap][mon]) {

    yday -= days_in_month[is_leap][mon];

    mon++;

}

How it works:

  1. Start with mon = 0 (January)
  2. Check if remaining yday is ≥ days in current month
  3. If yes: subtract that month's days and move to next month
  4. Repeat until yday < days in current month
  5. The remaining yday is the day within that month

Final Assignment:

result->tm_mon = mon;       // Month (0-11, where 0=January)

result->tm_mday = yday + 1; // Day of month (1-31, +1 because yday is 0-indexed)

 

 

 

Header File Components


You may get the source codes from https://github.com/happytong/time_offset_year2038


1. Configuration Constants

 

#define Y2038_TIME_OFFSET   0x7fffffff  // ~68 years in seconds

extern int g_nY2038OffsetActive;        // Runtime enable flag

 

- `Y2038_TIME_OFFSET`: The offset value (2,147,483,647 seconds ≈ 68 years)

- `g_nY2038OffsetActive`: Set to `1` to enable offset mode, `0` for normal operation

 

2. GetRealTimeUL()

 

Purpose: Get current timestamp as `unsigned long`, with offset correction.

static inline unsigned long GetRealTimeUL(void)

{

    time_t tOsTime = time(NULL);

    unsigned long ulOsTime = (unsigned long)tOsTime;

    if (g_nY2038OffsetActive) {

        return ulOsTime + Y2038_TIME_OFFSET;

    }

    return ulOsTime;

}

 

Usage:

unsigned long now = GetRealTimeUL();  // Safe timestamp, works beyond 2038

 

️ Warning: Do NOT cast the return value to `time_t` if it exceeds 0x7FFFFFFF!

 

3. UnixTimeToTm()

 

Purpose: Convert Unix timestamp to `struct tm` without using standard library functions (which fail for large timestamps). Details explained earlier.

static inline void UnixTimeToTm(unsigned long ulTime, struct tm* result)

 

4. GetRealLocalTime()

 

Purpose: Replacement for `localtime(time(NULL))`.

static inline struct tm* GetRealLocalTime(struct tm* result)

{

    unsigned long ulRealTime = GetRealTimeUL();

   

    // Use standard function if safe

    if (!g_nY2038OffsetActive && ulRealTime <= 0x7FFFFFFF) {

        time_t t = (time_t)ulRealTime;

        return localtime_r(&t, result);

    }

   

    // Otherwise use manual conversion

    UnixTimeToTm(ulRealTime, result);

    return result;

}

 

Behavior:

- Uses standard `localtime_r()` when safe (pre-2038, offset not active)

- Uses manual `UnixTimeToTm()` when timestamp exceeds safe range

 

5. FormatRealTime()

 

Purpose: Format current time as a string (for logging).

 

static inline void FormatRealTime(char* buffer, size_t size, const char* format)

{

    struct tm tmResult;

    GetRealLocalTime(&tmResult);

    strftime(buffer, size, format, &tmResult);

}

 

Usage:

char timestamp[32];

FormatRealTime(timestamp, sizeof(timestamp), "%Y-%m-%d %H:%M:%S");

// Result: "2038-01-19 03:14:07"

 

 

How to Use

 

Step 1: Include the Header

#include "year2038.h"

 

Step 2: Define the Global Variable

In one source file (e.g., `main.c`):

int g_nY2038OffsetActive = 0;  // Start in normal mode

 

Step 3: Enable Offset Mode

// In application initialization or when the time values bigger than 0x7FFFFFFF

g_nY2038OffsetActive = 1;  // Enable Year 2038 workaround

 

Step 4: Replace Time API Calls

Following existing APIs need to be replaced, note this is not the full list.

 

| Existing API                              | New Code

|---------------------------------------|----------------

| `time(NULL) `                           | `GetRealTimeUL()`

| `localtime(&t)`                           | `GetRealLocalTime(&result)`

| `strftime(...)` with time              | `FormatRealTime(...)`

 

Example Migration:

  OLD (fails after 2038)

time_t now = time(NULL);

struct tm* local = localtime(&now);

printf("Year: %d\n", local->tm_year + 1900);

 

  NEW (works beyond 2038)

struct tm local;

GetRealLocalTime(&local);

printf("Year: %d\n", local.tm_year + 1900);

 

Verification and Validation

Following function demonstrates how to implement the solution.

int g_nY2038OffsetActive = 0;

void TestTimeSync(unsigned long ulDateTime)

{

    time_t tOsTime;  // Time value to set in OS

 

    if (ulDateTime > Y2038_TIME_OFFSET && sizeof(time_t) == 4)

    {

        // Time beyond 2038 on 32-bit system - use offset

        if (!g_nY2038OffsetActive)

        {

            // First time crossing threshold

            g_nY2038OffsetActive = 1;

            printf("Y2038: Activating time offset mode (real time: 0x%lx)\n", ulDateTime);

 

        }

 

        // Subtract offset to keep OS time in valid range

        tOsTime = (time_t)(ulDateTime - Y2038_TIME_OFFSET);

 

        // Verify result is still valid for 32-bit signed

        if (tOsTime > Y2038_TIME_OFFSET || tOsTime < 0)

        {

            printf("ERROR: Time 0x%lx still out of range after offset\n\n", ulDateTime);

            return;

        }

    }

    else

    {

        // Normal operation - no offset needed

        tOsTime = (time_t)ulDateTime;

        if (g_nY2038OffsetActive && ulDateTime <= Y2038_TIME_OFFSET)

        {

            // Time went back below threshold (shouldn't happen normally)

            g_nY2038OffsetActive = 0;

            printf( "Y2038 offset deactivated: %ld\n", ulDateTime);

        }

    }

 

    time_t tNow = time(NULL);

    int nDiffTime = (int)difftime(tNow, tOsTime);

    if (nDiffTime > 2 || nDiffTime < -2) //only set time when there is a gap

    {

        if (g_nY2038OffsetActive)

        {

            printf("TimeSync (offset mode) diff=%ds (OS=%ld -> %ld, Real=%ld)\n",

                    nDiffTime, tNow, (long)tOsTime, (long)ulDateTime);

        }

        else

        {

            printf("TimeSync diff=%ds (%ld -> %ld)\n", nDiffTime, tNow, (long)tOsTime);

        }

 

        struct timespec stime;

        stime.tv_sec = tOsTime;  // Use offset-adjusted time

        stime.tv_nsec = 0;

 

        if (clock_settime( CLOCK_REALTIME, &stime) == -1) //set system time

        {

            int n = errno; //22: if time value > 0x7fffffff (invalid argument): > 20380119 03:14:07

            printf("TimeSync error: %d (%s)\n\n", n, strerror(n));

        }

        else

        {

            // Print real time after sync (with offset added back)

            struct tm tmReal;

            UnixTimeToTm(ulDateTime, &tmReal);

 

            struct tm tmOs;

            time_t tOsTimeNow = time(NULL);

            UnixTimeToTm((unsigned long)tOsTimeNow, &tmOs);

           

            printf("Time synchronized, diff=%ds\n App time: %04d-%02d-%02d %02d:%02d:%02d (%lu)\n OS time: %04d-%02d-%02d %02d:%02d:%02d (%ld)\n\n",

                   nDiffTime,

                   tmReal.tm_year + 1900, tmReal.tm_mon + 1, tmReal.tm_mday,

                   tmReal.tm_hour, tmReal.tm_min, tmReal.tm_sec,

                   ulDateTime,

                   tmOs.tm_year + 1900, tmOs.tm_mon + 1, tmOs.tm_mday,

                   tmOs.tm_hour, tmOs.tm_min, tmOs.tm_sec,

                   (long)tOsTimeNow);

        }

    }

}

 

Run the application with some values:



The last one is from online EPOCH converter, which has the same result:


 

Limitations

 

1. UTC Only

`UnixTimeToTm()` calculates UTC time. It does not apply:

- Timezone offsets

- Daylight Saving Time (DST) adjustments

 

Workaround: Apply timezone offset manually after conversion.

 

2. Year 2106 Limit

On 32-bit systems, `unsigned long` is 32 bits:

- Maximum value: 0xFFFFFFFE = 4,294,967,294 seconds

- Maximum date: February 7, 2106, 06:28:14 UTC

 

3. NTP Must Be Disabled

When using offset mode, disable automatic time synchronization:

sudo systemctl stop ntp

sudo systemctl disable ntp

 

4. External System Interoperability

Systems not using the offset will interpret timestamps differently. Document timestamp formats in protocols.

 

Conclusion

 

The `UnixTimeToTm` algorithm provides a robust, efficient, and mathematically sound solution for converting Unix timestamps to human-readable dates beyond the Year 2038 limit.

1. Constant Time Complexity: O(1) with predictable performance

2. No External Dependencies: Pure integer arithmetic, no library calls

3. Mathematical Correctness: Properly handles all Gregorian calendar rules

4. Extended Range: Supports dates through Feb 7, 2106

5. Embedded-Friendly: No dynamic allocation, minimal stack usage

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...