Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10144b0d0e | |||
| d9251241dd | |||
| aafeeec465 |
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "org.soulstone.overwatch"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 11
|
||||
versionName = "0.2.2"
|
||||
versionCode = 14
|
||||
versionName = "0.3.2"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -38,6 +38,12 @@
|
||||
<!-- Vibration on threat-tier escalation -->
|
||||
<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" />
|
||||
|
||||
<!-- Allow Intent.setPackage("com.google.android.apps.maps") on Android 11+
|
||||
|
||||
@@ -53,6 +53,9 @@ class Settings private constructor(private val prefs: SharedPreferences) {
|
||||
private val _vibrateOnAlert = MutableStateFlow(prefs.getBoolean(KEY_VIBRATE, true))
|
||||
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 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 }
|
||||
@@ -81,6 +84,11 @@ class Settings private constructor(private val prefs: SharedPreferences) {
|
||||
_vibrateOnAlert.value = v
|
||||
}
|
||||
|
||||
fun setOverlayEnabled(v: Boolean) {
|
||||
prefs.edit { putBoolean(KEY_OVERLAY, v) }
|
||||
_overlayEnabled.value = v
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFS = "overwatch_settings"
|
||||
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_THEME = "theme_mode"
|
||||
private const val KEY_VIBRATE = "vibrate_on_alert"
|
||||
private const val KEY_OVERLAY = "overlay_enabled"
|
||||
|
||||
const val DEFAULT_DEFLOCK_PROX = 200
|
||||
const val DEFAULT_CITIZEN_PROX = 500
|
||||
|
||||
@@ -105,12 +105,14 @@ class DetectionService : LifecycleService() {
|
||||
private lateinit var locationProvider: LocationProvider
|
||||
private lateinit var deflockScanner: DeflockScanner
|
||||
private lateinit var citizenScanner: CitizenScanner
|
||||
private lateinit var overlayManager: OverlayManager
|
||||
private var pruneJob: Job? = null
|
||||
private var observerJob: Job? = null
|
||||
private var mapPointsJob: 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 wifiStarted = false
|
||||
private var deflockStarted = false
|
||||
@@ -132,6 +134,13 @@ class DetectionService : LifecycleService() {
|
||||
store, locationProvider,
|
||||
proximityMeters = { settings.citizenProximityM.value.toFloat() }
|
||||
)
|
||||
overlayManager = OverlayManager(
|
||||
context = this,
|
||||
// User dragged the bubble onto the X — flip the persisted toggle
|
||||
// so the setting and the bubble state stay aligned. The settings
|
||||
// collector below will call hide() again, but hide() is idempotent.
|
||||
onDismissed = { settings.setOverlayEnabled(false) }
|
||||
)
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
@@ -248,6 +257,16 @@ class DetectionService : LifecycleService() {
|
||||
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() {
|
||||
@@ -268,6 +287,8 @@ class DetectionService : LifecycleService() {
|
||||
locationJob?.cancel(); locationJob = null
|
||||
deflockProxJob?.cancel(); deflockProxJob = null
|
||||
citizenProxJob?.cancel(); citizenProxJob = null
|
||||
overlayJob?.cancel(); overlayJob = null
|
||||
overlayManager.hide()
|
||||
_mapPoints.value = emptyList()
|
||||
_location.value = null
|
||||
lastNotifiedTier = ThreatLevel.GREEN
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
package org.soulstone.overwatch.service
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
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.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
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 kotlin.math.sqrt
|
||||
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.
|
||||
*
|
||||
* Touch model:
|
||||
* - The bubble window has FLAG_NOT_FOCUSABLE | FLAG_NOT_TOUCH_MODAL, so
|
||||
* touches outside the bubble pass through to whatever app is underneath
|
||||
* and the bubble never steals IME focus.
|
||||
* - Touches *inside* the bubble are intercepted by [TouchInterceptor], a
|
||||
* FrameLayout wrapper whose onInterceptTouchEvent always returns true.
|
||||
* Without that wrapper the inner osmdroid MapView consumes ACTION_DOWN
|
||||
* for its own pan handling and the OnTouchListener never sees the gesture.
|
||||
* - Drag updates the window LayoutParams via WindowManager.updateViewLayout.
|
||||
* - Tap (movement under TAP_SLOP_PX) launches MainActivity.
|
||||
*
|
||||
* Dismiss zone:
|
||||
* - When the user begins dragging, a separate WindowManager view ([DismissView])
|
||||
* appears at bottom-center showing an X. The bubble's screen-space center
|
||||
* is checked against the X's bounds on each MOVE; on UP, if the bubble was
|
||||
* released over the X, [onDismissed] fires (caller flips the setting off
|
||||
* so the toggle and the bubble state stay in sync).
|
||||
* - The dismiss zone uses FLAG_NOT_TOUCHABLE so it never steals the gesture
|
||||
* — it's purely visual feedback.
|
||||
*/
|
||||
class OverlayManager(
|
||||
private val context: Context,
|
||||
private val onDismissed: () -> Unit
|
||||
) {
|
||||
|
||||
private companion object {
|
||||
private const val TAG = "OverlayManager"
|
||||
private const val INITIAL_X_DP = 24
|
||||
private const val INITIAL_Y_DP = 120
|
||||
private const val TAP_SLOP_PX = 12
|
||||
private const val BUBBLE_SIZE_DP = 140
|
||||
private const val DISMISS_SIZE_DP = 88
|
||||
private const val DISMISS_BOTTOM_MARGIN_DP = 100
|
||||
/** Extra slop around the dismiss zone — released within this radius
|
||||
* counts as "dropped on X". */
|
||||
private const val DISMISS_HIT_SLOP_DP = 16
|
||||
}
|
||||
|
||||
private val wm: WindowManager =
|
||||
context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
private val density = context.resources.displayMetrics.density
|
||||
private val bubbleSizePx = (BUBBLE_SIZE_DP * density).toInt()
|
||||
|
||||
private var container: TouchInterceptor? = null
|
||||
private var owner: OverlayOwner? = null
|
||||
private var params: WindowManager.LayoutParams? = null
|
||||
|
||||
private var dismissView: DismissView? = null
|
||||
private var dismissCenterX = 0f
|
||||
private var dismissCenterY = 0f
|
||||
private var dismissHitRadius = 0f
|
||||
|
||||
fun show() {
|
||||
if (container != null) return
|
||||
if (!Settings.canDrawOverlays(context)) {
|
||||
Log.i(TAG, "Overlay permission not granted; skipping show()")
|
||||
return
|
||||
}
|
||||
|
||||
val newOwner = OverlayOwner()
|
||||
val composeView = ComposeView(context).apply {
|
||||
setContent { OverlayBubble() }
|
||||
}
|
||||
// Wrap so we can intercept *before* MapView's own touch handling.
|
||||
// Compose's WindowRecomposer reads findViewTreeLifecycleOwner from
|
||||
// the *window-root* view (= the wrapper here, since it's what's
|
||||
// attached to WindowManager). Setting the owner only on the inner
|
||||
// ComposeView throws IllegalStateException at composition startup —
|
||||
// that was the v0.3.1 crash.
|
||||
val wrapper = TouchInterceptor(context).apply {
|
||||
setViewTreeLifecycleOwner(newOwner)
|
||||
setViewTreeSavedStateRegistryOwner(newOwner)
|
||||
addView(
|
||||
composeView,
|
||||
FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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_DP * density).toInt()
|
||||
y = (INITIAL_Y_DP * density).toInt()
|
||||
}
|
||||
|
||||
wrapper.setOnTouchListener(DragHandler(lp))
|
||||
|
||||
try {
|
||||
wm.addView(wrapper, lp)
|
||||
container = wrapper
|
||||
owner = newOwner
|
||||
params = lp
|
||||
Log.i(TAG, "Overlay bubble attached")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to attach overlay: ${e.message}")
|
||||
newOwner.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
val v = container ?: return
|
||||
try { wm.removeView(v) } catch (e: Exception) {
|
||||
Log.w(TAG, "removeView failed: ${e.message}")
|
||||
}
|
||||
owner?.destroy()
|
||||
container = null
|
||||
owner = null
|
||||
params = null
|
||||
hideDismissZone()
|
||||
Log.i(TAG, "Overlay bubble detached")
|
||||
}
|
||||
|
||||
private fun showDismissZone() {
|
||||
if (dismissView != null) return
|
||||
val sizePx = (DISMISS_SIZE_DP * density).toInt()
|
||||
val marginPx = (DISMISS_BOTTOM_MARGIN_DP * density).toInt()
|
||||
|
||||
// Precompute screen-space center so MOVE checks don't traverse the
|
||||
// view tree on every frame. Use displayMetrics (sufficient — a real
|
||||
// multi-display split would need WindowManager#getCurrentWindowMetrics
|
||||
// on API 30+, but the bubble lives on the user's primary display).
|
||||
val dm = context.resources.displayMetrics
|
||||
dismissCenterX = dm.widthPixels / 2f
|
||||
dismissCenterY = dm.heightPixels - marginPx - sizePx / 2f
|
||||
dismissHitRadius = sizePx / 2f + DISMISS_HIT_SLOP_DP * density
|
||||
|
||||
val v = DismissView(context)
|
||||
val lp = WindowManager.LayoutParams(
|
||||
sizePx, sizePx,
|
||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
|
||||
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
|
||||
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
|
||||
PixelFormat.TRANSLUCENT
|
||||
).apply {
|
||||
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
|
||||
y = marginPx
|
||||
}
|
||||
try {
|
||||
wm.addView(v, lp)
|
||||
dismissView = v
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to attach dismiss zone: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideDismissZone() {
|
||||
val v = dismissView ?: return
|
||||
try { wm.removeView(v) } catch (_: Exception) {}
|
||||
dismissView = null
|
||||
}
|
||||
|
||||
private fun isOverDismiss(bubbleX: Int, bubbleY: Int): Boolean {
|
||||
if (dismissView == null) return false
|
||||
val cx = bubbleX + bubbleSizePx / 2f
|
||||
val cy = bubbleY + bubbleSizePx / 2f
|
||||
val dx = cx - dismissCenterX
|
||||
val dy = cy - dismissCenterY
|
||||
return sqrt((dx * dx + dy * dy).toDouble()) < dismissHitRadius
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
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 (!moved && (abs(dx) > TAP_SLOP_PX || abs(dy) > TAP_SLOP_PX)) {
|
||||
moved = true
|
||||
showDismissZone()
|
||||
}
|
||||
if (moved) {
|
||||
lp.x = startX + dx.toInt()
|
||||
lp.y = startY + dy.toInt()
|
||||
try { wm.updateViewLayout(v, lp) } catch (_: Exception) {}
|
||||
dismissView?.setHighlighted(isOverDismiss(lp.x, lp.y))
|
||||
}
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
if (moved && isOverDismiss(lp.x, lp.y)) {
|
||||
// Released on the X — tear down and signal the caller
|
||||
// so the persisted setting flips off too.
|
||||
hide()
|
||||
onDismissed()
|
||||
} else 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) {}
|
||||
}
|
||||
hideDismissZone()
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_CANCEL -> {
|
||||
hideDismissZone()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Always-claiming wrapper. Without this, the osmdroid MapView descendant
|
||||
* consumes ACTION_DOWN for pan handling and the OnTouchListener never
|
||||
* fires — drags pan the map instead of moving the bubble. */
|
||||
private class TouchInterceptor(context: Context) : FrameLayout(context) {
|
||||
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean = true
|
||||
}
|
||||
|
||||
/** Bottom-center dismiss target — translucent dark circle with a white X
|
||||
* that flips red when the bubble is hovering over it. Drawn manually so
|
||||
* we don't need to ship a vector resource for one-off use. */
|
||||
private class DismissView(context: Context) : View(context) {
|
||||
private val bg = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val xStroke = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = 0xFFFFFFFF.toInt()
|
||||
strokeWidth = 8f
|
||||
style = Paint.Style.STROKE
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
}
|
||||
private var highlighted = false
|
||||
|
||||
fun setHighlighted(value: Boolean) {
|
||||
if (highlighted != value) {
|
||||
highlighted = value
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val cx = width / 2f
|
||||
val cy = height / 2f
|
||||
val r = minOf(cx, cy)
|
||||
bg.color = if (highlighted) 0xDDD7263D.toInt() else 0xCC1A1A1A.toInt()
|
||||
canvas.drawCircle(cx, cy, r - 4f, bg)
|
||||
val inset = r * 0.35f
|
||||
canvas.drawLine(cx - inset, cy - inset, cx + inset, cy + inset, xStroke)
|
||||
canvas.drawLine(cx + inset, cy - inset, cx - inset, cy + inset, xStroke)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose's [ComposeView] requires both a LifecycleOwner and a
|
||||
* [SavedStateRegistryOwner] in its view tree. A bare 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,30 +224,6 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
private fun dotDrawable(
|
||||
resources: android.content.res.Resources,
|
||||
sizePx: Int,
|
||||
coreColor: Int
|
||||
): android.graphics.drawable.BitmapDrawable {
|
||||
val bitmap = android.graphics.Bitmap.createBitmap(
|
||||
sizePx, sizePx, android.graphics.Bitmap.Config.ARGB_8888
|
||||
)
|
||||
val canvas = android.graphics.Canvas(bitmap)
|
||||
val outline = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = 0xFFFFFFFF.toInt()
|
||||
}
|
||||
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f - 1f, outline)
|
||||
val core = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = coreColor
|
||||
}
|
||||
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f - 4f, core)
|
||||
return android.graphics.drawable.BitmapDrawable(resources, bitmap)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThreatMapCircle(
|
||||
level: ThreatLevel,
|
||||
@@ -324,8 +300,8 @@ private fun ThreatMapCircle(
|
||||
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, 0xFF2196F3.toInt()) }
|
||||
val flockDot = remember(ctx) { dotDrawable(ctx.resources, 26, 0xFFD7263D.toInt()) }
|
||||
val userDot = remember(ctx) { dotDrawable(ctx.resources, 36, DOT_USER_BLUE) }
|
||||
val flockDot = remember(ctx) { dotDrawable(ctx.resources, 26, DOT_FLOCK_RED) }
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { c ->
|
||||
|
||||
@@ -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
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings as AndroidSettings
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -54,6 +58,8 @@ fun SettingsScreen(
|
||||
val citizenProx by settings.citizenProximityM.collectAsState()
|
||||
val theme by settings.themeMode.collectAsState()
|
||||
val vibrate by settings.vibrateOnAlert.collectAsState()
|
||||
val overlay by settings.overlayEnabled.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -130,6 +136,32 @@ fun SettingsScreen(
|
||||
SectionLabel("Alerts")
|
||||
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))
|
||||
SectionLabel("Appearance")
|
||||
ThemeRadio("System default", theme == Settings.ThemeMode.SYSTEM) {
|
||||
|
||||
Reference in New Issue
Block a user