v0.2.0 — live map circle + MIC detection (Echo/Ring/Nest/hidden cams)

- 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.
This commit is contained in:
2026-05-07 22:45:33 -04:00
parent e277c48e89
commit 245055d9d2
15 changed files with 612 additions and 103 deletions
+3 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "org.soulstone.overwatch" applicationId = "org.soulstone.overwatch"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 8 versionCode = 9
versionName = "0.1.7" versionName = "0.2.0"
} }
buildTypes { buildTypes {
@@ -61,6 +61,7 @@ dependencies {
implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.play.services.location) implementation(libs.play.services.location)
implementation(libs.osmdroid.android)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
} }
@@ -19,6 +19,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import org.osmdroid.config.Configuration
import org.soulstone.overwatch.data.settings.Settings import org.soulstone.overwatch.data.settings.Settings
import org.soulstone.overwatch.service.DetectionService import org.soulstone.overwatch.service.DetectionService
import org.soulstone.overwatch.ui.MainScreen import org.soulstone.overwatch.ui.MainScreen
@@ -66,6 +67,14 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// osmdroid requires a User-Agent and a writable cache before any
// MapView is constructed, otherwise OSM may rate-limit/IP-ban us.
// Set it here once per process — Configuration is a singleton.
Configuration.getInstance().apply {
userAgentValue = packageName
osmdroidBasePath = cacheDir
osmdroidTileCache = java.io.File(cacheDir, "osmdroid-tiles").apply { mkdirs() }
}
permissionsGranted.value = checkAllPermissions() permissionsGranted.value = checkAllPermissions()
permanentlyDenied.value = false // reset on activity create permanentlyDenied.value = false // reset on activity create
val settings = Settings.get(this) val settings = Settings.get(this)
@@ -81,6 +90,8 @@ class MainActivity : ComponentActivity() {
val events by DetectionService.store.events.collectAsState() val events by DetectionService.store.events.collectAsState()
val threat by DetectionService.store.threatLevel.collectAsState() val threat by DetectionService.store.threatLevel.collectAsState()
val maxScore by DetectionService.store.maxScore.collectAsState() val maxScore by DetectionService.store.maxScore.collectAsState()
val mapPoints by DetectionService.mapPoints.collectAsState()
val userLocation by DetectionService.location.collectAsState()
val granted by permissionsGranted val granted by permissionsGranted
val denied by permanentlyDenied val denied by permanentlyDenied
@@ -95,6 +106,8 @@ class MainActivity : ComponentActivity() {
threat = threat, threat = threat,
score = maxScore, score = maxScore,
events = events, events = events,
mapPoints = mapPoints,
userLocation = userLocation,
canStart = true, canStart = true,
permissionMessage = message, permissionMessage = message,
showOpenAppSettings = denied && !granted, showOpenAppSettings = denied && !granted,
@@ -32,6 +32,9 @@ class Settings private constructor(private val prefs: SharedPreferences) {
private val _citizenEnabled = MutableStateFlow(prefs.getBoolean(KEY_CITIZEN, true)) private val _citizenEnabled = MutableStateFlow(prefs.getBoolean(KEY_CITIZEN, true))
val citizenEnabled: StateFlow<Boolean> = _citizenEnabled.asStateFlow() val citizenEnabled: StateFlow<Boolean> = _citizenEnabled.asStateFlow()
private val _micEnabled = MutableStateFlow(prefs.getBoolean(KEY_MIC, true))
val micEnabled: StateFlow<Boolean> = _micEnabled.asStateFlow()
private val _deflockProximityM = MutableStateFlow( private val _deflockProximityM = MutableStateFlow(
prefs.getInt(KEY_DEFLOCK_PROX, DEFAULT_DEFLOCK_PROX) prefs.getInt(KEY_DEFLOCK_PROX, DEFAULT_DEFLOCK_PROX)
) )
@@ -54,6 +57,7 @@ class Settings private constructor(private val prefs: SharedPreferences) {
fun setWifiEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_WIFI, v) }; _wifiEnabled.value = v } fun setWifiEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_WIFI, v) }; _wifiEnabled.value = v }
fun setDeflockEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_DEFLOCK, v) }; _deflockEnabled.value = v } fun setDeflockEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_DEFLOCK, v) }; _deflockEnabled.value = v }
fun setCitizenEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_CITIZEN, v) }; _citizenEnabled.value = v } fun setCitizenEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_CITIZEN, v) }; _citizenEnabled.value = v }
fun setMicEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_MIC, v) }; _micEnabled.value = v }
fun setDeflockProximityM(v: Int) { fun setDeflockProximityM(v: Int) {
val clamped = v.coerceIn(50, 1600) val clamped = v.coerceIn(50, 1600)
@@ -83,6 +87,7 @@ class Settings private constructor(private val prefs: SharedPreferences) {
private const val KEY_WIFI = "src_wifi" private const val KEY_WIFI = "src_wifi"
private const val KEY_DEFLOCK = "src_deflock" private const val KEY_DEFLOCK = "src_deflock"
private const val KEY_CITIZEN = "src_citizen" private const val KEY_CITIZEN = "src_citizen"
private const val KEY_MIC = "src_mic"
private const val KEY_DEFLOCK_PROX = "deflock_proximity_m" private const val KEY_DEFLOCK_PROX = "deflock_proximity_m"
private const val KEY_CITIZEN_PROX = "citizen_proximity_m" private const val KEY_CITIZEN_PROX = "citizen_proximity_m"
private const val KEY_THEME = "theme_mode" private const val KEY_THEME = "theme_mode"
@@ -0,0 +1,153 @@
package org.soulstone.overwatch.data.targets
import java.util.UUID
/**
* Curated targets for "device with a microphone in your space" detection.
*
* Scope is intentionally narrow — only well-known smart-home OEMs whose devices
* stay in fixed locations and continuously listen. Apple manufacturer id 0x004C
* is deliberately excluded because every iPhone, AirPod, and Apple Watch
* advertises it; a coffee shop full of phones must not light up the alarm.
*
* Detection vectors collected from public OUI registries (Wireshark/IEEE)
* and device-setup advertisement docs.
*/
object MicTargets {
enum class Family { ECHO, RING, GOOGLE, HIDDEN_CAM }
/** Bluetooth SIG company identifiers for "voice/smart-home" device families. */
private val MFG_GOOGLE = 0x00E0
private val MFG_AMAZON = 0x0171
/** Yingxin / cheap-spy-cam mfg id seen in field reports. */
private val MFG_YINGXIN = 0x05A7
/** Echo/Alexa Voice Service GATT (FE03 — assigned to Amazon Lab126). */
private val UUID_AVS = UUID.fromString("0000fe03-0000-1000-8000-00805f9b34fb")
/** Lab126 (Amazon — Echo, Ring, Fire TV) WiFi/BLE OUIs. */
private val OUIS_AMAZON: Set<String> = setOf(
"0c:47:c9", "38:f7:3d", "44:65:0d", "50:dc:e7", "78:e1:03",
"a8:51:5b", "b0:09:da", "f0:27:2d", "f0:81:73", "f0:d2:f1",
"fc:65:de", "fc:a1:83", "ac:63:be", "00:bb:3a"
)
/** Google (Nest, Home, Chromecast) WiFi/BLE OUIs. */
private val OUIS_GOOGLE: Set<String> = setOf(
"f8:8f:ca", "f4:f5:e8", "94:eb:cd", "64:16:66", "fc:9f:e9",
"1c:f2:9a", "08:9e:08", "20:df:b9", "30:fd:38", "48:d6:d5",
"54:60:09", "6c:ad:f8", "70:3a:cb", "94:c9:60", "f4:f1:9e"
)
/** Generic Chinese hidden-cam / smart-mic vendor OUIs (high-noise; opt-in). */
private val OUIS_HIDDEN_CAM: Set<String> = setOf(
"fc:b4:67", // Yingxin / SmartLife mini cams
"00:e0:4c", // Realtek (used in many cheap cams)
"dc:4f:22", // Tuya-affiliated module vendors
"a4:c1:38", // Telink (often inside cheap BLE mics)
"8c:ce:4e" // Shenzhen iComm — frequent in spy-cam BOMs
)
private val ALL_OUIS: Set<String> = OUIS_AMAZON + OUIS_GOOGLE + OUIS_HIDDEN_CAM
/** Case-sensitive substrings — distinct enough to avoid false positives. */
private val BLE_NAME_HINTS: List<Pair<String, Family>> = listOf(
"Echo" to Family.ECHO,
"echo-" to Family.ECHO,
"FireTV" to Family.ECHO,
"Amazon" to Family.ECHO,
"Ring-" to Family.RING,
"Ring " to Family.RING,
"Doorbell" to Family.RING,
"Nest" to Family.GOOGLE,
"GoogleHome" to Family.GOOGLE,
"Chromecast" to Family.GOOGLE,
"Google-Home" to Family.GOOGLE
)
private val SSID_HINTS: List<Pair<String, Family>> = listOf(
"Amazon-" to Family.ECHO,
"Echo-" to Family.ECHO,
"Ring-" to Family.RING,
"Ring_" to Family.RING,
"Nest_" to Family.GOOGLE,
"GoogleHome" to Family.GOOGLE,
"Chromecast" to Family.GOOGLE
)
data class Match(val family: Family, val reason: String)
fun matchOui(mac: String?): Family? {
if (mac.isNullOrBlank() || mac.length < 8) return null
val prefix = mac.lowercase().substring(0, 8)
return when (prefix) {
in OUIS_AMAZON -> Family.ECHO // Amazon OUIs cover both Echo and Ring
in OUIS_GOOGLE -> Family.GOOGLE
in OUIS_HIDDEN_CAM -> Family.HIDDEN_CAM
else -> null
}
}
fun isMicOui(mac: String?): Boolean = matchOui(mac) != null
fun matchBleName(name: String?): Match? {
if (name.isNullOrBlank()) return null
for ((needle, family) in BLE_NAME_HINTS) {
if (name.contains(needle, ignoreCase = false)) {
return Match(family, "name:$needle")
}
}
return null
}
fun matchSsid(ssid: String?): Match? {
if (ssid.isNullOrBlank()) return null
for ((needle, family) in SSID_HINTS) {
if (ssid.contains(needle, ignoreCase = true)) {
return Match(family, "ssid:$needle")
}
}
return null
}
fun matchManufacturer(companyId: Int?): Family? = when (companyId) {
MFG_AMAZON -> Family.ECHO
MFG_GOOGLE -> Family.GOOGLE
MFG_YINGXIN -> Family.HIDDEN_CAM
else -> null
}
fun matchAvsService(advertisedUuids: List<UUID>?): Boolean {
if (advertisedUuids.isNullOrEmpty()) return false
return advertisedUuids.contains(UUID_AVS)
}
/** Cheap pre-filter for the BLE scanner — true if any mic signal could match. */
fun couldBeMicBle(
mac: String?,
name: String?,
advertisedUuids: List<UUID>?,
companyId: Int?
): Boolean {
if (isMicOui(mac)) return true
if (matchBleName(name) != null) return true
if (matchManufacturer(companyId) != null) return true
if (matchAvsService(advertisedUuids)) return true
return false
}
/** Cheap pre-filter for the WiFi scanner. */
fun couldBeMicWifi(bssid: String?, ssid: String?): Boolean {
if (isMicOui(bssid)) return true
if (matchSsid(ssid) != null) return true
return false
}
fun familyLabel(f: Family): String = when (f) {
Family.ECHO -> "Amazon Echo / Ring"
Family.RING -> "Ring"
Family.GOOGLE -> "Google Nest / Home"
Family.HIDDEN_CAM -> "Possible hidden mic / cam"
}
}
@@ -38,6 +38,19 @@ object ConfidenceEngine {
const val B_STRONG_RSSI = 10 // > -50 dBm const val B_STRONG_RSSI = 10 // > -50 dBm
const val B_STATIONARY = 15 // RSSI rise-peak-fall const val B_STATIONARY = 15 // RSSI rise-peak-fall
// MIC channel — smart-home/voice-assistant detection. Capped so a Ring or
// Echo cluster can't push the global tier above ORANGE; RED stays reserved
// for ALPR/Axon-grade evidence.
const val MIC_SCORE_CAP = 84
const val W_MIC_OUI = 30
const val W_MIC_NAME = 45
const val W_MIC_MFG = 30
const val W_MIC_AVS_UUID = 50
const val W_MIC_SSID = 45
const val B_MIC_MULTI = 10
const val B_MIC_STATIONARY = 8
const val B_MIC_STRONG_RSSI = 5
/** What we observed about one BLE device on a single scan callback. */ /** What we observed about one BLE device on a single scan callback. */
data class BleObservation( data class BleObservation(
val mac: String, val mac: String,
@@ -202,6 +215,107 @@ object ConfidenceEngine {
return Scored(score, rangeTag, label, isAxon = false) return Scored(score, rangeTag, label, isAxon = false)
} }
/** A BLE mic-bearing-device observation, score-capped at ORANGE. */
data class MicBleObservation(
val mac: String,
val rssi: Int,
val deviceName: String?,
val advertisedUuids: List<java.util.UUID>?,
val manufacturerCompanyId: Int?,
val isStationary: Boolean
)
/** A WiFi mic-bearing-device observation, score-capped at ORANGE. */
data class MicWifiObservation(
val bssid: String,
val ssid: String?,
val rssi: Int,
val isStationary: Boolean
)
fun scoreMicBle(obs: MicBleObservation): Scored {
var score = 0
var methodCount = 0
val methods = StringBuilder()
val ouiFamily = org.soulstone.overwatch.data.targets.MicTargets.matchOui(obs.mac)
if (ouiFamily != null) {
score += W_MIC_OUI
methods.append("mic_oui ")
methodCount++
}
val nameMatch = org.soulstone.overwatch.data.targets.MicTargets.matchBleName(obs.deviceName)
if (nameMatch != null) {
score += W_MIC_NAME
methods.append("mic_name ")
methodCount++
}
val mfgFamily = org.soulstone.overwatch.data.targets.MicTargets.matchManufacturer(obs.manufacturerCompanyId)
if (mfgFamily != null) {
score += W_MIC_MFG
methods.append("mic_mfg ")
methodCount++
}
if (org.soulstone.overwatch.data.targets.MicTargets.matchAvsService(obs.advertisedUuids)) {
score += W_MIC_AVS_UUID
methods.append("mic_avs ")
methodCount++
}
if (methodCount >= 2) {
score += B_MIC_MULTI
methods.append("multi ")
}
if (obs.rssi > -50) {
score += B_MIC_STRONG_RSSI
methods.append("strong_rssi ")
}
if (obs.isStationary) {
score += B_MIC_STATIONARY
methods.append("stationary ")
}
score = score.coerceAtMost(MIC_SCORE_CAP)
val family = nameMatch?.family ?: ouiFamily ?: mfgFamily
?: org.soulstone.overwatch.data.targets.MicTargets.Family.HIDDEN_CAM
val familyLabel = org.soulstone.overwatch.data.targets.MicTargets.familyLabel(family)
val nameSuffix = if (!obs.deviceName.isNullOrBlank()) "${obs.deviceName}" else ""
return Scored(score, methods.toString().trim(), "$familyLabel$nameSuffix (${obs.mac})", isAxon = false)
}
fun scoreMicWifi(obs: MicWifiObservation): Scored {
var score = 0
var methodCount = 0
val methods = StringBuilder()
val ouiFamily = org.soulstone.overwatch.data.targets.MicTargets.matchOui(obs.bssid)
if (ouiFamily != null) {
score += W_MIC_OUI
methods.append("mic_oui ")
methodCount++
}
val ssidMatch = org.soulstone.overwatch.data.targets.MicTargets.matchSsid(obs.ssid)
if (ssidMatch != null) {
score += W_MIC_SSID
methods.append("mic_ssid ")
methodCount++
}
if (methodCount >= 2) {
score += B_MIC_MULTI
methods.append("multi ")
}
if (obs.rssi > -50) {
score += B_MIC_STRONG_RSSI
methods.append("strong_rssi ")
}
if (obs.isStationary) {
score += B_MIC_STATIONARY
methods.append("stationary ")
}
score = score.coerceAtMost(MIC_SCORE_CAP)
val family = ssidMatch?.family ?: ouiFamily
?: org.soulstone.overwatch.data.targets.MicTargets.Family.HIDDEN_CAM
val familyLabel = org.soulstone.overwatch.data.targets.MicTargets.familyLabel(family)
val ssidSuffix = if (!obs.ssid.isNullOrBlank()) "${obs.ssid}" else ""
return Scored(score, methods.toString().trim(), "$familyLabel$ssidSuffix (${obs.bssid})", isAxon = false)
}
fun scoreWifi(obs: WifiObservation): Scored { fun scoreWifi(obs: WifiObservation): Scored {
var score = 0 var score = 0
val methods = StringBuilder() val methods = StringBuilder()
@@ -27,17 +27,20 @@ object SourceHealth {
private val _wifi = MutableStateFlow(Health()) private val _wifi = MutableStateFlow(Health())
private val _deflock = MutableStateFlow(Health()) private val _deflock = MutableStateFlow(Health())
private val _citizen = MutableStateFlow(Health()) private val _citizen = MutableStateFlow(Health())
private val _mic = MutableStateFlow(Health())
val ble: StateFlow<Health> = _ble.asStateFlow() val ble: StateFlow<Health> = _ble.asStateFlow()
val wifi: StateFlow<Health> = _wifi.asStateFlow() val wifi: StateFlow<Health> = _wifi.asStateFlow()
val deflock: StateFlow<Health> = _deflock.asStateFlow() val deflock: StateFlow<Health> = _deflock.asStateFlow()
val citizen: StateFlow<Health> = _citizen.asStateFlow() val citizen: StateFlow<Health> = _citizen.asStateFlow()
val mic: StateFlow<Health> = _mic.asStateFlow()
fun flowFor(source: DetectionSource): StateFlow<Health> = when (source) { fun flowFor(source: DetectionSource): StateFlow<Health> = when (source) {
DetectionSource.BLE -> ble DetectionSource.BLE -> ble
DetectionSource.WIFI -> wifi DetectionSource.WIFI -> wifi
DetectionSource.DEFLOCK -> deflock DetectionSource.DEFLOCK -> deflock
DetectionSource.CITIZEN -> citizen DetectionSource.CITIZEN -> citizen
DetectionSource.MIC -> mic
} }
fun record(source: DetectionSource, ok: Boolean, message: String? = null) { fun record(source: DetectionSource, ok: Boolean, message: String? = null) {
@@ -46,6 +49,7 @@ object SourceHealth {
DetectionSource.WIFI -> _wifi DetectionSource.WIFI -> _wifi
DetectionSource.DEFLOCK -> _deflock DetectionSource.DEFLOCK -> _deflock
DetectionSource.CITIZEN -> _citizen DetectionSource.CITIZEN -> _citizen
DetectionSource.MIC -> _mic
} }
target.value = Health( target.value = Health(
status = if (ok) Status.OK else Status.FAILED, status = if (ok) Status.OK else Status.FAILED,
@@ -59,5 +63,6 @@ object SourceHealth {
_wifi.value = Health() _wifi.value = Health()
_deflock.value = Health() _deflock.value = Health()
_citizen.value = Health() _citizen.value = Health()
_mic.value = Health()
} }
} }
@@ -21,4 +21,4 @@ enum class ThreatLevel(val minScore: Int) {
} }
/** Logical signal channel — used in the drill-down UI. */ /** Logical signal channel — used in the drill-down UI. */
enum class DetectionSource { BLE, WIFI, DEFLOCK, CITIZEN } enum class DetectionSource { BLE, WIFI, DEFLOCK, CITIZEN, MIC }
@@ -14,6 +14,7 @@ import android.os.Build
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import org.soulstone.overwatch.data.targets.BleOuis import org.soulstone.overwatch.data.targets.BleOuis
import org.soulstone.overwatch.data.targets.MicTargets
import org.soulstone.overwatch.data.targets.Patterns import org.soulstone.overwatch.data.targets.Patterns
import org.soulstone.overwatch.data.targets.RavenUuids import org.soulstone.overwatch.data.targets.RavenUuids
import org.soulstone.overwatch.fusion.ConfidenceEngine import org.soulstone.overwatch.fusion.ConfidenceEngine
@@ -38,7 +39,9 @@ import org.soulstone.overwatch.fusion.SourceHealth
class BleScanner( class BleScanner(
private val context: Context, private val context: Context,
private val store: DetectionStore, private val store: DetectionStore,
private val rssi: RssiTracker = RssiTracker() private val rssi: RssiTracker = RssiTracker(),
/** When true, also evaluate each scan against MicTargets and submit MIC events. */
private val micEnabled: () -> Boolean = { false }
) { ) {
companion object { companion object {
@@ -170,14 +173,19 @@ class BleScanner(
} }
// Cheap pre-filter — drop devices that have zero target signals. // Cheap pre-filter — drop devices that have zero target signals.
val candidate = BleOuis.matches(mac) || val isSurveillance = BleOuis.matches(mac) ||
Patterns.bleNameMatch(name) || Patterns.bleNameMatch(name) ||
Patterns.isPenguinNumeric(name) || Patterns.isPenguinNumeric(name) ||
RavenUuids.countMatches(advertisedUuids) > 0 || RavenUuids.countMatches(advertisedUuids) > 0 ||
companyId == org.soulstone.overwatch.data.targets.Manufacturers.XUNTONG_COMPANY_ID companyId == org.soulstone.overwatch.data.targets.Manufacturers.XUNTONG_COMPANY_ID
if (!candidate) return val isMic = micEnabled() &&
MicTargets.couldBeMicBle(mac, name, advertisedUuids, companyId)
if (!isSurveillance && !isMic) return
rssi.update(mac, result.rssi) rssi.update(mac, result.rssi)
val stationary = rssi.isStationary(mac)
if (isSurveillance) {
val obs = ConfidenceEngine.BleObservation( val obs = ConfidenceEngine.BleObservation(
mac = mac, mac = mac,
rssi = result.rssi, rssi = result.rssi,
@@ -185,11 +193,10 @@ class BleScanner(
advertisedUuids = advertisedUuids, advertisedUuids = advertisedUuids,
manufacturerCompanyId = companyId, manufacturerCompanyId = companyId,
manufacturerPayload = payload, manufacturerPayload = payload,
isStationary = rssi.isStationary(mac) isStationary = stationary
) )
val scored = ConfidenceEngine.scoreBle(obs) val scored = ConfidenceEngine.scoreBle(obs)
if (scored.score < ALARM_THRESHOLD) return if (scored.score >= ALARM_THRESHOLD) {
store.submit( store.submit(
DetectionEvent( DetectionEvent(
source = DetectionSource.BLE, source = DetectionSource.BLE,
@@ -201,4 +208,31 @@ class BleScanner(
) )
) )
} }
}
if (isMic) {
val obs = ConfidenceEngine.MicBleObservation(
mac = mac,
rssi = result.rssi,
deviceName = name,
advertisedUuids = advertisedUuids,
manufacturerCompanyId = companyId,
isStationary = stationary
)
val scored = ConfidenceEngine.scoreMicBle(obs)
if (scored.score >= ALARM_THRESHOLD) {
store.submit(
DetectionEvent(
source = DetectionSource.MIC,
// Disambiguate from any BLE event on the same MAC so the
// store's (source, key) dedup doesn't collide.
key = "mic:$mac",
label = scored.label,
score = scored.score,
matchedMethods = scored.methods,
rssi = result.rssi
)
)
}
}
}
} }
@@ -4,6 +4,9 @@ import android.location.Location
import android.util.Log import android.util.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.soulstone.overwatch.data.location.LocationProvider import org.soulstone.overwatch.data.location.LocationProvider
@@ -41,7 +44,10 @@ class DeflockScanner(
private var lastFetchLon: Double? = null private var lastFetchLon: Double? = null
private var lastAttemptMs: Long = 0L private var lastAttemptMs: Long = 0L
private var lastAttemptOk: Boolean = false private var lastAttemptOk: Boolean = false
private var cachedPoints: List<DeflockClient.AlprPoint> = emptyList() private val _cachedPoints = MutableStateFlow<List<DeflockClient.AlprPoint>>(emptyList())
/** All ALPR points in the current cell — exposed so the UI map can render them.
* Distinct from the proximity-filtered DetectionEvents on [DetectionStore]. */
val cachedPoints: StateFlow<List<DeflockClient.AlprPoint>> = _cachedPoints.asStateFlow()
fun start(scope: CoroutineScope): Boolean { fun start(scope: CoroutineScope): Boolean {
if (job != null) return true if (job != null) return true
@@ -61,7 +67,7 @@ class DeflockScanner(
lastFetchLon = null lastFetchLon = null
lastAttemptMs = 0L lastAttemptMs = 0L
lastAttemptOk = false lastAttemptOk = false
cachedPoints = emptyList() _cachedPoints.value = emptyList()
Log.i(TAG, "DeflockScanner stopped") Log.i(TAG, "DeflockScanner stopped")
} }
@@ -74,12 +80,12 @@ class DeflockScanner(
lastAttemptMs = System.currentTimeMillis() lastAttemptMs = System.currentTimeMillis()
when (val result = client.fetchAround(fix.latitude, fix.longitude)) { when (val result = client.fetchAround(fix.latitude, fix.longitude)) {
is DeflockClient.FetchResult.Success -> { is DeflockClient.FetchResult.Success -> {
cachedPoints = result.points _cachedPoints.value = result.points
lastAttemptOk = true lastAttemptOk = true
SourceHealth.record(DetectionSource.DEFLOCK, ok = true) SourceHealth.record(DetectionSource.DEFLOCK, ok = true)
Log.i( Log.i(
TAG, TAG,
"Loaded ${cachedPoints.size} ALPRs around " + "Loaded ${result.points.size} ALPRs around " +
"(${fix.latitude}, ${fix.longitude})" "(${fix.latitude}, ${fix.longitude})"
) )
} }
@@ -95,11 +101,12 @@ class DeflockScanner(
} }
} }
} }
if (cachedPoints.isEmpty()) return val points = _cachedPoints.value
if (points.isEmpty()) return
val limit = proximityMeters() val limit = proximityMeters()
val out = FloatArray(1) val out = FloatArray(1)
for (p in cachedPoints) { for (p in points) {
Location.distanceBetween(fix.latitude, fix.longitude, p.lat, p.lon, out) Location.distanceBetween(fix.latitude, fix.longitude, p.lat, p.lon, out)
val dist = out[0] val dist = out[0]
if (dist > limit) continue if (dist > limit) continue
@@ -17,6 +17,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.soulstone.overwatch.data.targets.MicTargets
import org.soulstone.overwatch.data.targets.Patterns import org.soulstone.overwatch.data.targets.Patterns
import org.soulstone.overwatch.data.targets.WifiOuis import org.soulstone.overwatch.data.targets.WifiOuis
import org.soulstone.overwatch.fusion.ConfidenceEngine import org.soulstone.overwatch.fusion.ConfidenceEngine
@@ -40,7 +41,9 @@ import org.soulstone.overwatch.fusion.SourceHealth
class WifiScanner( class WifiScanner(
private val context: Context, private val context: Context,
private val store: DetectionStore, private val store: DetectionStore,
private val rssi: RssiTracker = RssiTracker() private val rssi: RssiTracker = RssiTracker(),
/** When true, also evaluate each scan against MicTargets and submit MIC events. */
private val micEnabled: () -> Boolean = { false }
) { ) {
companion object { companion object {
@@ -167,21 +170,21 @@ class WifiScanner(
val bssid = r.BSSID ?: continue val bssid = r.BSSID ?: continue
val ssid = readSsid(r) val ssid = readSsid(r)
val candidate = WifiOuis.matches(bssid) || val isSurveillance = WifiOuis.matches(bssid) ||
Patterns.ssidGenericMatch(ssid) || Patterns.ssidGenericMatch(ssid) ||
Patterns.ssidFlockFormat(ssid) Patterns.ssidFlockFormat(ssid)
if (!candidate) continue val isMic = micEnabled() && MicTargets.couldBeMicWifi(bssid, ssid)
if (!isSurveillance && !isMic) continue
rssi.update(bssid, r.level) rssi.update(bssid, r.level)
val stationary = rssi.isStationary(bssid)
if (isSurveillance) {
val obs = ConfidenceEngine.WifiObservation( val obs = ConfidenceEngine.WifiObservation(
bssid = bssid, bssid = bssid, ssid = ssid, rssi = r.level, isStationary = stationary
ssid = ssid,
rssi = r.level,
isStationary = rssi.isStationary(bssid)
) )
val scored = ConfidenceEngine.scoreWifi(obs) val scored = ConfidenceEngine.scoreWifi(obs)
if (scored.score < ALARM_THRESHOLD) continue if (scored.score >= ALARM_THRESHOLD) {
store.submit( store.submit(
DetectionEvent( DetectionEvent(
source = DetectionSource.WIFI, source = DetectionSource.WIFI,
@@ -194,6 +197,26 @@ class WifiScanner(
) )
} }
} }
if (isMic) {
val obs = ConfidenceEngine.MicWifiObservation(
bssid = bssid, ssid = ssid, rssi = r.level, isStationary = stationary
)
val scored = ConfidenceEngine.scoreMicWifi(obs)
if (scored.score >= ALARM_THRESHOLD) {
store.submit(
DetectionEvent(
source = DetectionSource.MIC,
key = "mic:$bssid",
label = scored.label,
score = scored.score,
matchedMethods = scored.methods,
rssi = r.level
)
)
}
}
}
}
private fun readSsid(r: ScanResult): String? { private fun readSsid(r: ScanResult): String? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -7,6 +7,7 @@ import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.location.Location
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.VibrationEffect import android.os.VibrationEffect
@@ -28,6 +29,7 @@ import org.soulstone.overwatch.R
import org.soulstone.overwatch.data.location.LocationProvider import org.soulstone.overwatch.data.location.LocationProvider
import org.soulstone.overwatch.data.settings.Settings import org.soulstone.overwatch.data.settings.Settings
import org.soulstone.overwatch.fusion.DetectionEvent import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.DetectionStore import org.soulstone.overwatch.fusion.DetectionStore
import org.soulstone.overwatch.fusion.SourceHealth import org.soulstone.overwatch.fusion.SourceHealth
import org.soulstone.overwatch.fusion.ThreatLevel import org.soulstone.overwatch.fusion.ThreatLevel
@@ -35,6 +37,7 @@ import org.soulstone.overwatch.scan.BleScanner
import org.soulstone.overwatch.scan.CitizenScanner import org.soulstone.overwatch.scan.CitizenScanner
import org.soulstone.overwatch.scan.DeflockClient import org.soulstone.overwatch.scan.DeflockClient
import org.soulstone.overwatch.scan.DeflockScanner import org.soulstone.overwatch.scan.DeflockScanner
import org.soulstone.overwatch.scan.DeflockClient.AlprPoint
import org.soulstone.overwatch.scan.WifiScanner import org.soulstone.overwatch.scan.WifiScanner
/** /**
@@ -67,6 +70,15 @@ class DetectionService : LifecycleService() {
private val _running = MutableStateFlow(false) private val _running = MutableStateFlow(false)
val running: StateFlow<Boolean> = _running.asStateFlow() val running: StateFlow<Boolean> = _running.asStateFlow()
/** Latest ALPR cell cache — UI map renders these as pins. Mirrored from
* the active DeflockScanner while the service is running; cleared on stop. */
private val _mapPoints = MutableStateFlow<List<AlprPoint>>(emptyList())
val mapPoints: StateFlow<List<AlprPoint>> = _mapPoints.asStateFlow()
/** Latest fused location fix — UI map centers on this. */
private val _location = MutableStateFlow<Location?>(null)
val location: StateFlow<Location?> = _location.asStateFlow()
fun start(context: Context) { fun start(context: Context) {
val intent = Intent(context, DetectionService::class.java).apply { val intent = Intent(context, DetectionService::class.java).apply {
action = ACTION_START action = ACTION_START
@@ -94,6 +106,8 @@ class DetectionService : LifecycleService() {
private lateinit var citizenScanner: CitizenScanner private lateinit var citizenScanner: CitizenScanner
private var pruneJob: Job? = null private var pruneJob: Job? = null
private var observerJob: Job? = null private var observerJob: Job? = null
private var mapPointsJob: Job? = null
private var locationJob: Job? = null
private var bleStarted = false private var bleStarted = false
private var wifiStarted = false private var wifiStarted = false
private var deflockStarted = false private var deflockStarted = false
@@ -104,8 +118,8 @@ class DetectionService : LifecycleService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
settings = Settings.get(this) settings = Settings.get(this)
bleScanner = BleScanner(this, store) bleScanner = BleScanner(this, store, micEnabled = { settings.micEnabled.value })
wifiScanner = WifiScanner(this, store) wifiScanner = WifiScanner(this, store, micEnabled = { settings.micEnabled.value })
locationProvider = LocationProvider(this) locationProvider = LocationProvider(this)
deflockScanner = DeflockScanner( deflockScanner = DeflockScanner(
store, locationProvider, DeflockClient(this), store, locationProvider, DeflockClient(this),
@@ -169,6 +183,20 @@ class DetectionService : LifecycleService() {
return return
} }
// MIC piggybacks on the BLE/WiFi scanners. Surface its health so the
// user sees an explicit status row rather than a silent UNKNOWN.
if (settings.micEnabled.value) {
if (bleStarted || wifiStarted) {
SourceHealth.record(DetectionSource.MIC, ok = true)
} else {
SourceHealth.record(
DetectionSource.MIC,
ok = false,
message = "Needs BLE or WiFi scanner enabled"
)
}
}
_running.value = true _running.value = true
pruneJob?.cancel() pruneJob?.cancel()
pruneJob = lifecycleScope.launch { pruneJob = lifecycleScope.launch {
@@ -187,6 +215,20 @@ class DetectionService : LifecycleService() {
onTierChanged(tier, top) onTierChanged(tier, top)
} }
} }
// Mirror scanner state to the companion StateFlows the UI observes.
// These exist so the map widget doesn't need a direct handle on the
// scanner instances (which are private to this service).
mapPointsJob?.cancel()
if (deflockStarted) {
mapPointsJob = lifecycleScope.launch {
deflockScanner.cachedPoints.collect { _mapPoints.value = it }
}
}
locationJob?.cancel()
locationJob = lifecycleScope.launch {
locationProvider.location.collect { _location.value = it }
}
} }
private fun endScanning() { private fun endScanning() {
@@ -203,6 +245,10 @@ class DetectionService : LifecycleService() {
SourceHealth.reset() SourceHealth.reset()
pruneJob?.cancel(); pruneJob = null pruneJob?.cancel(); pruneJob = null
observerJob?.cancel(); observerJob = null observerJob?.cancel(); observerJob = null
mapPointsJob?.cancel(); mapPointsJob = null
locationJob?.cancel(); locationJob = null
_mapPoints.value = emptyList()
_location.value = null
lastNotifiedTier = ThreatLevel.GREEN lastNotifiedTier = ThreatLevel.GREEN
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
@@ -1,5 +1,8 @@
package org.soulstone.overwatch.ui package org.soulstone.overwatch.ui
import android.content.Intent
import android.location.Location
import android.net.Uri
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
@@ -19,8 +22,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import android.content.Intent
import android.net.Uri
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Place import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
@@ -51,11 +52,17 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
import org.soulstone.overwatch.fusion.DetectionEvent import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.SourceHealth import org.soulstone.overwatch.fusion.SourceHealth
import org.soulstone.overwatch.fusion.ThreatLevel import org.soulstone.overwatch.fusion.ThreatLevel
import org.soulstone.overwatch.scan.DeflockClient
import org.soulstone.overwatch.ui.theme.ThreatColors import org.soulstone.overwatch.ui.theme.ThreatColors
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -65,6 +72,8 @@ fun MainScreen(
threat: ThreatLevel, threat: ThreatLevel,
score: Int, score: Int,
events: List<DetectionEvent>, events: List<DetectionEvent>,
mapPoints: List<DeflockClient.AlprPoint>,
userLocation: Location?,
onStartStop: () -> Unit, onStartStop: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
canStart: Boolean, canStart: Boolean,
@@ -88,22 +97,14 @@ fun MainScreen(
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Column {
Text( Text(
text = "[DЯΣΛMMΛKΣЯ]", text = "OVERWATCH",
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
fontSize = 22.sp, fontSize = 26.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace fontFamily = FontFamily.Monospace,
letterSpacing = 4.sp
) )
Text(
text = " . //0VΣЯW4TCH",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily.Monospace
)
}
IconButton(onClick = onOpenSettings) { IconButton(onClick = onOpenSettings) {
Icon( Icon(
Icons.Filled.Settings, Icons.Filled.Settings,
@@ -119,7 +120,13 @@ fun MainScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
ThreatCircle(level = threat, animating = running, onTap = { showSheet = true }) ThreatMapCircle(
level = threat,
animating = running,
userLocation = userLocation,
mapPoints = mapPoints,
onTap = { showSheet = true }
)
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Text( Text(
@@ -207,10 +214,13 @@ fun MainScreen(
} }
@Composable @Composable
private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Unit) { private fun ThreatMapCircle(
// When the scanner isn't running, deliberately use a muted color and IDLE level: ThreatLevel,
// text so the user can tell at a glance whether they're scanning. Without animating: Boolean,
// this, idle and "scanning, all clear" both render as solid green. userLocation: Location?,
mapPoints: List<DeflockClient.AlprPoint>,
onTap: () -> Unit
) {
val idleColor = MaterialTheme.colorScheme.surfaceVariant val idleColor = MaterialTheme.colorScheme.surfaceVariant
val activeColor = when (level) { val activeColor = when (level) {
ThreatLevel.GREEN -> ThreatColors.Green ThreatLevel.GREEN -> ThreatColors.Green
@@ -218,13 +228,10 @@ private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Un
ThreatLevel.ORANGE -> ThreatColors.Orange ThreatLevel.ORANGE -> ThreatColors.Orange
ThreatLevel.RED -> ThreatColors.Red ThreatLevel.RED -> ThreatColors.Red
} }
val color = if (animating) activeColor else idleColor
val labelText = if (animating) level.name else "IDLE"
val labelColor = if (animating) Color.White else MaterialTheme.colorScheme.onSurfaceVariant
val transition = rememberInfiniteTransition(label = "pulse") val transition = rememberInfiniteTransition(label = "pulse")
val pulse by transition.animateFloat( val pulse by transition.animateFloat(
initialValue = if (animating) 0.6f else 1.0f, initialValue = if (animating) 0.5f else 1.0f,
targetValue = 1.0f, targetValue = 1.0f,
animationSpec = infiniteRepeatable( animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200), animation = tween(durationMillis = 1200),
@@ -232,12 +239,22 @@ private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Un
), ),
label = "pulse" label = "pulse"
) )
val alpha = if (animating) pulse else 1.0f
Box( Box(
modifier = Modifier modifier = Modifier
.size(220.dp) .size(220.dp)
.clip(CircleShape) .clip(CircleShape),
contentAlignment = Alignment.Center
) {
// While idle OR before the first location fix arrives, fall back to the
// solid pulsing circle — a blank/loading map mid-tile-fetch reads as
// broken. The map only renders once we actually have something to show.
if (!animating || userLocation == null) {
val color = if (animating) activeColor else idleColor
val alpha = if (animating) pulse else 1.0f
Box(
modifier = Modifier
.fillMaxSize()
.background( .background(
Brush.radialGradient( Brush.radialGradient(
colors = listOf( colors = listOf(
@@ -245,18 +262,94 @@ private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Un
color.copy(alpha = alpha * 0.6f) color.copy(alpha = alpha * 0.6f)
) )
) )
) ),
.clickable(onClick = onTap),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
val labelText = when {
!animating -> "IDLE"
else -> "WAITING FIX"
}
Text( Text(
text = labelText, text = labelText,
color = labelColor, color = if (animating) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 28.sp, fontSize = 22.sp,
fontWeight = FontWeight.Black, fontWeight = FontWeight.Black,
fontFamily = FontFamily.Monospace fontFamily = FontFamily.Monospace
) )
} }
} 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.
// 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
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
MapView(ctx).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()
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.
setInfoWindow(null)
}
map.overlays.add(m)
}
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)
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.
Box(
modifier = Modifier
.fillMaxSize()
.clickable(onClick = onTap)
)
}
} }
@Composable @Composable
@@ -390,10 +483,23 @@ private fun EventRow(e: DetectionEvent) {
if (e.hasGeo) { if (e.hasGeo) {
IconButton( IconButton(
onClick = { onClick = {
// resolveActivity returns null on Android 11+ without a matching
// <queries> 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 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) val intent = Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (intent.resolveActivity(ctx.packageManager) != null) { try {
ctx.startActivity(intent) ctx.startActivity(intent)
} 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)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
try { ctx.startActivity(webIntent) } catch (_: android.content.ActivityNotFoundException) {}
} }
}, },
modifier = Modifier.size(28.dp) modifier = Modifier.size(28.dp)
@@ -49,6 +49,7 @@ fun SettingsScreen(
val wifi by settings.wifiEnabled.collectAsState() val wifi by settings.wifiEnabled.collectAsState()
val deflock by settings.deflockEnabled.collectAsState() val deflock by settings.deflockEnabled.collectAsState()
val citizen by settings.citizenEnabled.collectAsState() val citizen by settings.citizenEnabled.collectAsState()
val mic by settings.micEnabled.collectAsState()
val deflockProx by settings.deflockProximityM.collectAsState() val deflockProx by settings.deflockProximityM.collectAsState()
val citizenProx by settings.citizenProximityM.collectAsState() val citizenProx by settings.citizenProximityM.collectAsState()
val theme by settings.themeMode.collectAsState() val theme by settings.themeMode.collectAsState()
@@ -82,6 +83,7 @@ fun SettingsScreen(
SourceToggle("WIFI • WiFi BSSID + SSID", wifi) { settings.setWifiEnabled(it) } SourceToggle("WIFI • WiFi BSSID + SSID", wifi) { settings.setWifiEnabled(it) }
SourceToggle("DEFLOCK • ALPR map (Overpass)", deflock) { settings.setDeflockEnabled(it) } SourceToggle("DEFLOCK • ALPR map (Overpass)", deflock) { settings.setDeflockEnabled(it) }
SourceToggle("CITIZEN • Real-time incident feed", citizen) { settings.setCitizenEnabled(it) } SourceToggle("CITIZEN • Real-time incident feed", citizen) { settings.setCitizenEnabled(it) }
SourceToggle("MIC • Smart speakers / cams (Echo, Ring, Nest)", mic) { settings.setMicEnabled(it) }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
if (isRunning) { if (isRunning) {
Button( Button(
+2 -4
View File
@@ -1,14 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">[DЯΣΛMMΛKΣЯ] OVERWATCH</string> <string name="app_name">OVERWATCH</string>
<string name="title_line1">[DЯΣΛMMΛKΣЯ]</string>
<string name="title_line2"> . //0VΣЯW4TCH</string>
<string name="status_idle">Idle — press START to begin scanning</string> <string name="status_idle">Idle — press START to begin scanning</string>
<string name="status_scanning_clear">All clear</string> <string name="status_scanning_clear">All clear</string>
<string name="status_scanning">Scanning…</string> <string name="status_scanning">Scanning…</string>
<string name="action_start">START</string> <string name="action_start">START</string>
<string name="action_stop">STOP</string> <string name="action_stop">STOP</string>
<string name="notification_channel_name">DREAMMAKER / OVERWATCH detection</string> <string name="notification_channel_name">OVERWATCH detection</string>
<string name="notification_channel_desc">Foreground notification while scanning</string> <string name="notification_channel_desc">Foreground notification while scanning</string>
<string name="notification_title">OVERWATCH active</string> <string name="notification_title">OVERWATCH active</string>
<string name="notification_text">Scanning for nearby surveillance</string> <string name="notification_text">Scanning for nearby surveillance</string>
+2
View File
@@ -7,6 +7,7 @@ activityCompose = "1.9.3"
composeBom = "2024.12.01" composeBom = "2024.12.01"
material3 = "1.3.1" material3 = "1.3.1"
playServicesLocation = "21.3.0" playServicesLocation = "21.3.0"
osmdroid = "6.1.20"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -21,6 +22,7 @@ androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-toolin
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" } play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" }
osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }