diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e7ff756..119150f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "org.soulstone.overwatch" minSdk = 26 targetSdk = 35 - versionCode = 12 - versionName = "0.3.0" + versionCode = 13 + versionName = "0.3.1" } buildTypes { diff --git a/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt b/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt index 84a84f9..abc46d0 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/service/DetectionService.kt @@ -134,7 +134,13 @@ class DetectionService : LifecycleService() { store, locationProvider, 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() } diff --git a/app/src/main/kotlin/org/soulstone/overwatch/service/OverlayManager.kt b/app/src/main/kotlin/org/soulstone/overwatch/service/OverlayManager.kt index fb298f9..7918274 100644 --- a/app/src/main/kotlin/org/soulstone/overwatch/service/OverlayManager.kt +++ b/app/src/main/kotlin/org/soulstone/overwatch/service/OverlayManager.kt @@ -1,17 +1,21 @@ 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.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.setViewTreeLifecycleOwner import androidx.savedstate.SavedStateRegistry @@ -19,6 +23,7 @@ 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 @@ -26,40 +31,60 @@ 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. + * - 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) { +class OverlayManager( + private val context: Context, + private val onDismissed: () -> Unit +) { private companion object { private const val TAG = "OverlayManager" - private const val INITIAL_X = 60 - private const val INITIAL_Y = 240 + 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 view: ComposeView? = null + 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 (view != null) return + if (container != null) return if (!Settings.canDrawOverlays(context)) { Log.i(TAG, "Overlay permission not granted; skipping show()") return @@ -71,6 +96,16 @@ class OverlayManager(private val context: Context) { setViewTreeSavedStateRegistryOwner(newOwner) 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( WindowManager.LayoutParams.WRAP_CONTENT, @@ -81,42 +116,87 @@ class OverlayManager(private val context: Context) { PixelFormat.TRANSLUCENT ).apply { gravity = Gravity.TOP or Gravity.START - x = INITIAL_X - y = INITIAL_Y + x = (INITIAL_X_DP * density).toInt() + y = (INITIAL_Y_DP * density).toInt() } - composeView.setOnTouchListener(DragHandler(lp)) + wrapper.setOnTouchListener(DragHandler(lp)) try { - wm.addView(composeView, lp) - view = composeView + wm.addView(wrapper, lp) + container = wrapper 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) { + val v = container ?: return + try { wm.removeView(v) } catch (e: Exception) { Log.w(TAG, "removeView failed: ${e.message}") } owner?.destroy() - view = null + container = null owner = null params = null + hideDismissZone() 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) : View.OnTouchListener { @@ -126,6 +206,7 @@ class OverlayManager(private val context: Context) { 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 -> { @@ -139,18 +220,25 @@ class OverlayManager(private val context: Context) { MotionEvent.ACTION_MOVE -> { val dx = ev.rawX - touchDownX 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 + 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) { + 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 @@ -158,6 +246,11 @@ class OverlayManager(private val context: Context) { } try { context.startActivity(intent) } catch (_: Exception) {} } + hideDismissZone() + true + } + MotionEvent.ACTION_CANCEL -> { + hideDismissZone() true } 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 - * [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]. + * 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)