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:
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user