Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aafeeec465 | |||
| 5ad4b89e66 | |||
| 2a2eb9f4af |
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "org.soulstone.overwatch"
|
applicationId = "org.soulstone.overwatch"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 9
|
versionCode = 12
|
||||||
versionName = "0.2.0"
|
versionName = "0.3.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -38,8 +38,21 @@
|
|||||||
<!-- Vibration on threat-tier escalation -->
|
<!-- Vibration on threat-tier escalation -->
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
|
||||||
|
<!-- Floating threat-circle overlay (chat-bubble style). Special-access
|
||||||
|
permission — user grants via system Settings page, not the runtime
|
||||||
|
prompt. Only consumed when the user opts in to the bubble in app
|
||||||
|
Settings; otherwise dormant. -->
|
||||||
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||||
|
|
||||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||||
|
|
||||||
|
<!-- Allow Intent.setPackage("com.google.android.apps.maps") on Android 11+
|
||||||
|
(package visibility) so we can force "Open in Maps" pins to land in
|
||||||
|
Google Maps regardless of the user's default geo: handler. -->
|
||||||
|
<queries>
|
||||||
|
<package android:name="com.google.android.apps.maps" />
|
||||||
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
|||||||
@@ -92,6 +92,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
val maxScore by DetectionService.store.maxScore.collectAsState()
|
val maxScore by DetectionService.store.maxScore.collectAsState()
|
||||||
val mapPoints by DetectionService.mapPoints.collectAsState()
|
val mapPoints by DetectionService.mapPoints.collectAsState()
|
||||||
val userLocation by DetectionService.location.collectAsState()
|
val userLocation by DetectionService.location.collectAsState()
|
||||||
|
// Visible map radius = max of the two proximity sliders
|
||||||
|
// so the user sees the full area where a detection
|
||||||
|
// can fire. Using the raw setting values regardless of
|
||||||
|
// enabled-state keeps the visualization stable when a
|
||||||
|
// source is briefly toggled.
|
||||||
|
val deflockProx by settings.deflockProximityM.collectAsState()
|
||||||
|
val citizenProx by settings.citizenProximityM.collectAsState()
|
||||||
|
val mapRadiusM = maxOf(deflockProx, citizenProx).toFloat()
|
||||||
val granted by permissionsGranted
|
val granted by permissionsGranted
|
||||||
val denied by permanentlyDenied
|
val denied by permanentlyDenied
|
||||||
|
|
||||||
@@ -108,6 +116,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
events = events,
|
events = events,
|
||||||
mapPoints = mapPoints,
|
mapPoints = mapPoints,
|
||||||
userLocation = userLocation,
|
userLocation = userLocation,
|
||||||
|
mapRadiusMeters = mapRadiusM,
|
||||||
canStart = true,
|
canStart = true,
|
||||||
permissionMessage = message,
|
permissionMessage = message,
|
||||||
showOpenAppSettings = denied && !granted,
|
showOpenAppSettings = denied && !granted,
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ class Settings private constructor(private val prefs: SharedPreferences) {
|
|||||||
private val _vibrateOnAlert = MutableStateFlow(prefs.getBoolean(KEY_VIBRATE, true))
|
private val _vibrateOnAlert = MutableStateFlow(prefs.getBoolean(KEY_VIBRATE, true))
|
||||||
val vibrateOnAlert: StateFlow<Boolean> = _vibrateOnAlert.asStateFlow()
|
val vibrateOnAlert: StateFlow<Boolean> = _vibrateOnAlert.asStateFlow()
|
||||||
|
|
||||||
|
private val _overlayEnabled = MutableStateFlow(prefs.getBoolean(KEY_OVERLAY, false))
|
||||||
|
val overlayEnabled: StateFlow<Boolean> = _overlayEnabled.asStateFlow()
|
||||||
|
|
||||||
fun setBleEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_BLE, v) }; _bleEnabled.value = v }
|
fun setBleEnabled(v: Boolean) { prefs.edit { putBoolean(KEY_BLE, v) }; _bleEnabled.value = v }
|
||||||
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 }
|
||||||
@@ -81,6 +84,11 @@ class Settings private constructor(private val prefs: SharedPreferences) {
|
|||||||
_vibrateOnAlert.value = v
|
_vibrateOnAlert.value = v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setOverlayEnabled(v: Boolean) {
|
||||||
|
prefs.edit { putBoolean(KEY_OVERLAY, v) }
|
||||||
|
_overlayEnabled.value = v
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val PREFS = "overwatch_settings"
|
private const val PREFS = "overwatch_settings"
|
||||||
private const val KEY_BLE = "src_ble"
|
private const val KEY_BLE = "src_ble"
|
||||||
@@ -92,6 +100,7 @@ class Settings private constructor(private val prefs: SharedPreferences) {
|
|||||||
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"
|
||||||
private const val KEY_VIBRATE = "vibrate_on_alert"
|
private const val KEY_VIBRATE = "vibrate_on_alert"
|
||||||
|
private const val KEY_OVERLAY = "overlay_enabled"
|
||||||
|
|
||||||
const val DEFAULT_DEFLOCK_PROX = 200
|
const val DEFAULT_DEFLOCK_PROX = 200
|
||||||
const val DEFAULT_CITIZEN_PROX = 500
|
const val DEFAULT_CITIZEN_PROX = 500
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ class DetectionStore(
|
|||||||
_maxScore.value = 0
|
_maxScore.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Drop every event from a single source — used when a proximity threshold
|
||||||
|
* changes and the owning scanner needs to re-emit a fresh slate (events
|
||||||
|
* outside the new radius would otherwise linger until their 5-min TTL). */
|
||||||
|
@Synchronized
|
||||||
|
fun clearSource(source: DetectionSource) {
|
||||||
|
val remaining = _events.value.filter { it.source != source }
|
||||||
|
if (remaining.size == _events.value.size) return
|
||||||
|
_events.value = remaining
|
||||||
|
recompute(remaining)
|
||||||
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun pruneExpired() {
|
fun pruneExpired() {
|
||||||
val cutoff = nowMs() - retentionMs
|
val cutoff = nowMs() - retentionMs
|
||||||
|
|||||||
@@ -99,15 +99,36 @@ class CitizenScanner(
|
|||||||
// Drop cache entries that no longer appear in the trending list (resolved).
|
// Drop cache entries that no longer appear in the trending list (resolved).
|
||||||
incidentCache.keys.retainAll(ids.toSet())
|
incidentCache.keys.retainAll(ids.toSet())
|
||||||
|
|
||||||
|
// Fetch any ids we haven't seen yet — Citizen incidents don't mutate,
|
||||||
|
// so a single fetch per id per session is enough.
|
||||||
|
for (id in ids) {
|
||||||
|
if (incidentCache[id] == null) {
|
||||||
|
client.fetchIncident(id)?.also { incidentCache[id] = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emitProximityEvents(fix, ids.mapNotNull { incidentCache[it] })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-evaluate the cached incident set against the current proximity + age
|
||||||
|
* thresholds and the latest fix, *without* a network refetch. Used when
|
||||||
|
* the user moves the proximity slider — events outside a tightened radius
|
||||||
|
* would otherwise linger and detections inside a widened radius wouldn't
|
||||||
|
* appear until the next poll cycle.
|
||||||
|
*/
|
||||||
|
fun refresh() {
|
||||||
|
val fix = locationProvider.location.value ?: return
|
||||||
|
store.clearSource(DetectionSource.CITIZEN)
|
||||||
|
emitProximityEvents(fix, incidentCache.values.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitProximityEvents(fix: Location, incidents: Collection<CitizenClient.Incident>) {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val limit = proximityMeters()
|
val limit = proximityMeters()
|
||||||
val out = FloatArray(1)
|
val out = FloatArray(1)
|
||||||
|
|
||||||
for (id in ids) {
|
for (incident in incidents) {
|
||||||
val incident = incidentCache[id] ?: client.fetchIncident(id)?.also {
|
|
||||||
incidentCache[id] = it
|
|
||||||
} ?: continue
|
|
||||||
|
|
||||||
// Title-based pre-filter: drop pure fire/medical events.
|
// Title-based pre-filter: drop pure fire/medical events.
|
||||||
if (FIRE_MEDICAL_RX.containsMatchIn(incident.title) &&
|
if (FIRE_MEDICAL_RX.containsMatchIn(incident.title) &&
|
||||||
!POLICE_TITLE_RX.containsMatchIn(incident.title)) {
|
!POLICE_TITLE_RX.containsMatchIn(incident.title)) {
|
||||||
|
|||||||
@@ -101,6 +101,27 @@ class DeflockScanner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
emitProximityEvents(fix)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-evaluate the cached ALPRs against the current proximity threshold and
|
||||||
|
* the latest fix, *without* a network refetch. Used when the user moves the
|
||||||
|
* proximity slider — the slider changes [proximityMeters], but the scanner
|
||||||
|
* is otherwise idle (no new location ticks while stationary), so events
|
||||||
|
* outside the new radius would otherwise linger and detections inside the
|
||||||
|
* widened radius wouldn't appear until the next handleFix cycle.
|
||||||
|
*
|
||||||
|
* Clears the DEFLOCK source from the store first so events that fall
|
||||||
|
* outside a tightened radius disappear immediately.
|
||||||
|
*/
|
||||||
|
fun refresh() {
|
||||||
|
val fix = locationProvider.location.value ?: return
|
||||||
|
store.clearSource(DetectionSource.DEFLOCK)
|
||||||
|
emitProximityEvents(fix)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitProximityEvents(fix: Location) {
|
||||||
val points = _cachedPoints.value
|
val points = _cachedPoints.value
|
||||||
if (points.isEmpty()) return
|
if (points.isEmpty()) return
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.soulstone.overwatch.MainActivity
|
import org.soulstone.overwatch.MainActivity
|
||||||
import org.soulstone.overwatch.R
|
import org.soulstone.overwatch.R
|
||||||
@@ -104,10 +105,14 @@ class DetectionService : LifecycleService() {
|
|||||||
private lateinit var locationProvider: LocationProvider
|
private lateinit var locationProvider: LocationProvider
|
||||||
private lateinit var deflockScanner: DeflockScanner
|
private lateinit var deflockScanner: DeflockScanner
|
||||||
private lateinit var citizenScanner: CitizenScanner
|
private lateinit var citizenScanner: CitizenScanner
|
||||||
|
private lateinit var overlayManager: OverlayManager
|
||||||
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 mapPointsJob: Job? = null
|
||||||
private var locationJob: Job? = null
|
private var locationJob: Job? = null
|
||||||
|
private var deflockProxJob: Job? = null
|
||||||
|
private var citizenProxJob: Job? = null
|
||||||
|
private var overlayJob: 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
|
||||||
@@ -129,6 +134,7 @@ class DetectionService : LifecycleService() {
|
|||||||
store, locationProvider,
|
store, locationProvider,
|
||||||
proximityMeters = { settings.citizenProximityM.value.toFloat() }
|
proximityMeters = { settings.citizenProximityM.value.toFloat() }
|
||||||
)
|
)
|
||||||
|
overlayManager = OverlayManager(this)
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,6 +235,32 @@ class DetectionService : LifecycleService() {
|
|||||||
locationJob = lifecycleScope.launch {
|
locationJob = lifecycleScope.launch {
|
||||||
locationProvider.location.collect { _location.value = it }
|
locationProvider.location.collect { _location.value = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Live re-eval when the user moves a proximity slider. drop(1) skips
|
||||||
|
// the StateFlow's initial replay so we don't redundantly clear+re-emit
|
||||||
|
// the events the scanner just produced from its first handleFix call.
|
||||||
|
deflockProxJob?.cancel()
|
||||||
|
if (deflockStarted) {
|
||||||
|
deflockProxJob = lifecycleScope.launch {
|
||||||
|
settings.deflockProximityM.drop(1).collect { deflockScanner.refresh() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
citizenProxJob?.cancel()
|
||||||
|
if (citizenStarted) {
|
||||||
|
citizenProxJob = lifecycleScope.launch {
|
||||||
|
settings.citizenProximityM.drop(1).collect { citizenScanner.refresh() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating threat-circle overlay — observe the toggle and show/hide
|
||||||
|
// accordingly. The OverlayManager re-checks SYSTEM_ALERT_WINDOW each
|
||||||
|
// show() so a denied/revoked permission silently no-ops.
|
||||||
|
overlayJob?.cancel()
|
||||||
|
overlayJob = lifecycleScope.launch {
|
||||||
|
settings.overlayEnabled.collect { enabled ->
|
||||||
|
if (enabled) overlayManager.show() else overlayManager.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun endScanning() {
|
private fun endScanning() {
|
||||||
@@ -247,6 +279,10 @@ class DetectionService : LifecycleService() {
|
|||||||
observerJob?.cancel(); observerJob = null
|
observerJob?.cancel(); observerJob = null
|
||||||
mapPointsJob?.cancel(); mapPointsJob = null
|
mapPointsJob?.cancel(); mapPointsJob = null
|
||||||
locationJob?.cancel(); locationJob = null
|
locationJob?.cancel(); locationJob = null
|
||||||
|
deflockProxJob?.cancel(); deflockProxJob = null
|
||||||
|
citizenProxJob?.cancel(); citizenProxJob = null
|
||||||
|
overlayJob?.cancel(); overlayJob = null
|
||||||
|
overlayManager.hide()
|
||||||
_mapPoints.value = emptyList()
|
_mapPoints.value = emptyList()
|
||||||
_location.value = null
|
_location.value = null
|
||||||
lastNotifiedTier = ThreatLevel.GREEN
|
lastNotifiedTier = ThreatLevel.GREEN
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package org.soulstone.overwatch.service
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.PixelFormat
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.View
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.compose.ui.platform.ComposeView
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.lifecycle.LifecycleRegistry
|
||||||
|
import androidx.lifecycle.setViewTreeLifecycleOwner
|
||||||
|
import androidx.savedstate.SavedStateRegistry
|
||||||
|
import androidx.savedstate.SavedStateRegistryController
|
||||||
|
import androidx.savedstate.SavedStateRegistryOwner
|
||||||
|
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||||
|
import kotlin.math.abs
|
||||||
|
import org.soulstone.overwatch.MainActivity
|
||||||
|
import org.soulstone.overwatch.ui.OverlayBubble
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the floating threat-circle bubble — a [ComposeView] hosted in a
|
||||||
|
* [WindowManager] window at TYPE_APPLICATION_OVERLAY.
|
||||||
|
*
|
||||||
|
* Lifecycle:
|
||||||
|
* - [show] is idempotent. Silently no-ops if SYSTEM_ALERT_WINDOW isn't
|
||||||
|
* granted, so flipping the setting doesn't crash on a denied permission.
|
||||||
|
* - [hide] removes the view and tears down the owner.
|
||||||
|
* - The DetectionService calls [show] / [hide] based on the running ×
|
||||||
|
* overlayEnabled product. Permission revocation between show calls is
|
||||||
|
* handled silently — the bubble just doesn't appear.
|
||||||
|
*
|
||||||
|
* Touch model:
|
||||||
|
* - The window flag set lets touches outside the bubble pass through to
|
||||||
|
* whatever app is underneath (FLAG_NOT_TOUCH_MODAL) and never steals IME
|
||||||
|
* focus (FLAG_NOT_FOCUSABLE).
|
||||||
|
* - Touches *inside* the bubble are intercepted at the View layer for drag
|
||||||
|
* and tap-to-open. Compose doesn't see them — the bubble is a render-only
|
||||||
|
* visualization.
|
||||||
|
*/
|
||||||
|
class OverlayManager(private val context: Context) {
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private const val TAG = "OverlayManager"
|
||||||
|
private const val INITIAL_X = 60
|
||||||
|
private const val INITIAL_Y = 240
|
||||||
|
private const val TAP_SLOP_PX = 12
|
||||||
|
}
|
||||||
|
|
||||||
|
private val wm: WindowManager =
|
||||||
|
context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||||
|
|
||||||
|
private var view: ComposeView? = null
|
||||||
|
private var owner: OverlayOwner? = null
|
||||||
|
private var params: WindowManager.LayoutParams? = null
|
||||||
|
|
||||||
|
fun show() {
|
||||||
|
if (view != null) return
|
||||||
|
if (!Settings.canDrawOverlays(context)) {
|
||||||
|
Log.i(TAG, "Overlay permission not granted; skipping show()")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val newOwner = OverlayOwner()
|
||||||
|
val composeView = ComposeView(context).apply {
|
||||||
|
setViewTreeLifecycleOwner(newOwner)
|
||||||
|
setViewTreeSavedStateRegistryOwner(newOwner)
|
||||||
|
setContent { OverlayBubble() }
|
||||||
|
}
|
||||||
|
|
||||||
|
val lp = WindowManager.LayoutParams(
|
||||||
|
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||||
|
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||||
|
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
||||||
|
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
|
||||||
|
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
|
||||||
|
PixelFormat.TRANSLUCENT
|
||||||
|
).apply {
|
||||||
|
gravity = Gravity.TOP or Gravity.START
|
||||||
|
x = INITIAL_X
|
||||||
|
y = INITIAL_Y
|
||||||
|
}
|
||||||
|
|
||||||
|
composeView.setOnTouchListener(DragHandler(lp))
|
||||||
|
|
||||||
|
try {
|
||||||
|
wm.addView(composeView, lp)
|
||||||
|
view = composeView
|
||||||
|
owner = newOwner
|
||||||
|
params = lp
|
||||||
|
Log.i(TAG, "Overlay bubble attached")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Most common: WindowManager$BadTokenException if perm was revoked
|
||||||
|
// between the canDrawOverlays check and addView. Tear down and
|
||||||
|
// bail; service can retry on next state flip.
|
||||||
|
Log.w(TAG, "Failed to attach overlay: ${e.message}")
|
||||||
|
newOwner.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hide() {
|
||||||
|
val v = view ?: return
|
||||||
|
try {
|
||||||
|
wm.removeView(v)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "removeView failed: ${e.message}")
|
||||||
|
}
|
||||||
|
owner?.destroy()
|
||||||
|
view = null
|
||||||
|
owner = null
|
||||||
|
params = null
|
||||||
|
Log.i(TAG, "Overlay bubble detached")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drag with raw coords, tap if movement stayed under [TAP_SLOP_PX]. */
|
||||||
|
private inner class DragHandler(private val lp: WindowManager.LayoutParams) :
|
||||||
|
View.OnTouchListener {
|
||||||
|
|
||||||
|
private var startX = 0
|
||||||
|
private var startY = 0
|
||||||
|
private var touchDownX = 0f
|
||||||
|
private var touchDownY = 0f
|
||||||
|
private var moved = false
|
||||||
|
|
||||||
|
override fun onTouch(v: View, ev: MotionEvent): Boolean {
|
||||||
|
return when (ev.action) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
startX = lp.x
|
||||||
|
startY = lp.y
|
||||||
|
touchDownX = ev.rawX
|
||||||
|
touchDownY = ev.rawY
|
||||||
|
moved = false
|
||||||
|
true
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_MOVE -> {
|
||||||
|
val dx = ev.rawX - touchDownX
|
||||||
|
val dy = ev.rawY - touchDownY
|
||||||
|
if (abs(dx) > TAP_SLOP_PX || abs(dy) > TAP_SLOP_PX) {
|
||||||
|
moved = true
|
||||||
|
}
|
||||||
|
if (moved) {
|
||||||
|
lp.x = startX + dx.toInt()
|
||||||
|
lp.y = startY + dy.toInt()
|
||||||
|
try { wm.updateViewLayout(v, lp) } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_UP -> {
|
||||||
|
if (!moved) {
|
||||||
|
// Tap → bring the host app forward.
|
||||||
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||||
|
Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
}
|
||||||
|
try { context.startActivity(intent) } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compose's [ComposeView] requires both a [LifecycleOwner] and a
|
||||||
|
* [SavedStateRegistryOwner] in its view tree. A bare [android.app.Service]
|
||||||
|
* isn't an SSR owner, so we synthesize one bound to the bubble's lifetime.
|
||||||
|
* The lifecycle is forced to RESUMED on construction (Compose only renders
|
||||||
|
* at STARTED+) and DESTROYED on [destroy].
|
||||||
|
*/
|
||||||
|
private class OverlayOwner : SavedStateRegistryOwner {
|
||||||
|
private val lifecycleReg = LifecycleRegistry(this)
|
||||||
|
private val ssrController = SavedStateRegistryController.create(this)
|
||||||
|
|
||||||
|
init {
|
||||||
|
ssrController.performAttach()
|
||||||
|
ssrController.performRestore(null)
|
||||||
|
lifecycleReg.currentState = Lifecycle.State.RESUMED
|
||||||
|
}
|
||||||
|
|
||||||
|
override val lifecycle: Lifecycle get() = lifecycleReg
|
||||||
|
override val savedStateRegistry: SavedStateRegistry
|
||||||
|
get() = ssrController.savedStateRegistry
|
||||||
|
|
||||||
|
fun destroy() {
|
||||||
|
lifecycleReg.currentState = Lifecycle.State.DESTROYED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -53,8 +54,9 @@ 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 androidx.compose.ui.viewinterop.AndroidView
|
||||||
import kotlinx.coroutines.launch
|
import kotlin.math.cos
|
||||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||||
|
import org.osmdroid.util.BoundingBox
|
||||||
import org.osmdroid.util.GeoPoint
|
import org.osmdroid.util.GeoPoint
|
||||||
import org.osmdroid.views.MapView
|
import org.osmdroid.views.MapView
|
||||||
import org.osmdroid.views.overlay.Marker
|
import org.osmdroid.views.overlay.Marker
|
||||||
@@ -74,6 +76,10 @@ fun MainScreen(
|
|||||||
events: List<DetectionEvent>,
|
events: List<DetectionEvent>,
|
||||||
mapPoints: List<DeflockClient.AlprPoint>,
|
mapPoints: List<DeflockClient.AlprPoint>,
|
||||||
userLocation: Location?,
|
userLocation: Location?,
|
||||||
|
/** Visible radius of the map circle, in meters. Driven by the larger of
|
||||||
|
* the DeFlock and Citizen proximity sliders so the user sees the full
|
||||||
|
* area where a detection could fire. */
|
||||||
|
mapRadiusMeters: Float,
|
||||||
onStartStop: () -> Unit,
|
onStartStop: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
canStart: Boolean,
|
canStart: Boolean,
|
||||||
@@ -90,12 +96,12 @@ fun MainScreen(
|
|||||||
.background(MaterialTheme.colorScheme.background)
|
.background(MaterialTheme.colorScheme.background)
|
||||||
.padding(horizontal = 24.dp)
|
.padding(horizontal = 24.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
// Box (rather than Row + SpaceBetween) so the title is truly centered
|
||||||
|
// regardless of the gear icon's width.
|
||||||
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 8.dp),
|
.padding(top = 8.dp)
|
||||||
verticalAlignment = Alignment.Top,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "OVERWATCH",
|
text = "OVERWATCH",
|
||||||
@@ -103,9 +109,13 @@ fun MainScreen(
|
|||||||
fontSize = 26.sp,
|
fontSize = 26.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
letterSpacing = 4.sp
|
letterSpacing = 4.sp,
|
||||||
|
modifier = Modifier.align(Alignment.Center)
|
||||||
)
|
)
|
||||||
IconButton(onClick = onOpenSettings) {
|
IconButton(
|
||||||
|
onClick = onOpenSettings,
|
||||||
|
modifier = Modifier.align(Alignment.CenterEnd)
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Settings,
|
Icons.Filled.Settings,
|
||||||
contentDescription = "Settings",
|
contentDescription = "Settings",
|
||||||
@@ -125,6 +135,7 @@ fun MainScreen(
|
|||||||
animating = running,
|
animating = running,
|
||||||
userLocation = userLocation,
|
userLocation = userLocation,
|
||||||
mapPoints = mapPoints,
|
mapPoints = mapPoints,
|
||||||
|
mapRadiusMeters = mapRadiusMeters,
|
||||||
onTap = { showSheet = true }
|
onTap = { showSheet = true }
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -219,6 +230,7 @@ private fun ThreatMapCircle(
|
|||||||
animating: Boolean,
|
animating: Boolean,
|
||||||
userLocation: Location?,
|
userLocation: Location?,
|
||||||
mapPoints: List<DeflockClient.AlprPoint>,
|
mapPoints: List<DeflockClient.AlprPoint>,
|
||||||
|
mapRadiusMeters: Float,
|
||||||
onTap: () -> Unit
|
onTap: () -> Unit
|
||||||
) {
|
) {
|
||||||
val idleColor = MaterialTheme.colorScheme.surfaceVariant
|
val idleColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
@@ -278,69 +290,83 @@ private fun ThreatMapCircle(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// OSM map snapshot, centered on the user, with red ALPR pins.
|
// OSM map snapshot, centered on the user, with red ALPR pins and
|
||||||
// Non-interactive — touches are captured by the click overlay above
|
// a blue user-position dot. Non-interactive — touches are captured
|
||||||
// so a tap opens the source-details bottom sheet (matching the old
|
// by the click overlay above, so a tap opens the source-details
|
||||||
// circle's UX). Pan/zoom controls stay off.
|
// bottom sheet. Pan/zoom controls stay off.
|
||||||
// Capture into a local non-null val so the AndroidView update
|
// Capture into a local non-null val so the AndroidView update
|
||||||
// lambda doesn't run afoul of smart-cast-into-closure rules.
|
// lambda doesn't run afoul of smart-cast-into-closure rules.
|
||||||
val fix: Location = userLocation
|
val fix: Location = userLocation
|
||||||
|
val ctx = LocalContext.current
|
||||||
|
// Build the marker drawables once per Composition rather than
|
||||||
|
// every recomposition — bitmap allocation isn't free.
|
||||||
|
val userDot = remember(ctx) { dotDrawable(ctx.resources, 36, DOT_USER_BLUE) }
|
||||||
|
val flockDot = remember(ctx) { dotDrawable(ctx.resources, 26, DOT_FLOCK_RED) }
|
||||||
AndroidView(
|
AndroidView(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
factory = { ctx ->
|
factory = { c ->
|
||||||
MapView(ctx).apply {
|
MapView(c).apply {
|
||||||
setTileSource(TileSourceFactory.MAPNIK)
|
setTileSource(TileSourceFactory.MAPNIK)
|
||||||
setMultiTouchControls(false)
|
setMultiTouchControls(false)
|
||||||
setBuiltInZoomControls(false)
|
setBuiltInZoomControls(false)
|
||||||
isClickable = false
|
isClickable = false
|
||||||
isFocusable = false
|
isFocusable = false
|
||||||
controller.setZoom(17.0)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
update = { map ->
|
update = { map ->
|
||||||
map.controller.setCenter(GeoPoint(fix.latitude, fix.longitude))
|
map.controller.setCenter(GeoPoint(fix.latitude, fix.longitude))
|
||||||
map.overlays.clear()
|
map.overlays.clear()
|
||||||
|
|
||||||
|
// ALPR dots first, user dot last so the user draws on top.
|
||||||
for (p in mapPoints) {
|
for (p in mapPoints) {
|
||||||
val m = Marker(map).apply {
|
map.overlays.add(
|
||||||
position = GeoPoint(p.lat, p.lon)
|
Marker(map).apply {
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
position = GeoPoint(p.lat, p.lon)
|
||||||
title = p.operator ?: p.manufacturer ?: "ALPR"
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||||
// Disable osmdroid's per-marker info popup since
|
icon = flockDot
|
||||||
// the map isn't interactive — the bottom sheet is
|
title = p.operator ?: p.manufacturer ?: "ALPR"
|
||||||
// the canonical "details" surface.
|
setInfoWindow(null)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
map.overlays.add(
|
||||||
|
Marker(map).apply {
|
||||||
|
position = GeoPoint(fix.latitude, fix.longitude)
|
||||||
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||||
|
icon = userDot
|
||||||
setInfoWindow(null)
|
setInfoWindow(null)
|
||||||
}
|
}
|
||||||
map.overlays.add(m)
|
)
|
||||||
}
|
|
||||||
|
// Fit the visible radius to the larger of the two proximity
|
||||||
|
// settings. Defer to map.post so the call lands after layout
|
||||||
|
// — zoomToBoundingBox needs measured dimensions to compute
|
||||||
|
// the right zoom level. Latitude-aware longitude scaling so
|
||||||
|
// the bbox stays roughly square in real meters at any lat.
|
||||||
|
val r = mapRadiusMeters.toDouble().coerceAtLeast(50.0)
|
||||||
|
val latDegPerMeter = 1.0 / 111_000.0
|
||||||
|
val lonDegPerMeter = 1.0 /
|
||||||
|
(111_000.0 * cos(Math.toRadians(fix.latitude)).coerceAtLeast(0.01))
|
||||||
|
val bbox = BoundingBox(
|
||||||
|
fix.latitude + r * latDegPerMeter,
|
||||||
|
fix.longitude + r * lonDegPerMeter,
|
||||||
|
fix.latitude - r * latDegPerMeter,
|
||||||
|
fix.longitude - r * lonDegPerMeter
|
||||||
|
)
|
||||||
|
map.post { map.zoomToBoundingBox(bbox, false, 0) }
|
||||||
map.invalidate()
|
map.invalidate()
|
||||||
},
|
},
|
||||||
onRelease = { map -> map.onDetach() }
|
onRelease = { map -> map.onDetach() }
|
||||||
)
|
)
|
||||||
// Threat-tier scrim — pulses while scanning, dims tiles to keep
|
// Threat-tier scrim — pulses while scanning. Heavier alpha than
|
||||||
// the dark theme aesthetic and signals tier without text.
|
// the first cut so the tier color reads at a glance over OSM
|
||||||
val scrimAlpha = (0.35f * pulse).coerceIn(0.18f, 0.5f)
|
// tiles, which are themselves cream/light by default.
|
||||||
|
val scrimAlpha = (0.55f * pulse).coerceIn(0.40f, 0.65f)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(activeColor.copy(alpha = scrimAlpha))
|
.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
|
// Click capture sits on top so taps reach onTap regardless of which
|
||||||
// visual layer was painted underneath.
|
// visual layer was painted underneath.
|
||||||
@@ -395,6 +421,14 @@ private fun SourcesPanel(events: List<DetectionEvent>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** User-facing label for a detection source. The internal enum stays MIC
|
||||||
|
* (mic-bearing devices is the technical concept) while the UI shows the
|
||||||
|
* friendlier "COMMERCIAL" — Nest/Ring/Echo are commercial smart-home gear. */
|
||||||
|
private fun DetectionSource.displayLabel(): String = when (this) {
|
||||||
|
DetectionSource.MIC -> "COMMERCIAL"
|
||||||
|
else -> name
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
|
private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
|
||||||
val health by SourceHealth.flowFor(source).collectAsState()
|
val health by SourceHealth.flowFor(source).collectAsState()
|
||||||
@@ -414,7 +448,7 @@ private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = source.name,
|
text = source.displayLabel(),
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
@@ -483,23 +517,22 @@ private fun EventRow(e: DetectionEvent) {
|
|||||||
if (e.hasGeo) {
|
if (e.hasGeo) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
// resolveActivity returns null on Android 11+ without a matching
|
// Force the pin to open in Google Maps rather than whichever
|
||||||
// <queries> entry even when Google Maps is installed. Skip the
|
// app holds the user's default geo: handler — Waze, etc. can
|
||||||
// pre-check and let startActivity handle it; catch the rare
|
// intercept geo: intents and we don't want that here. Falls
|
||||||
// "no app at all" case instead of silently no-op'ing.
|
// back to a generic browser intent if Maps isn't installed.
|
||||||
val uri = Uri.parse("geo:${e.lat},${e.lon}?q=${e.lat},${e.lon}(${Uri.encode(e.label)})")
|
val mapsUri = Uri.parse(
|
||||||
val intent = Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
"https://www.google.com/maps/search/?api=1&query=${e.lat},${e.lon}"
|
||||||
|
)
|
||||||
|
val mapsIntent = Intent(Intent.ACTION_VIEW, mapsUri)
|
||||||
|
.setPackage("com.google.android.apps.maps")
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
try {
|
try {
|
||||||
ctx.startActivity(intent)
|
ctx.startActivity(mapsIntent)
|
||||||
} catch (_: android.content.ActivityNotFoundException) {
|
} catch (_: android.content.ActivityNotFoundException) {
|
||||||
// Fall back to a Google Maps URL — works even on devices
|
val fallback = Intent(Intent.ACTION_VIEW, mapsUri)
|
||||||
// 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)
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
try { ctx.startActivity(webIntent) } catch (_: android.content.ActivityNotFoundException) {}
|
try { ctx.startActivity(fallback) } catch (_: android.content.ActivityNotFoundException) {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.size(28.dp)
|
modifier = Modifier.size(28.dp)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package org.soulstone.overwatch.ui
|
||||||
|
|
||||||
|
import android.content.res.Resources
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.drawable.BitmapDrawable
|
||||||
|
|
||||||
|
/** Builds a small filled-circle Marker icon. Used for both the user-position
|
||||||
|
* dot (blue) and the ALPR pins (red) — osmdroid's default teardrop marker
|
||||||
|
* reads as a "click me" affordance which is wrong for a non-interactive
|
||||||
|
* visualization, so we use simple dots instead. Shared by the in-app and
|
||||||
|
* overlay versions of the threat circle. */
|
||||||
|
internal fun dotDrawable(
|
||||||
|
resources: Resources,
|
||||||
|
sizePx: Int,
|
||||||
|
coreColor: Int
|
||||||
|
): BitmapDrawable {
|
||||||
|
val bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(bitmap)
|
||||||
|
val outline = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = 0xFFFFFFFF.toInt() }
|
||||||
|
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f - 1f, outline)
|
||||||
|
val core = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = coreColor }
|
||||||
|
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f - 4f, core)
|
||||||
|
return BitmapDrawable(resources, bitmap)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal const val DOT_USER_BLUE = 0xFF2196F3.toInt()
|
||||||
|
internal const val DOT_FLOCK_RED = 0xFFD7263D.toInt()
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package org.soulstone.overwatch.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.max
|
||||||
|
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||||
|
import org.osmdroid.util.BoundingBox
|
||||||
|
import org.osmdroid.util.GeoPoint
|
||||||
|
import org.osmdroid.views.MapView
|
||||||
|
import org.osmdroid.views.overlay.Marker
|
||||||
|
import org.soulstone.overwatch.data.settings.Settings
|
||||||
|
import org.soulstone.overwatch.fusion.ThreatLevel
|
||||||
|
import org.soulstone.overwatch.service.DetectionService
|
||||||
|
import org.soulstone.overwatch.ui.theme.ThreatColors
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smaller "chat-bubble" version of the threat-map circle, hosted in a
|
||||||
|
* WindowManager overlay by [org.soulstone.overwatch.service.OverlayManager].
|
||||||
|
*
|
||||||
|
* Self-contained: pulls all of its data from the same companion-level
|
||||||
|
* StateFlows the in-app [MainScreen] uses (DetectionService.running / store /
|
||||||
|
* mapPoints / location) plus the proximity sliders from [Settings]. The
|
||||||
|
* caller doesn't pass any state — keeps the OverlayManager dumb.
|
||||||
|
*
|
||||||
|
* Tap and drag are handled at the View layer (OverlayManager's OnTouchListener);
|
||||||
|
* this composable is render-only.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun OverlayBubble() {
|
||||||
|
val ctx = LocalContext.current
|
||||||
|
val settings = remember(ctx) { Settings.get(ctx) }
|
||||||
|
|
||||||
|
val running by DetectionService.running.collectAsState()
|
||||||
|
val threat by DetectionService.store.threatLevel.collectAsState()
|
||||||
|
val userLocation by DetectionService.location.collectAsState()
|
||||||
|
val mapPoints by DetectionService.mapPoints.collectAsState()
|
||||||
|
val deflockProx by settings.deflockProximityM.collectAsState()
|
||||||
|
val citizenProx by settings.citizenProximityM.collectAsState()
|
||||||
|
val radius = max(deflockProx, citizenProx).toFloat()
|
||||||
|
|
||||||
|
val activeColor = when (threat) {
|
||||||
|
ThreatLevel.GREEN -> ThreatColors.Green
|
||||||
|
ThreatLevel.YELLOW -> ThreatColors.Yellow
|
||||||
|
ThreatLevel.ORANGE -> ThreatColors.Orange
|
||||||
|
ThreatLevel.RED -> ThreatColors.Red
|
||||||
|
}
|
||||||
|
|
||||||
|
val transition = rememberInfiniteTransition(label = "overlay-pulse")
|
||||||
|
val pulse by transition.animateFloat(
|
||||||
|
initialValue = 0.55f,
|
||||||
|
targetValue = 1.0f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 1200),
|
||||||
|
repeatMode = RepeatMode.Reverse
|
||||||
|
),
|
||||||
|
label = "overlay-pulse"
|
||||||
|
)
|
||||||
|
|
||||||
|
val userDot = remember(ctx) { dotDrawable(ctx.resources, 30, DOT_USER_BLUE) }
|
||||||
|
val flockDot = remember(ctx) { dotDrawable(ctx.resources, 22, DOT_FLOCK_RED) }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(140.dp)
|
||||||
|
.clip(CircleShape),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
// The OverlayManager only attaches the bubble while running == true,
|
||||||
|
// but check anyway — paranoia keeps the bubble from rendering a stale
|
||||||
|
// map if a future code path lets the composition outlive the service.
|
||||||
|
val fix = userLocation
|
||||||
|
if (!running || fix == null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Brush.radialGradient(
|
||||||
|
colors = listOf(
|
||||||
|
activeColor.copy(alpha = pulse),
|
||||||
|
activeColor.copy(alpha = pulse * 0.6f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AndroidView(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
factory = { c ->
|
||||||
|
MapView(c).apply {
|
||||||
|
setTileSource(TileSourceFactory.MAPNIK)
|
||||||
|
setMultiTouchControls(false)
|
||||||
|
setBuiltInZoomControls(false)
|
||||||
|
isClickable = false
|
||||||
|
isFocusable = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update = { map ->
|
||||||
|
map.controller.setCenter(GeoPoint(fix.latitude, fix.longitude))
|
||||||
|
map.overlays.clear()
|
||||||
|
for (p in mapPoints) {
|
||||||
|
map.overlays.add(
|
||||||
|
Marker(map).apply {
|
||||||
|
position = GeoPoint(p.lat, p.lon)
|
||||||
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||||
|
icon = flockDot
|
||||||
|
title = p.operator ?: p.manufacturer ?: "ALPR"
|
||||||
|
setInfoWindow(null)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
map.overlays.add(
|
||||||
|
Marker(map).apply {
|
||||||
|
position = GeoPoint(fix.latitude, fix.longitude)
|
||||||
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
|
||||||
|
icon = userDot
|
||||||
|
setInfoWindow(null)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
val r = radius.toDouble().coerceAtLeast(50.0)
|
||||||
|
val latDegPerMeter = 1.0 / 111_000.0
|
||||||
|
val lonDegPerMeter = 1.0 /
|
||||||
|
(111_000.0 * cos(Math.toRadians(fix.latitude)).coerceAtLeast(0.01))
|
||||||
|
val bbox = BoundingBox(
|
||||||
|
fix.latitude + r * latDegPerMeter,
|
||||||
|
fix.longitude + r * lonDegPerMeter,
|
||||||
|
fix.latitude - r * latDegPerMeter,
|
||||||
|
fix.longitude - r * lonDegPerMeter
|
||||||
|
)
|
||||||
|
map.post { map.zoomToBoundingBox(bbox, false, 0) }
|
||||||
|
map.invalidate()
|
||||||
|
},
|
||||||
|
onRelease = { map -> map.onDetach() }
|
||||||
|
)
|
||||||
|
// Tier scrim — same pulse alpha range as the in-app circle.
|
||||||
|
val scrimAlpha = (0.55f * pulse).coerceIn(0.40f, 0.65f)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(activeColor.copy(alpha = scrimAlpha))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
package org.soulstone.overwatch.ui
|
package org.soulstone.overwatch.ui
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.Settings as AndroidSettings
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -11,6 +14,7 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -54,6 +58,8 @@ fun SettingsScreen(
|
|||||||
val citizenProx by settings.citizenProximityM.collectAsState()
|
val citizenProx by settings.citizenProximityM.collectAsState()
|
||||||
val theme by settings.themeMode.collectAsState()
|
val theme by settings.themeMode.collectAsState()
|
||||||
val vibrate by settings.vibrateOnAlert.collectAsState()
|
val vibrate by settings.vibrateOnAlert.collectAsState()
|
||||||
|
val overlay by settings.overlayEnabled.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -83,7 +89,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) }
|
SourceToggle("COMMERCIAL • Nest, Ring, Echo", mic) { settings.setMicEnabled(it) }
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
Button(
|
Button(
|
||||||
@@ -130,6 +136,32 @@ fun SettingsScreen(
|
|||||||
SectionLabel("Alerts")
|
SectionLabel("Alerts")
|
||||||
SourceToggle("Vibrate on threat escalation", vibrate) { settings.setVibrateOnAlert(it) }
|
SourceToggle("Vibrate on threat escalation", vibrate) { settings.setVibrateOnAlert(it) }
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
SectionLabel("Display over other apps")
|
||||||
|
SourceToggle("Floating threat circle", overlay) { enabled ->
|
||||||
|
settings.setOverlayEnabled(enabled)
|
||||||
|
// Special-access perm: can't be granted via runtime prompt. Bounce
|
||||||
|
// the user to the system settings page for this app so they can
|
||||||
|
// approve. The DetectionService re-checks canDrawOverlays at show()
|
||||||
|
// time so a denied/revoked perm just means the bubble silently
|
||||||
|
// doesn't appear — no crash.
|
||||||
|
if (enabled && !AndroidSettings.canDrawOverlays(context)) {
|
||||||
|
val intent = Intent(
|
||||||
|
AndroidSettings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||||
|
Uri.parse("package:${context.packageName}")
|
||||||
|
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
try { context.startActivity(intent) } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (overlay && !AndroidSettings.canDrawOverlays(context)) {
|
||||||
|
Text(
|
||||||
|
"Permission needed — system page should have opened. If not, grant manually under Apps → OVERWATCH → Display over other apps.",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
SectionLabel("Appearance")
|
SectionLabel("Appearance")
|
||||||
ThemeRadio("System default", theme == Settings.ThemeMode.SYSTEM) {
|
ThemeRadio("System default", theme == Settings.ThemeMode.SYSTEM) {
|
||||||
@@ -167,11 +199,16 @@ private fun SourceToggle(label: String, value: Boolean, onChange: (Boolean) -> U
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
|
// weight(1f) reserves the remaining row width for the label so it
|
||||||
|
// wraps on narrow screens instead of clipping under the Switch.
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontFamily = FontFamily.Monospace
|
fontFamily = FontFamily.Monospace,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f, fill = true)
|
||||||
|
.padding(end = 12.dp)
|
||||||
)
|
)
|
||||||
Switch(checked = value, onCheckedChange = onChange)
|
Switch(checked = value, onCheckedChange = onChange)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user