diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3776a58..afb2649 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 = 8 - versionName = "0.1.7" + versionCode = 9 + versionName = "0.2.0" } buildTypes { @@ -61,6 +61,7 @@ dependencies { implementation(libs.androidx.compose.material.icons.extended) implementation(libs.play.services.location) + implementation(libs.osmdroid.android) debugImplementation(libs.androidx.compose.ui.tooling) } diff --git a/app/src/main/kotlin/org/soulstone/overwatch/MainActivity.kt b/app/src/main/kotlin/org/soulstone/overwatch/MainActivity.kt index ec40ffa..e098827 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/MainActivity.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/MainActivity.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import org.osmdroid.config.Configuration import org.soulstone.overwatch.data.settings.Settings import org.soulstone.overwatch.service.DetectionService import org.soulstone.overwatch.ui.MainScreen @@ -66,6 +67,14 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { 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() permanentlyDenied.value = false // reset on activity create val settings = Settings.get(this) @@ -81,6 +90,8 @@ class MainActivity : ComponentActivity() { val events by DetectionService.store.events.collectAsState() val threat by DetectionService.store.threatLevel.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 denied by permanentlyDenied @@ -95,6 +106,8 @@ class MainActivity : ComponentActivity() { threat = threat, score = maxScore, events = events, + mapPoints = mapPoints, + userLocation = userLocation, canStart = true, permissionMessage = message, showOpenAppSettings = denied && !granted, diff --git a/app/src/main/kotlin/org/soulstone/overwatch/data/settings/Settings.kt b/app/src/main/kotlin/org/soulstone/overwatch/data/settings/Settings.kt index a5dad79..afa737c 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/data/settings/Settings.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/data/settings/Settings.kt @@ -32,6 +32,9 @@ class Settings private constructor(private val prefs: SharedPreferences) { private val _citizenEnabled = MutableStateFlow(prefs.getBoolean(KEY_CITIZEN, true)) val citizenEnabled: StateFlow = _citizenEnabled.asStateFlow() + private val _micEnabled = MutableStateFlow(prefs.getBoolean(KEY_MIC, true)) + val micEnabled: StateFlow = _micEnabled.asStateFlow() + private val _deflockProximityM = MutableStateFlow( 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 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 setMicEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_MIC, v) }; _micEnabled.value = v } fun setDeflockProximityM(v: Int) { 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_DEFLOCK = "src_deflock" 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_CITIZEN_PROX = "citizen_proximity_m" private const val KEY_THEME = "theme_mode" diff --git a/app/src/main/kotlin/org/soulstone/overwatch/data/targets/MicTargets.kt b/app/src/main/kotlin/org/soulstone/overwatch/data/targets/MicTargets.kt new file mode 100644 index 0000000..62e1ccf --- /dev/null +++ b/app/src/main/kotlin/org/soulstone/overwatch/data/targets/MicTargets.kt @@ -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 = 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 = 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 = 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 = OUIS_AMAZON + OUIS_GOOGLE + OUIS_HIDDEN_CAM + + /** Case-sensitive substrings — distinct enough to avoid false positives. */ + private val BLE_NAME_HINTS: List> = 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> = 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?): 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?, + 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" + } +} diff --git a/app/src/main/kotlin/org/soulstone/overwatch/fusion/ConfidenceEngine.kt b/app/src/main/kotlin/org/soulstone/overwatch/fusion/ConfidenceEngine.kt index 7ca3e54..326b4d2 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/fusion/ConfidenceEngine.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/fusion/ConfidenceEngine.kt @@ -38,6 +38,19 @@ object ConfidenceEngine { const val B_STRONG_RSSI = 10 // > -50 dBm 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. */ data class BleObservation( val mac: String, @@ -202,6 +215,107 @@ object ConfidenceEngine { 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?, + 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 { var score = 0 val methods = StringBuilder() diff --git a/app/src/main/kotlin/org/soulstone/overwatch/fusion/SourceHealth.kt b/app/src/main/kotlin/org/soulstone/overwatch/fusion/SourceHealth.kt index 0f5ce04..ff27551 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/fusion/SourceHealth.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/fusion/SourceHealth.kt @@ -27,17 +27,20 @@ object SourceHealth { private val _wifi = MutableStateFlow(Health()) private val _deflock = MutableStateFlow(Health()) private val _citizen = MutableStateFlow(Health()) + private val _mic = MutableStateFlow(Health()) val ble: StateFlow = _ble.asStateFlow() val wifi: StateFlow = _wifi.asStateFlow() val deflock: StateFlow = _deflock.asStateFlow() val citizen: StateFlow = _citizen.asStateFlow() + val mic: StateFlow = _mic.asStateFlow() fun flowFor(source: DetectionSource): StateFlow = when (source) { DetectionSource.BLE -> ble DetectionSource.WIFI -> wifi DetectionSource.DEFLOCK -> deflock DetectionSource.CITIZEN -> citizen + DetectionSource.MIC -> mic } fun record(source: DetectionSource, ok: Boolean, message: String? = null) { @@ -46,6 +49,7 @@ object SourceHealth { DetectionSource.WIFI -> _wifi DetectionSource.DEFLOCK -> _deflock DetectionSource.CITIZEN -> _citizen + DetectionSource.MIC -> _mic } target.value = Health( status = if (ok) Status.OK else Status.FAILED, @@ -59,5 +63,6 @@ object SourceHealth { _wifi.value = Health() _deflock.value = Health() _citizen.value = Health() + _mic.value = Health() } } diff --git a/app/src/main/kotlin/org/soulstone/overwatch/fusion/ThreatLevel.kt b/app/src/main/kotlin/org/soulstone/overwatch/fusion/ThreatLevel.kt index 0d37132..75066dc 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/fusion/ThreatLevel.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/fusion/ThreatLevel.kt @@ -21,4 +21,4 @@ enum class ThreatLevel(val minScore: Int) { } /** Logical signal channel — used in the drill-down UI. */ -enum class DetectionSource { BLE, WIFI, DEFLOCK, CITIZEN } +enum class DetectionSource { BLE, WIFI, DEFLOCK, CITIZEN, MIC } diff --git a/app/src/main/kotlin/org/soulstone/overwatch/scan/BleScanner.kt b/app/src/main/kotlin/org/soulstone/overwatch/scan/BleScanner.kt index dc59a48..d079cb2 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/scan/BleScanner.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/scan/BleScanner.kt @@ -14,6 +14,7 @@ import android.os.Build import android.util.Log import androidx.core.content.ContextCompat 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.RavenUuids import org.soulstone.overwatch.fusion.ConfidenceEngine @@ -38,7 +39,9 @@ import org.soulstone.overwatch.fusion.SourceHealth class BleScanner( private val context: Context, 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 { @@ -170,35 +173,66 @@ class BleScanner( } // Cheap pre-filter — drop devices that have zero target signals. - val candidate = BleOuis.matches(mac) || + val isSurveillance = BleOuis.matches(mac) || Patterns.bleNameMatch(name) || Patterns.isPenguinNumeric(name) || RavenUuids.countMatches(advertisedUuids) > 0 || 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) - val obs = ConfidenceEngine.BleObservation( - mac = mac, - rssi = result.rssi, - deviceName = name, - advertisedUuids = advertisedUuids, - manufacturerCompanyId = companyId, - manufacturerPayload = payload, - isStationary = rssi.isStationary(mac) - ) - val scored = ConfidenceEngine.scoreBle(obs) - if (scored.score < ALARM_THRESHOLD) return + val stationary = rssi.isStationary(mac) - store.submit( - DetectionEvent( - source = DetectionSource.BLE, - key = mac, - label = scored.label, - score = scored.score, - matchedMethods = scored.methods, - rssi = result.rssi + if (isSurveillance) { + val obs = ConfidenceEngine.BleObservation( + mac = mac, + rssi = result.rssi, + deviceName = name, + advertisedUuids = advertisedUuids, + manufacturerCompanyId = companyId, + manufacturerPayload = payload, + isStationary = stationary ) - ) + val scored = ConfidenceEngine.scoreBle(obs) + if (scored.score >= ALARM_THRESHOLD) { + store.submit( + DetectionEvent( + source = DetectionSource.BLE, + key = mac, + label = scored.label, + score = scored.score, + matchedMethods = scored.methods, + rssi = result.rssi + ) + ) + } + } + 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 + ) + ) + } + } } } diff --git a/app/src/main/kotlin/org/soulstone/overwatch/scan/DeflockScanner.kt b/app/src/main/kotlin/org/soulstone/overwatch/scan/DeflockScanner.kt index e09b731..8537922 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/scan/DeflockScanner.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/scan/DeflockScanner.kt @@ -4,6 +4,9 @@ import android.location.Location import android.util.Log import kotlinx.coroutines.CoroutineScope 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.launch import org.soulstone.overwatch.data.location.LocationProvider @@ -41,7 +44,10 @@ class DeflockScanner( private var lastFetchLon: Double? = null private var lastAttemptMs: Long = 0L private var lastAttemptOk: Boolean = false - private var cachedPoints: List = emptyList() + private val _cachedPoints = MutableStateFlow>(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> = _cachedPoints.asStateFlow() fun start(scope: CoroutineScope): Boolean { if (job != null) return true @@ -61,7 +67,7 @@ class DeflockScanner( lastFetchLon = null lastAttemptMs = 0L lastAttemptOk = false - cachedPoints = emptyList() + _cachedPoints.value = emptyList() Log.i(TAG, "DeflockScanner stopped") } @@ -74,12 +80,12 @@ class DeflockScanner( lastAttemptMs = System.currentTimeMillis() when (val result = client.fetchAround(fix.latitude, fix.longitude)) { is DeflockClient.FetchResult.Success -> { - cachedPoints = result.points + _cachedPoints.value = result.points lastAttemptOk = true SourceHealth.record(DetectionSource.DEFLOCK, ok = true) Log.i( TAG, - "Loaded ${cachedPoints.size} ALPRs around " + + "Loaded ${result.points.size} ALPRs around " + "(${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 out = FloatArray(1) - for (p in cachedPoints) { + for (p in points) { Location.distanceBetween(fix.latitude, fix.longitude, p.lat, p.lon, out) val dist = out[0] if (dist > limit) continue diff --git a/app/src/main/kotlin/org/soulstone/overwatch/scan/WifiScanner.kt b/app/src/main/kotlin/org/soulstone/overwatch/scan/WifiScanner.kt index 456814b..3f8a2bd 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/scan/WifiScanner.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/scan/WifiScanner.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import org.soulstone.overwatch.data.targets.MicTargets import org.soulstone.overwatch.data.targets.Patterns import org.soulstone.overwatch.data.targets.WifiOuis import org.soulstone.overwatch.fusion.ConfidenceEngine @@ -40,7 +41,9 @@ import org.soulstone.overwatch.fusion.SourceHealth class WifiScanner( private val context: Context, 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 { @@ -167,31 +170,51 @@ class WifiScanner( val bssid = r.BSSID ?: continue val ssid = readSsid(r) - val candidate = WifiOuis.matches(bssid) || + val isSurveillance = WifiOuis.matches(bssid) || Patterns.ssidGenericMatch(ssid) || Patterns.ssidFlockFormat(ssid) - if (!candidate) continue + val isMic = micEnabled() && MicTargets.couldBeMicWifi(bssid, ssid) + if (!isSurveillance && !isMic) continue rssi.update(bssid, r.level) - val obs = ConfidenceEngine.WifiObservation( - bssid = bssid, - ssid = ssid, - rssi = r.level, - isStationary = rssi.isStationary(bssid) - ) - val scored = ConfidenceEngine.scoreWifi(obs) - if (scored.score < ALARM_THRESHOLD) continue + val stationary = rssi.isStationary(bssid) - store.submit( - DetectionEvent( - source = DetectionSource.WIFI, - key = bssid, - label = scored.label, - score = scored.score, - matchedMethods = scored.methods, - rssi = r.level + if (isSurveillance) { + val obs = ConfidenceEngine.WifiObservation( + bssid = bssid, ssid = ssid, rssi = r.level, isStationary = stationary ) - ) + val scored = ConfidenceEngine.scoreWifi(obs) + if (scored.score >= ALARM_THRESHOLD) { + store.submit( + DetectionEvent( + source = DetectionSource.WIFI, + key = bssid, + label = scored.label, + score = scored.score, + matchedMethods = scored.methods, + rssi = r.level + ) + ) + } + } + 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 + ) + ) + } + } } } diff --git a/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt b/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt index abf77ec..c1ea753 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt @@ -7,6 +7,7 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo +import android.location.Location import android.os.Build import android.os.IBinder 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.settings.Settings import org.soulstone.overwatch.fusion.DetectionEvent +import org.soulstone.overwatch.fusion.DetectionSource import org.soulstone.overwatch.fusion.DetectionStore import org.soulstone.overwatch.fusion.SourceHealth 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.DeflockClient import org.soulstone.overwatch.scan.DeflockScanner +import org.soulstone.overwatch.scan.DeflockClient.AlprPoint import org.soulstone.overwatch.scan.WifiScanner /** @@ -67,6 +70,15 @@ class DetectionService : LifecycleService() { private val _running = MutableStateFlow(false) val running: StateFlow = _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>(emptyList()) + val mapPoints: StateFlow> = _mapPoints.asStateFlow() + + /** Latest fused location fix — UI map centers on this. */ + private val _location = MutableStateFlow(null) + val location: StateFlow = _location.asStateFlow() + fun start(context: Context) { val intent = Intent(context, DetectionService::class.java).apply { action = ACTION_START @@ -94,6 +106,8 @@ class DetectionService : LifecycleService() { private lateinit var citizenScanner: CitizenScanner private var pruneJob: Job? = null private var observerJob: Job? = null + private var mapPointsJob: Job? = null + private var locationJob: Job? = null private var bleStarted = false private var wifiStarted = false private var deflockStarted = false @@ -104,8 +118,8 @@ class DetectionService : LifecycleService() { override fun onCreate() { super.onCreate() settings = Settings.get(this) - bleScanner = BleScanner(this, store) - wifiScanner = WifiScanner(this, store) + bleScanner = BleScanner(this, store, micEnabled = { settings.micEnabled.value }) + wifiScanner = WifiScanner(this, store, micEnabled = { settings.micEnabled.value }) locationProvider = LocationProvider(this) deflockScanner = DeflockScanner( store, locationProvider, DeflockClient(this), @@ -169,6 +183,20 @@ class DetectionService : LifecycleService() { 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 pruneJob?.cancel() pruneJob = lifecycleScope.launch { @@ -187,6 +215,20 @@ class DetectionService : LifecycleService() { 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() { @@ -203,6 +245,10 @@ class DetectionService : LifecycleService() { SourceHealth.reset() pruneJob?.cancel(); pruneJob = null observerJob?.cancel(); observerJob = null + mapPointsJob?.cancel(); mapPointsJob = null + locationJob?.cancel(); locationJob = null + _mapPoints.value = emptyList() + _location.value = null lastNotifiedTier = ThreatLevel.GREEN if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { stopForeground(STOP_FOREGROUND_REMOVE) diff --git a/app/src/main/kotlin/org/soulstone/overwatch/ui/MainScreen.kt b/app/src/main/kotlin/org/soulstone/overwatch/ui/MainScreen.kt index d76d3e6..6666d33 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/ui/MainScreen.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/ui/MainScreen.kt @@ -1,5 +1,8 @@ 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.animateFloat 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.shape.CircleShape 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.filled.Place 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.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView 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.DetectionSource import org.soulstone.overwatch.fusion.SourceHealth import org.soulstone.overwatch.fusion.ThreatLevel +import org.soulstone.overwatch.scan.DeflockClient import org.soulstone.overwatch.ui.theme.ThreatColors @OptIn(ExperimentalMaterial3Api::class) @@ -65,6 +72,8 @@ fun MainScreen( threat: ThreatLevel, score: Int, events: List, + mapPoints: List, + userLocation: Location?, onStartStop: () -> Unit, onOpenSettings: () -> Unit, canStart: Boolean, @@ -88,22 +97,14 @@ fun MainScreen( verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.SpaceBetween ) { - Column { - Text( - text = "[DЯΣΛMMΛKΣЯ]", - color = MaterialTheme.colorScheme.onBackground, - fontSize = 22.sp, - fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace - ) - Text( - text = " . //0VΣЯW4TCH", - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 18.sp, - fontWeight = FontWeight.Medium, - fontFamily = FontFamily.Monospace - ) - } + Text( + text = "OVERWATCH", + color = MaterialTheme.colorScheme.onBackground, + fontSize = 26.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + letterSpacing = 4.sp + ) IconButton(onClick = onOpenSettings) { Icon( Icons.Filled.Settings, @@ -119,7 +120,13 @@ fun MainScreen( modifier = Modifier.fillMaxWidth(), 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)) Text( @@ -207,10 +214,13 @@ fun MainScreen( } @Composable -private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Unit) { - // When the scanner isn't running, deliberately use a muted color and IDLE - // text so the user can tell at a glance whether they're scanning. Without - // this, idle and "scanning, all clear" both render as solid green. +private fun ThreatMapCircle( + level: ThreatLevel, + animating: Boolean, + userLocation: Location?, + mapPoints: List, + onTap: () -> Unit +) { val idleColor = MaterialTheme.colorScheme.surfaceVariant val activeColor = when (level) { ThreatLevel.GREEN -> ThreatColors.Green @@ -218,13 +228,10 @@ private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Un ThreatLevel.ORANGE -> ThreatColors.Orange 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 pulse by transition.animateFloat( - initialValue = if (animating) 0.6f else 1.0f, + initialValue = if (animating) 0.5f else 1.0f, targetValue = 1.0f, animationSpec = infiniteRepeatable( animation = tween(durationMillis = 1200), @@ -232,29 +239,115 @@ private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Un ), label = "pulse" ) - val alpha = if (animating) pulse else 1.0f Box( modifier = Modifier .size(220.dp) - .clip(CircleShape) - .background( - Brush.radialGradient( - colors = listOf( - color.copy(alpha = alpha), - color.copy(alpha = alpha * 0.6f) - ) - ) - ) - .clickable(onClick = onTap), + .clip(CircleShape), contentAlignment = Alignment.Center ) { - Text( - text = labelText, - color = labelColor, - fontSize = 28.sp, - fontWeight = FontWeight.Black, - fontFamily = FontFamily.Monospace + // 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( + Brush.radialGradient( + colors = listOf( + color.copy(alpha = alpha), + color.copy(alpha = alpha * 0.6f) + ) + ) + ), + contentAlignment = Alignment.Center + ) { + val labelText = when { + !animating -> "IDLE" + else -> "WAITING FIX" + } + Text( + text = labelText, + color = if (animating) Color.White else MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 22.sp, + fontWeight = FontWeight.Black, + 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) ) } } @@ -390,10 +483,23 @@ 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) - if (intent.resolveActivity(ctx.packageManager) != null) { + try { 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) 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 0556c79..7f78265 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/ui/SettingsScreen.kt @@ -49,6 +49,7 @@ fun SettingsScreen( val wifi by settings.wifiEnabled.collectAsState() val deflock by settings.deflockEnabled.collectAsState() val citizen by settings.citizenEnabled.collectAsState() + val mic by settings.micEnabled.collectAsState() val deflockProx by settings.deflockProximityM.collectAsState() val citizenProx by settings.citizenProximityM.collectAsState() val theme by settings.themeMode.collectAsState() @@ -82,6 +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) } Spacer(Modifier.height(8.dp)) if (isRunning) { Button( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 41e346d..be8285d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,14 +1,12 @@ - [DЯΣΛMMΛKΣЯ] OVERWATCH - [DЯΣΛMMΛKΣЯ] - . //0VΣЯW4TCH + OVERWATCH Idle — press START to begin scanning All clear Scanning… START STOP - DREAMMAKER / OVERWATCH detection + OVERWATCH detection Foreground notification while scanning OVERWATCH active Scanning for nearby surveillance diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 38483d7..9c715bb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,6 +7,7 @@ activityCompose = "1.9.3" composeBom = "2024.12.01" material3 = "1.3.1" playServicesLocation = "21.3.0" +osmdroid = "6.1.20" [libraries] 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-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" } +osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }