Sunday, May 31, 2026

What a Visual Perception Course Taught Me About the Future of Developers

 Part I

Course Content Summary

On 28–29 May 2026, I attended this NUS course Engineering Visual Perception for Autonomous Systems (by Tian Jing), which covers from sensors to deployment. It explains how vision sensors (cameras, LiDAR, radar) capture data and how images are represented as pixel arrays with color channels.

The following is listed for reference — not that I fully understood every detail, but it captures the key topics covered.

Image Classification

The core classification task assigns a label to an entire image. Two architectures are taught:

  • ResNet-18 (CNN) — 18 layers with residual/skip connections, 11.7M parameters, 1.81G FLOPs
  • Vision Transformer (ViT) — patch embedding (16×16), class token, positional encoding, transformer encoder; 86.6M parameters, 17.56G FLOPs, higher accuracy

Key concepts: convolution operations, pooling, fully connected + SoftMax layers, transfer learning (freeze pretrained layers, fine-tune last K layers).

Object Detection

Detection outputs both a box label (classification) and box coordinates x, y, w, h (regression).

  • YOLOv11 architecture: backbone → neck → head; input 3×480×640 → output 84×6300 (80 COCO classes + 4 box coords, 6300 anchor locations)
  • YOLO annotation format: class_id, x_center, y_center, width, height (normalized)

Training & Evaluation

  • Training: data augmentation, optimizer (SGD/Adam), learning rate scheduling, cross-entropy loss
  • Classification metrics: accuracy, precision, recall, confusion matrix (TP/FP/TN/FN)
  • Detection metrics: IoU, AP@50, AP@50-95, mAP (mean average precision)

Emerging Trends

  • Static → Dynamic: video tracking, event recognition
  • Vision-only → Multimodal: sensor fusion, vision-language models
  • Closed-set → Open-world: VLMs with text prompts for zero-shot detection
  • Accuracy → Robustness: adversarial attacks and security
  • Perception → Reasoning: spatial reasoning, conversational queries over visual data

Hands-on Workshops

Workshops were run on Google Colab using provided Jupyter notebooks in Python: solar panel defect classification (ResNet/ViT) and candy detection (YOLOv11).


Part II

Reflections: AI, Developers & the Changing Landscape

The "Black Box Productivity" Paradox

The workshop experience perfectly illustrates the current shift: we ran ResNet and ViT notebooks on Colab, got correct results, without understanding the source code. This works — until it doesn't. When the model underperforms on your own data, debugging requires understanding the layers you skipped over.

The Freemium Trap & AI Investment Cycle

Massive capital is flowing into AI infrastructure (OpenAI, Google, Microsoft). The playbook is clear: offer free access to build dependency, then monetize. Google Colab already tiered its GPU access; GitHub Copilot is capping free usage; OpenAI raised API prices. These companies are subsidizing adoption with investor money, knowing that once teams build workflows around these tools, switching costs are high.

The risk for developers and organizations: building critical skills and pipelines on platforms whose pricing you don't control.

Developer Role Shift: From Creator to Verifier

AI can now generate boilerplate, debug simple errors, and scaffold entire projects. This shifts the developer's core job:

Old RoleNew Role
Write code from scratchVerify AI-generated code for correctness, security, edge cases
Memorize syntax/APIsEvaluate architectural trade-offs
Debug line-by-lineValidate system-level behavior
Individual contributorAI orchestrator + quality gate

The challenge: verification requires deeper understanding than writing. You can copy-paste a ResNet training loop from Copilot, but catching a subtle data leakage between train/validation splits requires real expertise. The skill floor rises, not falls.

Fresh vs. Experienced Developers — Corporate Perspective

FactorFresh DeveloperExperienced Staff
AI tool adoptionFast, native usersSlower adoption, but deeper judgment
Code verificationLimited — can't spot what they haven't seenStrong — pattern recognition from years of debugging
CostLower salaryHigher salary
Risk with AI-generated codeHigh — may accept flawed output ("it runs, ship it")Lower — skepticism from experience
Domain knowledgeMinimalDeep, hard to replace
AdaptabilityHighVaries

