v0.3.1 introduced TouchInterceptor (a FrameLayout wrapping the
ComposeView) so we could intercept touches before the inner MapView
saw ACTION_DOWN. That made the wrapper the window-root view (the
View actually attached to WindowManager) but I left the
ViewTreeLifecycleOwner / ViewTreeSavedStateRegistryOwner tags on
the inner ComposeView.
Compose's WindowRecomposer.create looks up findViewTreeLifecycleOwner
on the window-root, not on the inner ComposeView. With the tag
missing on the wrapper, Compose throws IllegalStateException at
composition startup — service crash on overlay enable.
Fix: move setViewTreeLifecycleOwner + setViewTreeSavedStateRegistryOwner
to the wrapper. v0.3.0 worked because the ComposeView itself was the
window-root then; the same pattern (owners on whatever's attached
directly to WindowManager) holds here.
- Bug: dragging the bubble panned the OSM map instead of moving the
bubble. The OnTouchListener was attached to the ComposeView, but
the inner MapView consumed ACTION_DOWN for its own pan handling
before the listener fired. Fix: wrap the ComposeView in a custom
TouchInterceptor FrameLayout whose onInterceptTouchEvent always
returns true. Touches go to the wrapper's OnTouchListener; child
views (including MapView) never see them. Bubble is now purely a
visualization — pan/zoom is impossible.
- Drag-to-dismiss: when the user starts dragging (after passing
TAP_SLOP), a translucent dark circle with a white X appears at
bottom-center via a separate WindowManager view. Highlights red
when the bubble's screen-space center is within hit slop of the X.
Releasing on the X tears down the bubble AND fires onDismissed —
DetectionService flips setOverlayEnabled(false) so the toggle and
the bubble state stay in sync. Releasing elsewhere is a normal
drag (just repositions). The dismiss zone uses FLAG_NOT_TOUCHABLE
so it never steals the gesture; it's purely visual feedback.
A 140dp draggable bubble shows the same map / tier scrim / user dot /
ALPR dots that the in-app circle does, on top of any other app, while
scanning is on. Tap = brings the host app forward; drag = repositions.
- Manifest: add SYSTEM_ALERT_WINDOW (special-access — granted via
system Settings page, not the runtime prompt).
- Settings: add overlayEnabled flag (default off) + a "Display over
other apps" section in SettingsScreen. Flipping the toggle to on
fires Settings.ACTION_MANAGE_OVERLAY_PERMISSION so the user can
grant via the system page; if they deny or revoke, the OverlayMgr
re-checks canDrawOverlays() at every show() call and silently
no-ops, no crash.
- New OverlayManager: owns the WindowManager view at
TYPE_APPLICATION_OVERLAY with FLAG_NOT_FOCUSABLE | FLAG_NOT_TOUCH_MODAL
so touches outside the bubble pass through and the bubble never
steals IME focus. Custom OverlayOwner implementing LifecycleOwner +
SavedStateRegistryOwner since LifecycleService doesn't satisfy SSR
(Compose's ComposeView requires both via the view tree).
- Drag/tap handler at the View layer: rawX/rawY math for the drag,
TAP_SLOP_PX guard to discriminate tap from drag, tap launches
MainActivity (FLAG_ACTIVITY_NEW_TASK | SINGLE_TOP).
- New OverlayBubble composable: smaller (140dp) self-contained version
of the in-app threat circle that pulls running/threat/location/
mapPoints/proximity from the same companion StateFlows. Shared
dot-drawable helper extracted into ui/MarkerIcons.kt.
- DetectionService observes settings.overlayEnabled in beginScanning
and toggles the overlay; endScanning hides it.
- Map markers: replace osmdroid's default teardrop pin (which reads as
a 'click me' affordance the map doesn't actually offer) with simple
red dots for ALPRs, matching the blue user-position dot. Drawables
share a single dotDrawable() helper.
- Live re-eval on proximity slider change. Bug: moving the DeFlock or
Citizen distance slider while scanning updated the map's visible
radius but didn't trigger a re-evaluation of which detections fire,
so events outside a tightened radius lingered until restart and
events inside a widened radius wouldn't appear until the next
fix/poll cycle. Fix: add DetectionStore.clearSource(), add
refresh() to DeflockScanner + CitizenScanner that clear the source
and re-emit against cached state, observe the proximity StateFlows
in DetectionService (drop initial replay so we don't redundantly
clear+re-emit on first scan start).
- COMMERCIAL row label was clipping under the Switch. Shorten to
'COMMERCIAL • Nest, Ring, Echo' (drop 'devices') and give every
SourceToggle's Text Modifier.weight(1f) so labels wrap gracefully
on narrow displays instead of getting truncated.
- Center the OVERWATCH header (was left-aligned with the gear pushed
by SpaceBetween — now Box-aligned so the title sits dead-center
regardless of icon width).
- Settings + drill-down: rename the MIC source label to "COMMERCIAL"
("Nest, Ring, Echo devices") for clarity. Internal enum stays MIC.
- Drop the GREEN/YELLOW/ORANGE/RED text inside the threat circle and
bump the tier scrim alpha (0.40-0.65 vs. 0.18-0.50) so the color
reads at a glance over OSM tiles.
- Force the per-event "Open in Maps" pin to use Google Maps instead
of whichever geo: handler the user has set as default (Waze, etc.
could intercept). setPackage("com.google.android.apps.maps") + a
matching <queries> entry in the manifest so it works on Android
11+; web fallback if Maps isn't installed.
- Add a blue user-position dot at the center of the map circle, drawn
on top of any ALPR pins.
- Auto-fit the visible map radius to max(deflockProximityM,
citizenProximityM) via zoomToBoundingBox so the circle's edge
literally represents the alert distance the user has chosen.
- Replace the static threat circle with an osmdroid-backed map
centered on the user, with red ALPR pins and a tier-color scrim.
Falls back to the muted gradient when idle or before the first
location fix arrives.
- Add DetectionSource.MIC: BLE/WiFi candidate path for Amazon
Echo/Ring (Lab126 OUIs + AVS service UUID 0xFE03), Google Nest/
Home/Chromecast (Google OUIs + mfg id 0x00E0), and generic
Chinese hidden-cam vendors. Score capped at 84 (ORANGE) so RED
stays reserved for ALPR/Axon-grade evidence. Toggleable in
Settings; piggybacks on the BLE+WiFi scanners — no new radio.
- Drop the "[DЯΣΛMMΛKΣЯ]" stylized branding for a clean OVERWATCH
header (notification channel + app label updated to match).
- Fix DeFlock geo-pin tap doing nothing: resolveActivity returns
null on Android 11+ without a <queries> entry even when Maps is
installed. Drop the pre-check, try/catch ActivityNotFoundException,
fall back to a maps.google.com URL if no geo: handler exists.
Stale items corrected:
- Architecture file list referenced WazeClient.kt and WazeScanner.kt
(deleted) and CDN-tile DeflockClient (now Overpass POST). Added the
missing CitizenClient/CitizenScanner/SourceHealth/ThreatLevel files.
- Permissions table said "DeFlock CDN + Waze API" — now Overpass +
Citizen. Added VIBRATE row.
- Settings section listed Waze instead of Citizen; missing the new
Vibrate-on-escalation toggle and Restart-to-apply button.
- Status said "Phases 1-5 complete as of v0.1.0" — bumped to v0.1.7
with a per-version changelog of what landed.
Added:
- Hero paragraph mentions notification + vibration alerting.
- New "How alerts work" section explaining notification updates,
vibration cadence, drill-down sheet, and Open-in-Maps.
- Idle-visual note in scoring section.
- START_NOT_STICKY note in architecture.
- Open-app-settings recovery note in permissions section.
The screen enum lives entirely inside Compose, so the system back press
went straight to Activity.finish(). Added a BackHandler in the SETTINGS
branch that intercepts and routes back to MAIN.
versionCode 7 → 8, versionName 0.1.6 → 0.1.7.
Critical
--------
- DetectionService: subscribe to threatLevel + top event flows; rebuild the
foreground notification on every change so a locked-screen user sees
escalations. Vibrate on upward tier transitions (escalating waveforms for
YELLOW/ORANGE/RED), gated by Settings.vibrateOnAlert (default on).
- DetectionService: only mark _running=true if at least one scanner started;
stopSelf() if everything was disabled or denied. Switch START_STICKY →
START_NOT_STICKY so a system-killed service doesn't re-create into a
stuck "running but not scanning" state.
- DeflockClient: detect Overpass timeout-in-body (`{"remark": "...timed
out..."}`) and treat as failure — previously these 200-with-empty-elements
responses got cached for 24 h, hiding ALPRs in that 5×5 km cell for the
next day.
- DeflockScanner: record lastFetch coords + timestamp on BOTH success and
failure, with a 60 s backoff window after a failed attempt. Previously
`lastFetchLat` was only set on Success, so every subsequent location
update would re-trigger a 30 s POST that collectLatest then cancelled —
we'd never finish a fetch under sustained Overpass slowness.
- LocationProvider: stale-lastLocation race fix. The async `lastLocation`
callback now only seeds `_location` if it's still null and we're still
running — previously it could overwrite a fresher fix from
requestLocationUpdates, or fire after stop() and resurrect _location with
stale data.
Moderate
--------
- CitizenScanner: wait for the first non-null location with .first { } before
starting the poll/delay loop. First Citizen poll now fires within seconds
of the location fix, not up to 60 s after.
- MainScreen: when not running, show a muted gray circle with "IDLE" text
instead of the same solid green look as "scanning, all clear" — the
pulse animation was the only differentiator before.
- Compose state: rememberSaveable for the screen enum + bottom-sheet open
state, so SETTINGS survives rotation.
- MainActivity: detect permanently-denied permissions (the user picked
"don't ask again") via shouldShowRequestPermissionRationale. UI swaps the
call-to-action to "Open app settings" which fires
Settings.ACTION_APPLICATION_DETAILS_SETTINGS. onResume re-checks so a
user returning from app settings is reflected immediately.
Improvements
------------
- BLE/WiFi scanners record SourceHealth.OK on a successful start (and
FAILED with a specific reason on every short-circuit — disabled adapter,
missing permission, etc.) so the drill-down sheet is honest about radio
state, not just network state.
- DetectionEvent gains optional lat/lon (populated by DEFLOCK and CITIZEN);
SourceRow shows a tap-to-open-Maps icon next to events with coordinates,
firing a `geo:lat,lon?q=lat,lon(label)` Intent.
- SettingsScreen sliders use onValueChangeFinished — only commit to
SharedPreferences on drag-release, not on every pixel of movement.
- New Settings.vibrateOnAlert toggle (default on) wired to a SettingsScreen
row under a new "Alerts" section.
Minor
-----
- BleScanner iterates ALL manufacturer-data entries to find XUNTONG; only
falls back to the first entry if no XUNTONG match is present. Previously
we only inspected the first entry.
- Drop dead `?.` on JSONArray.optString in CitizenClient (returns String,
never null).
- Remove unused rememberCoroutineScope in MainScreen.
- Update stale Phase/Waze references in DetectionService comments.
- Add VIBRATE permission to manifest.
versionCode 6 → 7, versionName 0.1.5 → 0.1.6.
Waze's reCAPTCHA gating on live-map/api/georss has no clean mobile
workaround, and the Citizen source added in v0.1.4 covers the same
threat model with better data. Keeping a permanently-failed source
visible was UI clutter — drop it.
Removed:
- scan/WazeClient.kt and scan/WazeScanner.kt (deleted)
- WAZE from DetectionSource enum
- waze flow from SourceHealth (+ flowFor/record/reset cases)
- WazeObservation + scoreWaze + W_WAZE_POLICE from ConfidenceEngine
- wazeEnabled from Settings (+ KEY_WAZE)
- WAZE row from SettingsScreen
- wazeScanner from DetectionService
Renamed (Citizen now owns the proximity slider that Waze used to share):
- Settings.wazeProximityM → citizenProximityM
- Settings.setWazeProximityM → setCitizenProximityM
- KEY_WAZE_PROX → KEY_CITIZEN_PROX
- DEFAULT_WAZE_PROX → DEFAULT_CITIZEN_PROX (still 500)
- SettingsScreen "Waze alert distance" → "Citizen alert distance"
Existing users will see the slider reset to 500 m default since the
SharedPreferences key changed.
versionCode 5 → 6, versionName 0.1.4 → 0.1.5.
Waze remains gated behind 2025/2026 reCAPTCHA on live-map; added Citizen
as a working alternative for police-presence signal. Citizen pulls from
911 + scanner traffic, returns rich incident data (lat/lon, timestamp,
severity level, responding precinct, title), and has no auth or
rate-limit gating.
New scan/CitizenClient.kt:
- GET /api/incident/trending (bbox query → list of incident ids)
- GET /api/incident/{id} (full detail per id)
- Sealed TrendingResult so the scanner can surface 4xx via SourceHealth.
New scan/CitizenScanner.kt:
- 60s poll interval, 30-min freshness window
- Per-id detail cache for the lifetime of a start/stop cycle —
incidents are immutable, so each is fetched at most once per session
- Title regex filter: drops pure fire/medical events that don't imply
police presence; retains them when the title also names police action
- Submits to the shared DetectionStore as DetectionSource.CITIZEN
ConfidenceEngine.scoreCitizen:
- Base 55 (matches the old W_WAZE_POLICE weight)
- +5 if level >= 2 (Citizen's own severity)
- +5 if title contains police-action keyword (police/officer/arrest/
swat/tactical/raid/pursuit/stop/search warrant)
Settings: new citizenEnabled toggle (default on); UI row in
SettingsScreen. SourceHealth has a new flow for CITIZEN. DetectionService
starts the scanner alongside the others when location is available.
Continued investigation of Waze / Google Maps police APIs:
- Waze SDK (hewliyang/waze-traffic-api): wraps the same blocked endpoint
- ddd/google_maps reverse-engineering: locations only, no incidents
- Google Maps Platform: no public incidents API (just displays Waze data internally)
- TomTom Traffic Incidents: traffic-only, no police presence
- Waze for Cities partner feed: real but requires being a city/police agency
versionCode 4 → 5, versionName 0.1.3 → 0.1.4.
The cdn.deflock.me CDN is gated behind Cloudflare bot mitigation that
mobile HTTP clients can't pass. The live deflock-app Flutter client
abandoned that path; it POSTs Overpass-QL queries directly to
overpass.deflock.org (with overpass-api.de as a fallback). Verified by
hitting the same endpoint from curl — 22 ALPRs returned for the
Springfield VA bbox, matching the user's screenshot of the working app.
DeflockClient rewrite:
- POST [out:json][timeout:25];(node[surveillance][type=ALPR](bbox););out body;
- 5 km half-width bbox around the user
- 24h on-disk cache keyed by 0.05° grid cell (revisits don't refetch)
- Returns sealed FetchResult: Success(points) | Failed(reason)
DeflockScanner update:
- Replaces 20° tile concept with distance-based refetch (1.5 km threshold)
- Records SourceHealth on each fetch outcome
Waze: reCAPTCHA gating confirmed. WazeClient.fetchPoliceNear now returns
sealed FetchResult; WazeScanner records SourceHealth.FAILED with
"Upstream blocked (HTTP 403)" so the user sees why no Waze data is
flowing instead of silent zeros.
New fusion/SourceHealth.kt — per-source MutableStateFlow registry,
record(source, ok, message) + reset() called on service start/stop.
UI: SourceRow in the bottom-sheet drill-down now shows the health
message in orange when status = FAILED instead of "no detections".
versionCode 3 → 4, versionName 0.1.2 → 0.1.3.
Critical:
- DetectionService.startInForeground now passes
FOREGROUND_SERVICE_TYPE_LOCATION OR'd with TYPE_CONNECTED_DEVICE on
Android 14+. Without this, the system silently revoked location access
once the screen locked, breaking DeFlock + Waze for foreground-service
use (the whole point of the foreground service).
- DeflockClient and WazeClient now skip JSON entries whose lat/lon parse
to NaN. Previously NaN flowed into Location.distanceBetween, the
NaN > limit check returned false (IEEE 754), and we submitted a
full-confidence detection labeled "@0m" — instant false-positive RED
from a single malformed map entry.
UX:
- First-run permission flow auto-starts scanning after the user grants
everything; no second tap on START required.
- Settings shows a "Restart scan to apply" button when toggling sources
while scanning. Source toggle changes used to silently no-op until
the next manual stop+start.
versionCode 1 → 3, versionName 0.1.0 → 0.1.2.
The button was gated on `granted || running`, but the only thing that
triggers the permission request is tapping the button — catch-22 that
left first-time users with no way to grant permissions.
Always enable the button when not running; the onStartStop handler already
routes correctly (start scanning if granted, else launch the permission
request flow). Updated the helper text to point at this directly.