v0.3.1 — overlay drag fix + drag-to-dismiss zone

- Bug: dragging the bubble panned the OSM map instead of moving the
  bubble. The OnTouchListener was attached to the ComposeView, but
  the inner MapView consumed ACTION_DOWN for its own pan handling
  before the listener fired. Fix: wrap the ComposeView in a custom
  TouchInterceptor FrameLayout whose onInterceptTouchEvent always
  returns true. Touches go to the wrapper's OnTouchListener; child
  views (including MapView) never see them. Bubble is now purely a
  visualization — pan/zoom is impossible.
- Drag-to-dismiss: when the user starts dragging (after passing
  TAP_SLOP), a translucent dark circle with a white X appears at
  bottom-center via a separate WindowManager view. Highlights red
  when the bubble's screen-space center is within hit slop of the X.
  Releasing on the X tears down the bubble AND fires onDismissed —
  DetectionService flips setOverlayEnabled(false) so the toggle and
  the bubble state stay in sync. Releasing elsewhere is a normal
  drag (just repositions). The dismiss zone uses FLAG_NOT_TOUCHABLE
  so it never steals the gesture; it's purely visual feedback.
This commit is contained in:
2026-05-07 23:45:01 -04:00
parent 0841a0a33f
commit eb26def14b
3 changed files with 182 additions and 44 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "org.soulstone.overwatch" applicationId = "org.soulstone.overwatch"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 12 versionCode = 13
versionName = "0.3.0" versionName = "0.3.1"
} }
buildTypes { buildTypes {
@@ -134,7 +134,13 @@ class DetectionService : LifecycleService() {
store, locationProvider, store, locationProvider,
proximityMeters = { settings.citizenProximityM.value.toFloat() } proximityMeters = { settings.citizenProximityM.value.toFloat() }
) )
overlayManager = OverlayManager(this) 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() createNotificationChannel()
} }
@@ -1,17 +1,21 @@
package org.soulstone.overwatch.service package org.soulstone.overwatch.service
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.PixelFormat import android.graphics.PixelFormat
import android.provider.Settings import android.provider.Settings
import android.util.Log import android.util.Log
import android.view.Gravity import android.view.Gravity
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup
import android.view.WindowManager import android.view.WindowManager
import android.widget.FrameLayout
import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.setViewTreeLifecycleOwner import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.savedstate.SavedStateRegistry import androidx.savedstate.SavedStateRegistry
@@ -19,6 +23,7 @@ import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.sqrt
import org.soulstone.overwatch.MainActivity import org.soulstone.overwatch.MainActivity
import org.soulstone.overwatch.ui.OverlayBubble import org.soulstone.overwatch.ui.OverlayBubble
@@ -26,40 +31,60 @@ import org.soulstone.overwatch.ui.OverlayBubble
* Owns the floating threat-circle bubble — a [ComposeView] hosted in a * Owns the floating threat-circle bubble — a [ComposeView] hosted in a
* [WindowManager] window at TYPE_APPLICATION_OVERLAY. * [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: * Touch model:
* - The window flag set lets touches outside the bubble pass through to * - The bubble window has FLAG_NOT_FOCUSABLE | FLAG_NOT_TOUCH_MODAL, so
* whatever app is underneath (FLAG_NOT_TOUCH_MODAL) and never steals IME * touches outside the bubble pass through to whatever app is underneath
* focus (FLAG_NOT_FOCUSABLE). * and the bubble never steals IME focus.
* - Touches *inside* the bubble are intercepted at the View layer for drag * - Touches *inside* the bubble are intercepted by [TouchInterceptor], a
* and tap-to-open. Compose doesn't see them — the bubble is a render-only * FrameLayout wrapper whose onInterceptTouchEvent always returns true.
* visualization. * 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) { class OverlayManager(
private val context: Context,
private val onDismissed: () -> Unit
) {
private companion object { private companion object {
private const val TAG = "OverlayManager" private const val TAG = "OverlayManager"
private const val INITIAL_X = 60 private const val INITIAL_X_DP = 24
private const val INITIAL_Y = 240 private const val INITIAL_Y_DP = 120
private const val TAP_SLOP_PX = 12 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 = private val wm: WindowManager =
context.getSystemService(Context.WINDOW_SERVICE) as 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 view: ComposeView? = null private var container: TouchInterceptor? = null
private var owner: OverlayOwner? = null private var owner: OverlayOwner? = null
private var params: WindowManager.LayoutParams? = 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() { fun show() {
if (view != null) return if (container != null) return
if (!Settings.canDrawOverlays(context)) { if (!Settings.canDrawOverlays(context)) {
Log.i(TAG, "Overlay permission not granted; skipping show()") Log.i(TAG, "Overlay permission not granted; skipping show()")
return return
@@ -71,6 +96,16 @@ class OverlayManager(private val context: Context) {
setViewTreeSavedStateRegistryOwner(newOwner) setViewTreeSavedStateRegistryOwner(newOwner)
setContent { OverlayBubble() } setContent { OverlayBubble() }
} }
// Wrap so we can intercept *before* MapView's own touch handling.
val wrapper = TouchInterceptor(context).apply {
addView(
composeView,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
)
}
val lp = WindowManager.LayoutParams( val lp = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT,
@@ -81,42 +116,87 @@ class OverlayManager(private val context: Context) {
PixelFormat.TRANSLUCENT PixelFormat.TRANSLUCENT
).apply { ).apply {
gravity = Gravity.TOP or Gravity.START gravity = Gravity.TOP or Gravity.START
x = INITIAL_X x = (INITIAL_X_DP * density).toInt()
y = INITIAL_Y y = (INITIAL_Y_DP * density).toInt()
} }
composeView.setOnTouchListener(DragHandler(lp)) wrapper.setOnTouchListener(DragHandler(lp))
try { try {
wm.addView(composeView, lp) wm.addView(wrapper, lp)
view = composeView container = wrapper
owner = newOwner owner = newOwner
params = lp params = lp
Log.i(TAG, "Overlay bubble attached") Log.i(TAG, "Overlay bubble attached")
} catch (e: Exception) { } 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}") Log.w(TAG, "Failed to attach overlay: ${e.message}")
newOwner.destroy() newOwner.destroy()
} }
} }
fun hide() { fun hide() {
val v = view ?: return val v = container ?: return
try { try { wm.removeView(v) } catch (e: Exception) {
wm.removeView(v)
} catch (e: Exception) {
Log.w(TAG, "removeView failed: ${e.message}") Log.w(TAG, "removeView failed: ${e.message}")
} }
owner?.destroy() owner?.destroy()
view = null container = null
owner = null owner = null
params = null params = null
hideDismissZone()
Log.i(TAG, "Overlay bubble detached") Log.i(TAG, "Overlay bubble detached")
} }
/** Drag with raw coords, tap if movement stayed under [TAP_SLOP_PX]. */ 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) : private inner class DragHandler(private val lp: WindowManager.LayoutParams) :
View.OnTouchListener { View.OnTouchListener {
@@ -126,6 +206,7 @@ class OverlayManager(private val context: Context) {
private var touchDownY = 0f private var touchDownY = 0f
private var moved = false private var moved = false
@SuppressLint("ClickableViewAccessibility")
override fun onTouch(v: View, ev: MotionEvent): Boolean { override fun onTouch(v: View, ev: MotionEvent): Boolean {
return when (ev.action) { return when (ev.action) {
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
@@ -139,18 +220,25 @@ class OverlayManager(private val context: Context) {
MotionEvent.ACTION_MOVE -> { MotionEvent.ACTION_MOVE -> {
val dx = ev.rawX - touchDownX val dx = ev.rawX - touchDownX
val dy = ev.rawY - touchDownY val dy = ev.rawY - touchDownY
if (abs(dx) > TAP_SLOP_PX || abs(dy) > TAP_SLOP_PX) { if (!moved && (abs(dx) > TAP_SLOP_PX || abs(dy) > TAP_SLOP_PX)) {
moved = true moved = true
showDismissZone()
} }
if (moved) { if (moved) {
lp.x = startX + dx.toInt() lp.x = startX + dx.toInt()
lp.y = startY + dy.toInt() lp.y = startY + dy.toInt()
try { wm.updateViewLayout(v, lp) } catch (_: Exception) {} try { wm.updateViewLayout(v, lp) } catch (_: Exception) {}
dismissView?.setHighlighted(isOverDismiss(lp.x, lp.y))
} }
true true
} }
MotionEvent.ACTION_UP -> { MotionEvent.ACTION_UP -> {
if (!moved) { 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. // Tap → bring the host app forward.
val intent = Intent(context, MainActivity::class.java).apply { val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or flags = Intent.FLAG_ACTIVITY_NEW_TASK or
@@ -158,6 +246,11 @@ class OverlayManager(private val context: Context) {
} }
try { context.startActivity(intent) } catch (_: Exception) {} try { context.startActivity(intent) } catch (_: Exception) {}
} }
hideDismissZone()
true
}
MotionEvent.ACTION_CANCEL -> {
hideDismissZone()
true true
} }
else -> false else -> false
@@ -165,12 +258,51 @@ class OverlayManager(private val context: Context) {
} }
} }
/** 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 * Compose's [ComposeView] requires both a LifecycleOwner and a
* [SavedStateRegistryOwner] in its view tree. A bare [android.app.Service] * [SavedStateRegistryOwner] in its view tree. A bare Service isn't an SSR
* isn't an SSR owner, so we synthesize one bound to the bubble's lifetime. * owner, so we synthesize one bound to the bubble's lifetime. The
* The lifecycle is forced to RESUMED on construction (Compose only renders * lifecycle is forced to RESUMED on construction (Compose only renders at
* at STARTED+) and DESTROYED on [destroy]. * STARTED+) and DESTROYED on [destroy].
*/ */
private class OverlayOwner : SavedStateRegistryOwner { private class OverlayOwner : SavedStateRegistryOwner {
private val lifecycleReg = LifecycleRegistry(this) private val lifecycleReg = LifecycleRegistry(this)