The honest answer: neither alone is optimal. And this mirrors what happens in the classroom too — in a class like the EVPAS workshop, younger students may grasp new concepts like "occlusion" or "mAP" faster, partly because they're closer to academic training and often operating in English natively. But speed of recall is not depth of understanding.

You may forget the word "occlusion" by the next day's exam, yet still arrive at the correct answer by reasoning through elimination — recognizing that partial visibility behind another object is the only option that fits. That's exactly the kind of structured problem-solving that experienced professionals bring.

AI-generated code works the same way — the person who can logically evaluate whether an output makes sense beats the person who memorized the syntax but can't reason about edge cases.

  • Fresh developers are productive fast with AI tools but dangerous without mentorship — they become the person in the workshop who "gets results but doesn't know the details." In production, that gap causes incidents.
  • Experienced developers bring the judgment to verify, debug, and architect — but if they resist AI tools, they lose the speed multiplier. Being a non-native English speaker or an older learner in a fast-moving field adds friction, but it also forces a habit of deliberate learning — double-checking, cross-referencing, reasoning by elimination — which is precisely the verification mindset the AI era demands.

The most effective corporate strategy: pair experienced staff (as verifiers/architects) with AI-augmented fresh developers (as accelerated producers). The experienced developer's role shifts from writing code to reviewing AI-assisted output and teaching juniors what to watch for.

The Real Challenge

The deeper issue is asymmetric skill decay: if junior developers never learn to build from scratch (because AI does it), they never develop the judgment needed to verify. You end up with a generation that can prompt but can't debug — exactly like running a Colab notebook successfully without understanding why it works. The moment the pre-built solution breaks, they're stuck.

The developers who will thrive are those who use AI to move faster while insisting on understanding what the code does — treating AI as a power tool, not a replacement for knowledge. And ironically, the "disadvantage" of being an older, non-native learner — having to slow down, reason carefully, and verify before committing — may be the exact discipline this new era rewards most.

Conclusion

In this fast-pacing AI era, learning is truly endless — new models, new frameworks, new paradigms emerge before you've finished digesting the last one. It can feel impossible to catch up, and the honest truth is: you probably can't catch everything. But that's not the point. As the Chinese saying goes, 姜是老的辣 — "older ginger is spicier" — meaning experience carries a sharpness that youth alone cannot replicate. The value of a seasoned professional is not in memorizing every new API or chasing every trending tool, but in knowing what matters: staying aware of ongoing trends, keeping a watchful eye on where the technology is heading, and — most critically — blending that awareness with the deep domain knowledge accumulated over more than 20 years of real-world practice. Frameworks will come and go, AI tools will rise and be replaced, but the understanding of how systems actually work in production, why certain designs fail under pressure, and what questions to ask when something looks too good to be true — that kind of wisdom is earned, not prompted. The path forward is not to outrun the young, but to out-think the noise: learn selectively, verify rigorously, and let experience be the compass that AI cannot replace.

A question for you, the reader: Every one of us is getting older — that clock doesn't pause for any technology wave. So have you thought about your own future in this AI era? With AI closing the gap between one person and an entire team, are you going to run a one-person company powered by AI — leveraging your domain expertise with AI as your tireless co-worker? Or will you continue seeking traditional employment, hoping the next round of layoffs passes you by? There's no wrong answer, but there is a wrong strategy: not thinking about it at all.
This article was written with the assistance of AI (GitHub Copilot), based on my course experience and personal reflections.

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.


You can see how it would help you in the real world.


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

What a Visual Perception Course Taught Me About the Future of Developers

  Part I Course Content Summary On 28–29 May 2026, I attended this NUS course  Engineering Visual Perception for Autonomous Systems (by Tian...