diff --git a/app/build.gradle.kts b/app/build.gradle.kts index afb2649..536f9dd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "org.soulstone.overwatch" minSdk = 26 targetSdk = 35 - versionCode = 9 - versionName = "0.2.0" + versionCode = 10 + versionName = "0.2.1" } buildTypes { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e1cd96a..0268574 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -40,6 +40,13 @@ + + + + + , mapPoints: List, userLocation: Location?, + /** Visible radius of the map circle, in meters. Driven by the larger of + * the DeFlock and Citizen proximity sliders so the user sees the full + * area where a detection could fire. */ + mapRadiusMeters: Float, onStartStop: () -> Unit, onOpenSettings: () -> Unit, canStart: Boolean, @@ -90,12 +96,12 @@ fun MainScreen( .background(MaterialTheme.colorScheme.background) .padding(horizontal = 24.dp) ) { - Row( + // Box (rather than Row + SpaceBetween) so the title is truly centered + // regardless of the gear icon's width. + Box( modifier = Modifier .fillMaxWidth() - .padding(top = 8.dp), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.SpaceBetween + .padding(top = 8.dp) ) { Text( text = "OVERWATCH", @@ -103,9 +109,13 @@ fun MainScreen( fontSize = 26.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, - letterSpacing = 4.sp + letterSpacing = 4.sp, + modifier = Modifier.align(Alignment.Center) ) - IconButton(onClick = onOpenSettings) { + IconButton( + onClick = onOpenSettings, + modifier = Modifier.align(Alignment.CenterEnd) + ) { Icon( Icons.Filled.Settings, contentDescription = "Settings", @@ -125,6 +135,7 @@ fun MainScreen( animating = running, userLocation = userLocation, mapPoints = mapPoints, + mapRadiusMeters = mapRadiusMeters, onTap = { showSheet = true } ) @@ -213,12 +224,33 @@ fun MainScreen( } } +/** Builds a small white-bordered blue dot used as the user-position Marker. */ +private fun userDotDrawable( + resources: android.content.res.Resources +): android.graphics.drawable.BitmapDrawable { + val sizePx = 36 + val bitmap = android.graphics.Bitmap.createBitmap( + sizePx, sizePx, android.graphics.Bitmap.Config.ARGB_8888 + ) + val canvas = android.graphics.Canvas(bitmap) + val outline = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { + color = 0xFFFFFFFF.toInt() + } + canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f - 2f, outline) + val core = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { + color = 0xFF2196F3.toInt() + } + canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f - 6f, core) + return android.graphics.drawable.BitmapDrawable(resources, bitmap) +} + @Composable private fun ThreatMapCircle( level: ThreatLevel, animating: Boolean, userLocation: Location?, mapPoints: List, + mapRadiusMeters: Float, onTap: () -> Unit ) { val idleColor = MaterialTheme.colorScheme.surfaceVariant @@ -278,69 +310,81 @@ private fun ThreatMapCircle( ) } } else { - // OSM map snapshot, centered on the user, with red ALPR pins. - // Non-interactive — touches are captured by the click overlay above - // so a tap opens the source-details bottom sheet (matching the old - // circle's UX). Pan/zoom controls stay off. + // OSM map snapshot, centered on the user, with red ALPR pins and + // a blue user-position dot. Non-interactive — touches are captured + // by the click overlay above, so a tap opens the source-details + // bottom sheet. Pan/zoom controls stay off. // Capture into a local non-null val so the AndroidView update // lambda doesn't run afoul of smart-cast-into-closure rules. val fix: Location = userLocation + val ctx = LocalContext.current + // Build the user-dot drawable once per Composition rather than + // every recomposition — bitmap allocation isn't free. + val userDot = remember(ctx) { userDotDrawable(ctx.resources) } AndroidView( modifier = Modifier.fillMaxSize(), - factory = { ctx -> - MapView(ctx).apply { + factory = { c -> + MapView(c).apply { setTileSource(TileSourceFactory.MAPNIK) setMultiTouchControls(false) setBuiltInZoomControls(false) isClickable = false isFocusable = false - controller.setZoom(17.0) } }, update = { map -> map.controller.setCenter(GeoPoint(fix.latitude, fix.longitude)) map.overlays.clear() + + // ALPR pins first, user dot last so the dot draws on top. for (p in mapPoints) { - val m = Marker(map).apply { - position = GeoPoint(p.lat, p.lon) - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) - title = p.operator ?: p.manufacturer ?: "ALPR" - // Disable osmdroid's per-marker info popup since - // the map isn't interactive — the bottom sheet is - // the canonical "details" surface. + map.overlays.add( + Marker(map).apply { + position = GeoPoint(p.lat, p.lon) + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + title = p.operator ?: p.manufacturer ?: "ALPR" + setInfoWindow(null) + } + ) + } + map.overlays.add( + Marker(map).apply { + position = GeoPoint(fix.latitude, fix.longitude) + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) + icon = userDot setInfoWindow(null) } - map.overlays.add(m) - } + ) + + // Fit the visible radius to the larger of the two proximity + // settings. Defer to map.post so the call lands after layout + // — zoomToBoundingBox needs measured dimensions to compute + // the right zoom level. Latitude-aware longitude scaling so + // the bbox stays roughly square in real meters at any lat. + val r = mapRadiusMeters.toDouble().coerceAtLeast(50.0) + val latDegPerMeter = 1.0 / 111_000.0 + val lonDegPerMeter = 1.0 / + (111_000.0 * cos(Math.toRadians(fix.latitude)).coerceAtLeast(0.01)) + val bbox = BoundingBox( + fix.latitude + r * latDegPerMeter, + fix.longitude + r * lonDegPerMeter, + fix.latitude - r * latDegPerMeter, + fix.longitude - r * lonDegPerMeter + ) + map.post { map.zoomToBoundingBox(bbox, false, 0) } map.invalidate() }, onRelease = { map -> map.onDetach() } ) - // Threat-tier scrim — pulses while scanning, dims tiles to keep - // the dark theme aesthetic and signals tier without text. - val scrimAlpha = (0.35f * pulse).coerceIn(0.18f, 0.5f) + // Threat-tier scrim — pulses while scanning. Heavier alpha than + // the first cut so the tier color reads at a glance over OSM + // tiles, which are themselves cream/light by default. + val scrimAlpha = (0.55f * pulse).coerceIn(0.40f, 0.65f) Box( modifier = Modifier .fillMaxSize() .background(activeColor.copy(alpha = scrimAlpha)) ) - // Tier label, top-center. Smaller than the old text so the map - // remains readable underneath. - Box( - modifier = Modifier - .fillMaxSize() - .padding(top = 14.dp), - contentAlignment = Alignment.TopCenter - ) { - Text( - text = level.name, - color = Color.White, - fontSize = 14.sp, - fontWeight = FontWeight.Black, - fontFamily = FontFamily.Monospace, - letterSpacing = 2.sp - ) - } } // Click capture sits on top so taps reach onTap regardless of which // visual layer was painted underneath. @@ -395,6 +439,14 @@ private fun SourcesPanel(events: List) { } } +/** User-facing label for a detection source. The internal enum stays MIC + * (mic-bearing devices is the technical concept) while the UI shows the + * friendlier "COMMERCIAL" — Nest/Ring/Echo are commercial smart-home gear. */ +private fun DetectionSource.displayLabel(): String = when (this) { + DetectionSource.MIC -> "COMMERCIAL" + else -> name +} + @Composable private fun SourceRow(source: DetectionSource, events: List) { val health by SourceHealth.flowFor(source).collectAsState() @@ -414,7 +466,7 @@ private fun SourceRow(source: DetectionSource, events: List) { verticalAlignment = Alignment.CenterVertically ) { Text( - text = source.name, + text = source.displayLabel(), color = MaterialTheme.colorScheme.onSurface, fontSize = 14.sp, fontWeight = FontWeight.Bold, @@ -483,23 +535,22 @@ private fun EventRow(e: DetectionEvent) { if (e.hasGeo) { IconButton( onClick = { - // resolveActivity returns null on Android 11+ without a matching - // entry even when Google Maps is installed. Skip the - // pre-check and let startActivity handle it; catch the rare - // "no app at all" case instead of silently no-op'ing. - val uri = Uri.parse("geo:${e.lat},${e.lon}?q=${e.lat},${e.lon}(${Uri.encode(e.label)})") - val intent = Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + // Force the pin to open in Google Maps rather than whichever + // app holds the user's default geo: handler — Waze, etc. can + // intercept geo: intents and we don't want that here. Falls + // back to a generic browser intent if Maps isn't installed. + val mapsUri = Uri.parse( + "https://www.google.com/maps/search/?api=1&query=${e.lat},${e.lon}" + ) + val mapsIntent = Intent(Intent.ACTION_VIEW, mapsUri) + .setPackage("com.google.android.apps.maps") + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) try { - ctx.startActivity(intent) + ctx.startActivity(mapsIntent) } catch (_: android.content.ActivityNotFoundException) { - // Fall back to a Google Maps URL — works even on devices - // without a registered geo: handler. - val webUri = Uri.parse( - "https://www.google.com/maps/search/?api=1&query=${e.lat},${e.lon}" - ) - val webIntent = Intent(Intent.ACTION_VIEW, webUri) + val fallback = Intent(Intent.ACTION_VIEW, mapsUri) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - try { ctx.startActivity(webIntent) } catch (_: android.content.ActivityNotFoundException) {} + try { ctx.startActivity(fallback) } catch (_: android.content.ActivityNotFoundException) {} } }, modifier = Modifier.size(28.dp) diff --git a/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt b/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt index 7f78265..f006c0c 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt @@ -83,7 +83,7 @@ fun SettingsScreen( SourceToggle("WIFI • WiFi BSSID + SSID", wifi) { settings.setWifiEnabled(it) } SourceToggle("DEFLOCK • ALPR map (Overpass)", deflock) { settings.setDeflockEnabled(it) } SourceToggle("CITIZEN • Real-time incident feed", citizen) { settings.setCitizenEnabled(it) } - SourceToggle("MIC • Smart speakers / cams (Echo, Ring, Nest)", mic) { settings.setMicEnabled(it) } + SourceToggle("COMMERCIAL • Nest, Ring, Echo devices", mic) { settings.setMicEnabled(it) } Spacer(Modifier.height(8.dp)) if (isRunning) { Button(