@@ -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 t o
* - The bubble window has FLAG_NOT_FOCUSABLE | FLAG_NOT_TOUCH_MODAL, s o
* 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 )