Website · How it works · Anti-tracking · Build · Donate
blep turns your phone — or your watch — into a warm/cold pointer for the Bluetooth Low Energy devices already around you. Pick a device and blep walks you to it: a single animated arrow and a background colour that shifts from cool to warm as you close in. No extra hardware, no maps, no accounts.
The same scan runs the other way too: blep watches for unwanted trackers travelling with you — an AirTag, Tile or SmartTag someone may have slipped into your bag — and, because it's a finder, doesn't just warn but points you to it. (Anti-tracking ↓)
It's a minimalist, cross-platform Kotlin Multiplatform app (iOS, Android, Apple Watch, Wear OS) plus a small static PWA landing page at blep.fyi. Everything runs on-device — no accounts, no analytics, no data collection.
A phone's BLE radio is roughly omnidirectional, so raw RSSI tells you how far but not which way. blep makes it directional using your own body:
Hold the phone flat against your chest. Your torso absorbs ~10–20 dB of 2.4 GHz signal, so the reading is strongest when the target is in front of you.
From there it's a guided loop driven entirely by the change in signal (ΔRSSI):
| Step | Phase | What you do | blep says |
|---|---|---|---|
| 1 | Calibrate | Hold at your chest, stay still | Hold at your chest |
| 2 | Axis sweep | Turn slowly in place | Warmer · turn back |
| 3 | Vector walk | Walk forward | Keep going · stop |
| 4 | Reorient | Re-sweep as needed | Turn again |
| 5 | Pinpoint | Kneel, search low | Almost there |
| 6 | Done | 🎉 | Finished — congratulations! |
RSSI is noisy and multipath-prone, so this is an assistive heuristic, not a
precise locator. Every threshold lives in
TrackingTuning
and is unit-tested.
The body-shielding loop is the floor, not the ceiling. When motion sensors are present blep fuses them into a parallel spatial tracker — and degrades cleanly back to RSSI-only when they aren't:
- Compass + accelerometer + step counter + GPS dead-reckon the path you walk (real step distance indoors; GPS-fused outdoors). Turning the phone no longer fools the tracker — signal swings during reorientation are discounted.
- A recursive Bayesian particle filter triangulates the target from the RSSI sampled along that path: each reading constrains it to a sphere, and the intersections across your route localise it. It accumulates evidence over time, learns the environment's path-loss as it goes, and can even follow a moving target.
- A barometer adds altitude, so the filter is 3-D — it can tell you the target is one floor up / down.
- The screen is a no-map radar: your heading wedge (green toward / red away), a signal-coloured trail with a warm "fog", and the predicted target as a pulsing glow with an uncertainty ellipse — plus turn-by-turn copy ("turn 30° left · ~8 m") and a Geiger-counter haptic + tone that quickens as you close in. On phone, Wear OS, and Apple Watch.
- At point-blank the live signal trumps the estimate: rather than a stuck triangulated distance, it switches to a plain "It's right here" with a look-around hint. The whole UI is light / dark / system themable, with independent sound and vibration toggles.
It all lives in core/spatial
— pure and unit-tested like the rest, with platform sensor providers behind an
expect/actual boundary.
A finder run in reverse is a tracker detector. Tap "Is something tracking you?" and blep watches the same advertisements for a tag that's travelling with you:
- Known tracker kinds — Find My (AirTag), Tile, Samsung SmartTag and other beacons are recognised by their manufacturer data / service UUIDs, including a Find My device advertising in separated-from-owner mode near you.
- Address-rotation correlation — a privacy tracker rotates its BLE address to stay anonymous, but gives itself away by reappearing at the same close range again and again. The un-correlation is the correlation — the trick AirTag detectors that key on a stable address miss.
- Cross-session memory (opt-in) — a small on-device log of close encounters by tracker kind + time. A tag that keeps showing up across separate hours — and, if you allow coarse location, across separate places — gets flagged as likely following you.
- Background watch (opt-in) — a foreground service / periodic worker keeps an eye out while the app is closed and notifies you, with a battery-minded interval (it skips actively ranging your own paired devices). On Wear OS the watch runs only the lighter periodic (~30-min) check — no continuous foreground service — to spare the watch battery; the phone does the continuous watch.
- Sensitivity profiles — Relaxed / Balanced / Strict retune the thresholds; surfaced both in Settings and on the safety screen.
Because blep is a finder, every alert is actionable: hit Find and it walks
you to the tag with the same warm/cold guidance. Everything stays on-device;
location is opt-in, coarse, and never leaves the phone. It lives in
core/safety
(SafetyScanner · TrackerDetector · SafetyHistory) — pure and unit-tested.
Warning
Tracker detection is new and not yet widely field-tested. It can produce
false negatives (miss a real tracker) and false positives (flag a
harmless device). Treat it as a helpful signal — not a guarantee — and don't
rely on it alone for your safety. blep is a free side project, so real-world
use is the testing: it gets better as people use it, and bug reports of a
missed or mis-flagged tracker are very welcome.
Design notes: docs/detection.md.
The inverse of "is something following you?" — tether a device (a tag on your keys, a bag, earbuds, even your phone) and blep alerts you (notification + vibrate, per your system settings) the moment it slips out of Bluetooth range, and again when it's back, so you notice before you walk off without it. On the phone the device panel has the toggle; on Wear OS you long-press a device in the list. The direction — leave / return / both (default both) — is configurable in Settings.
It works in all three scan modes — in-app, the continuous foreground service, and
the periodic background worker — backed by a small persistent
PresenceMonitor
in core/tether that turns presence samples into debounced leave/return
events (shared by phone and watch, unit-tested). Tethering a device keeps the
foreground watch alive so a leave is caught promptly.
Two detection tracks, chosen automatically by whether the device is paired:
- Paired / bonded devices (earbuds, a car, a watch, a bonded phone) are caught
the instant they disconnect or reconnect via the OS connection-state hook
(
ACTION_ACL_DISCONNECTED/ACTION_ACL_CONNECTED) — event-driven, no scanning, and rotation-proof (a bond resolves to the device's stable identity address). A short debounce rides out a momentary blip. It runs in the foreground service (which a tether keeps alive), so it works with the app closed; aBOOT_COMPLETEDreceiver re-arms it after a reboot. - Unpaired advertising tags use the scan-based
PresenceMonitor.
On Wear OS the watch also warns you if you walk off without your phone: it watches the watch↔phone companion link (the Wear Data Layer — the system delivers connect/disconnect events, no scanning, no foreground service), debounced so a blip doesn't cry wolf. Toggle it in the watch's Settings.
The Apple Watch / iPhone side is still in progress.
Your phone and watch act like one device. Over the Wear Data Layer they keep a small shared state in step and relay live events:
- Converged state — favourites, device names, the tether set and a couple of
settings (sensitivity, left-behind direction) sync both ways. It's a tiny
last-write-wins CRDT (
core/sync/SyncState), so the two converge no matter the order things sync — and a removal (un-favourite, rename-clear) really propagates instead of being resurrected by a stale replica. - Relayed events — the phone, in your pocket with the better antenna and
battery, runs the continuous scan and buzzes the watch when it finds a tracker
or a tethered thing drops out of range (
MessageClient). The watch gets phone-grade detection for almost no watch battery. - Presence —
CapabilityClientreports whether the peer is reachable + nearby (the foundation for splitting scan duty by who's better placed).
Every category is gated by a Sync settings menu (master switch + per-category,
so you can share favourites but keep names private). The whole engine is pure and
unit-tested (SyncManager
SyncState); the transport is anexpect/actual(Data Layer on Android).
In progress: the watch reflecting synced names/favourites in its own list, live scan fusion (relaying sightings for better coverage), and the Apple side (WatchConnectivity is the WCSession analog of the Data Layer). Design in
docs/sync.md.
.
├── app/ Kotlin Multiplatform project
│ ├── core/ Pure tracking logic + BLE scanner (shared, tested)
│ │ └── src/
│ │ ├── commonMain/ RssiFilter · SignalTrend · DeviceTable ·
│ │ │ TrackingSession · BleScanner (expect) ·
│ │ │ spatial/ (DeadReckoner · ParticleTargetEstimator ·
│ │ │ StepCounter · PathLossCalibrator · MotionProvider) ·
│ │ │ safety/ (SafetyScanner · TrackerDetector ·
│ │ │ SafetyHistory · TrackerTuning) ·
│ │ │ sync/ (SyncManager · SyncState LWW CRDT) ·
│ │ │ tether/ (PresenceMonitor)
│ │ ├── commonTest/ JVM-runnable unit tests
│ │ ├── kableMain/ Kable scanner (Android + Apple share this)
│ │ └── jvmMain/ Fake scanner for tests/preview
│ ├── composeApp/ Shared Compose UI as a KMP library (Android + iOS):
│ │ Discovery (favorites + paired) · Tracking ·
│ │ Safety · Settings · background-scan service
│ ├── androidApp/ Android phone app (launcher, manifest, signing)
│ ├── wearApp/ Wear OS app (Wear Compose)
│ ├── iosApp/ SwiftUI shell for iPhone (XcodeGen)
│ ├── watchApp/ SwiftUI watchOS app (uses shared BlepCore)
│ └── zeppApp/ Zepp OS mini-app (Amazfit) — find-only
├── web/ Vite + Tailwind PWA for blep.fyi
├── design/tokens.md Design tokens shared by app + web
└── .github/workflows/ CI (tests + cross-platform compile) + Pages deploy
The core module is deliberately free of platform and UI dependencies so the
tracking logic is provable on a plain JVM. The Compose phone app and the Wear app
each provide a thin state holder (BlepController / WearController) over the
same TrackingSession; the watchOS app does CoreBluetooth in Swift and feeds RSSI
into that same Kotlin engine — no Flow bridging.
┌─────────────────────── :core (commonMain, pure) ───────────────────────┐
│ RssiFilter (EMA, ΔRSSI) → SignalTrend (peak/drop) → TrackingSession │
│ DeviceTable (dedup/sort/TTL) BleScanner (expect) TrackingStatus │
└───────▲───────────────────────────────────▲─────────────────▲──────────┘
│ actual (Kable) │ actual (Kable) │ actual (fake)
androidMain ─┐ appleMain ─┐ jvmMain
└── kableMain (shared) ◄───────────┘
┌─────────────┴───────────┐ ┌────────────┴───────────┐ ┌──────┴───────┐
│ composeApp (Android+iOS)│ │ wearApp (Wear Compose) │ │ watchApp │
│ BlepController + screens│ │ WearController │ │ SwiftUI + │
└─────────────────────────┘ └────────────────────────┘ │ BlepCore │
└──────────────┘
The toolchain is pinned with mise (mise.toml):
JDK 21 + Gradle. Run mise install once, or bring your own JDK 21.
Common tasks are wrapped in a Makefile — run make help:
make test # core unit tests (JVM)
make build # phone + Wear debug APKs
make run # install + launch blep on a connected phone
make demo # install + launch in demo mode (scripted data, no BLE)
make sim # closed-loop tracking simulation + scenario table
make emulator-setup # one-time: install emulator + create the AVD
make emulator # boot it (UI only — emulators have no Bluetooth)
make web # website dev server
make ci # what CI runs on Linux (tests + Android build)
Core unit tests — no Android SDK needed:
cd app
./gradlew :core:jvmTest -Pblep.android=false-Pblep.android=false skips the Android targets so the pure logic builds and
tests anywhere. CI runs the full matrix.
Android (phone + Wear) — needs the Android SDK (ANDROID_HOME or
app/local.properties):
cd app
./gradlew :androidApp:assembleDebug # phone APK
./gradlew :wearApp:assembleDebug # Wear OS APKMin SDK 26 (phone) / 30 (Wear OS 3). BLE permissions are requested at runtime.
iOS / watchOS — needs macOS + Xcode + XcodeGen:
cd app/iosApp && xcodegen generate && open iosApp.xcodeproj # iPhone
cd app/watchApp && xcodegen generate && open watchApp.xcodeproj # Apple WatchA pre-build phase compiles the shared Kotlin framework via Gradle; the
Info.plists declare NSBluetoothAlwaysUsageDescription.
Website:
cd web
npm install
npm run dev # local dev server
npm run build # static output in web/dist
npm run gen:icons # regenerate PWA icons from logo.svg (only if it changes)
npm run gen:shots # regenerate the landing-page gallery from the store screenshotsDeployed to GitHub Pages by .github/workflows/deploy.yml on push to main. The
Pages source and blep.fyi custom domain are set in repo settings, so no CNAME
file is committed.
| Platform | Status | Notes |
|---|---|---|
| Android phone | ✅ | Compose Multiplatform |
| iOS (iPhone) | ✅ | Compose Multiplatform in a SwiftUI shell |
| Wear OS | ✅ | Wear Compose, standalone |
| Apple Watch | ✅ | SwiftUI + shared BlepCore |
| Zepp OS | 🟡 | Find-only mini-app (app/zeppApp). No general third-party BLE central/scan API, so the anti-tracking scan isn't possible. |
The website #donate section (the target of the app's Help & donate button)
supports Stripe, Open Collective, PayPal and GitHub Sponsors, each with a
payment record or receipt. The provider links live in
web/index.html and web/donate.html.
:core ships JVM unit tests for the RSSI filter, trend detector, device table,
the full tracking state machine (calibration → … → completion, plus re-aim and
stale-eviction edge cases), and the whole spatial layer — dead reckoning,
particle-filter localisation (target found to within a few metres, follows a
moving target, 3-D floor detection), path-loss calibration, step counting, haptic
cadence and turn-by-turn guidance, all on synthetic walks — plus the safety
layer (tracker-kind recognition, address-rotation correlation, cross-session
history).
CI additionally compiles the Android, iOS and watchOS
Kotlin targets and builds the SwiftUI iOS + watchOS apps (XcodeGen +
xcodebuild) so the Swift glue is verified too.
MIT © Jan Guth