Kestrel is an Android mock GPS app that lets you pin the system location to any point on the globe, simulate walking along a route, or generate random walking paths — all without root access.
Built with Kotlin, Jetpack Compose, Material 3, and MapLibre. Kestrel uses Android's official mock location APIs and is designed for development, testing, and personal location-simulation workflows.
| Feature | Description |
|---|---|
| 📍 Single-point mock | Tap the map or paste a lat,lng coordinate / Google Maps URL to lock the system location to that point. |
| 🚶 Route playback | Define waypoints, set speed (km/h), and let the engine walk the path automatically. Supports Once / Loop / PingPong playback modes. |
| 🎲 Random route generation | Enter a waypoint count and step distance; Kestrel generates a smooth random walk from the current map centre. |
| ⭐ Favorites | Save single points or full routes. Sort by Recent / Alphabetical / Manual order. |
| 🔄 Startup behaviour | Resume the last mock state, stay at the current location, or apply a saved favourite on every launch. |
-
Android 10+ (API 29+)
-
Developer Options → Select mock location app → Kestrel
-
(Recommended) Settings → Location → Location Services → Google Location Accuracy → Off
Disabling Google Location Accuracy prevents Google Play Services from overriding the mocked position with Wi-Fi / cell-tower fusion.
Kestrel uses the Android platform APIs LocationManager.addTestProvider() and setTestProviderLocation() — the official mock location mechanism. No root or system modification is required.
A foreground service (type location) keeps the mock alive while the UI is in the background. Movement is driven by a 1 Hz tick that advances MovementEngine along the route and pushes each sample through MockProviderManager.
The web console can remote-control an Android device only after the Android app is signed in and the user enables Options → Web remote control. First-version remote control uses the cloud command queue plus Android polling: Kestrel must be open in the foreground or already running its mock-location foreground service to receive commands. Commands expire after a bounded window if they are not delivered, and neither the web console, Google services, nor the backend can wake an app process that Android has killed.
⚠️ Note: Apps protected by Play Integrity or SafetyNet can still detect mock locations — this is a system-level behaviour that Kestrel does not attempt to bypass.
app/src/main/java/dev/narumi/kestrel/
├── core/
│ ├── data/ # DataStore Preferences, @Serializable schema
│ ├── location/ # LatLng, Geo, MovementEngine, RouteGenerator,
│ │ # LocationService, MockProviderManager, CoordParser
│ └── map/ # KestrelMap (MapLibre Compose wrapper), MapStyle
└── feature/
├── map/ # Main screen with map and bottom sheet
├── favorites/ # Saved points and routes list
├── options/ # Startup behaviour settings
├── routes/
├── tracks/
└── settings/
backend/ # NestJS + Prisma cloud platform backend
web/ # Next.js cloud console
Prerequisites: Android Studio, JDK (bundled with Android Studio),
adbonPATH. All common tasks are driven by thejustfile— install just to use them.
| Task | Command |
|---|---|
| Build debug APK | just build |
| Build release APK | just release |
| Build → install → launch | just (or just br) |
| Run unit tests | just test |
| Auto-format (Spotless + ktlint + Biome) | just format |
| Verify formatting (no writes) | just check |
| Detekt static analysis | just lint |
| Regenerate Detekt baseline | just lint-baseline |
| Install git hooks (prek) | just hooks |
| Reset app data | just reset |
| Follow logcat | just log |
| Start full dev stack (Docker Compose) | just cloud-up |
| Stop Docker Compose stack | just cloud-down |
Pure-Kotlin tests (no Android context needed) live in app/src/test/. Tests that require an Android context go in app/src/androidTest/.
just testThe NestJS + Prisma backend lives in backend/.
cd backend
npm install
npm run db:up
npm run prisma:migrate:dev
npm run start:devThe Next.js cloud console lives in web/ and proxies /api/backend/* to the NestJS API.
cd web
npm install
npm run devOpen http://localhost:3301. Set KESTREL_API_BASE_URL if the API is not running on http://localhost:3300.
To run PostgreSQL, the NestJS backend, and the Next.js web console together:
just cloud-up # or: docker compose up --build| Service | Address |
|---|---|
| PostgreSQL | localhost:15432 |
| Backend API | http://localhost:3300 |
| Web console | http://localhost:3301 |
The Compose file uses development defaults and bind-mounts backend/ and web/ for live reload.
If those ports are already taken, copy .env.example to .env and adjust the KESTREL_*_PORT variables.
Use just cloud-down to stop the stack.
.github/workflows/ci.yml runs three lanes on every PR and push to main:
| Lane | What it runs | Triggers on |
|---|---|---|
android |
spotlessCheck, detekt, :app:assembleDebug, unit tests (non-blocking), uploads app-debug.apk artifact |
changes under app/, top-level Gradle files, detekt*, justfile, or .github/workflows/** |
backend |
npm ci, prisma generate, lint, test, test:e2e, typecheck, build |
changes under backend/ or .github/workflows/** |
web |
npm ci, lint, typecheck, build |
changes under web/ or .github/workflows/** |
A leading changes job uses dorny/paths-filter to gate each lane, so a PR touching only one workspace skips the others. Push events to main always run all three.
just releaseruns:app:assembleReleaseand producesapp/build/outputs/apk/release/app-release-unsigned.apk..github/workflows/bump-version.ymltakes amajor/minor/patchchoice, callsscripts/bump-version.py, and bumps the shared Android/backend/web version plus AndroidappVersionCode, then opens a PR using repository secretPAT_TOKENso the resulting PR can trigger downstream CI workflows..github/workflows/tag-release.ymlwatches version-file merges tomain, resolves the shared Android/backend/web version, and pushes the matchingv*tag withPAT_TOKENif it does not already exist..github/workflows/release.ymlcallsscripts/resolve-release-metadata.sh, then publishes a GitHub Release when a matchingv*tag is pushed (or when manually dispatched against an existing tag) and uploads the unsigned release APK as a release asset.
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<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"/>
<uses-permission android:name="android.permission.INTERNET"/> <!-- map tile downloads -->ACCESS_MOCK_LOCATION is declared in the manifest (required for the app to appear in the Developer Options mock-location picker) but has no runtime effect since Android 6.