package com.openless.app import android.Manifest import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.Service import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.graphics.Color import android.graphics.PixelFormat import android.graphics.drawable.GradientDrawable import android.os.Build import android.os.IBinder import android.util.Log import android.view.Gravity import android.view.MotionEvent import android.view.View import android.view.WindowManager import android.widget.FrameLayout import android.widget.ImageView import android.widget.Toast import kotlin.math.abs /** Foreground service + TYPE_APPLICATION_OVERLAY floating dictation control. */ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateListener { private var windowManager: WindowManager? = null private var rootView: FrameLayout? = null private var layoutParams: WindowManager.LayoutParams? = null // Custom setter, not a plain field: tryPromoteRecordingForeground() // posts the FOREGROUND_SERVICE_TYPE_MICROPHONE "录音中" notification // Android requires while actually recording in the background, but // there used to be exactly one place that tore it back down // (ACTION_HIDE, i.e. only when the whole overlay is dismissed) — every // other path that ends a recording (onCapsuleStateChanged()'s // "transcribing"/"polishing"/"done"/"error"/"cancelled"/"idle" // branches, stopRecordingFromOverlay(), the error branches in // beginDictationFromOverlay()/stopRecordingFromOverlay()) just flipped // this flag to false and left the notification up — which is exactly // the "stuck showing 录音中 even when nothing is recording" bug this // catches structurally: demoting on every true->false transition here // means no call site can forget to do it, present or future. private var recording = false set(value) { val wasRecording = field field = value if (wasRecording && !value) demoteFromRecordingForeground() } private var processing = false private var keyboardVisible = false private var armed = false private var dragStartX = 0 private var dragStartY = 0 private var paramStartX = 0 private var paramStartY = 0 private var dragging = false private var longPressRecording = false private var pendingSwipe: SwipeDirection? = null private var swipeConsumed = false private lateinit var iconContainer: FrameLayout private lateinit var iconButton: ImageView override fun onBind(intent: Intent?): IBinder? = null override fun onCreate() { super.onCreate() // #region agent log // Overlay mode must register its own Service Context: RuntimeService only // lives while the IME is active, so pure-floating-window dictation otherwise // hits "no live Android Activity context registered" on every capsule notify. runCatching { OpenLessNative.nativeRegisterActivityContext(this) android.util.Log.i( "OpenLessDbg58c22b", """{"sessionId":"58c22b","hypothesisId":"A","location":"OpenLessOverlayService.onCreate","message":"registered overlay service context","data":{"hasCtx":${OpenLessNative.nativeHasRegisteredActivityContext()}},"timestamp":${System.currentTimeMillis()}}""", ) }.onFailure { error -> android.util.Log.w( "OpenLessDbg58c22b", """{"sessionId":"58c22b","hypothesisId":"A","location":"OpenLessOverlayService.onCreate","message":"overlay register failed","data":{"error":"${error.message}"},"timestamp":${System.currentTimeMillis()}}""", ) } // #endregion try { OpenLessNative.requireBackendContract() } catch (error: Throwable) { Log.e(TAG, "backend contract handshake failed", error) stopSelf() return } instance = this OpenLessOverlayBridge.listener = this } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { Log.i( TAG, "onStartCommand action=${intent?.action} startId=$startId rootAttached=${rootView?.isAttachedToWindow}", ) when (intent?.action) { null, ACTION_SHOW -> showOverlay() ACTION_START_RECORDING -> { showOverlay() if (!tryPromoteRecordingForeground()) { abortRecordingStart(startId) return START_NOT_STICKY } beginDictationFromOverlay() } ACTION_HIDE -> { hideOverlay() demoteFromRecordingForeground() stopSelf(startId) } ACTION_REPLACE_OVERLAY -> replaceOverlay() ACTION_TOGGLE_EXPAND -> handleIconClick() ACTION_KEYBOARD_CHANGED -> handleKeyboardChanged(intent) ACTION_REFRESH_LAYOUT -> refreshOverlayLayout() } return START_STICKY } override fun onDestroy() { if (OpenLessOverlayBridge.listener === this) { OpenLessOverlayBridge.listener = null } hideOverlay() if (instance === this) { instance = null } // Also reached when the system kills the foreground service: sync native OVERLAY_VISIBLE=false so the state never sticks at true. runCatching { OpenLessNative.nativeNotifyOverlayDestroyed() } // #region agent log android.util.Log.i( "OpenLessDbg58c22b", """{"sessionId":"58c22b","hypothesisId":"A","location":"OpenLessOverlayService.onDestroy","message":"unregistering overlay service context","timestamp":${System.currentTimeMillis()}}""", ) runCatching { OpenLessNative.nativeUnregisterActivityContext(this) } runCatching { OpenLessNative.nativeRegisterActivityContext(applicationContext) } // #endregion super.onDestroy() } override fun onCapsuleStateChanged(state: String, message: String?, level: Float) { when (state) { "recording" -> { recording = true processing = false if (!tryPromoteRecordingForeground()) { try { OpenLessNative.nativeCancelDictation() } catch (error: Throwable) { Log.w(TAG, "cancel dictation bridge unavailable", error) } return } applyVisualState(OverlayVisualState.Recording) } "transcribing", "polishing" -> { recording = false processing = true applyVisualState(OverlayVisualState.Processing) } "done" -> { recording = false processing = false setArmed(false) } "error" -> { recording = false processing = false setArmed(false) applyVisualState(OverlayVisualState.Error) message?.takeIf { it.isNotBlank() }?.let { showToast(it) } } "cancelled", "idle" -> { recording = false processing = false setArmed(false) } } } private fun showOverlay() = withOverlayLock { windowManager = getSystemService(WINDOW_SERVICE) as WindowManager reconcileOverlayRoots() overlayRoots.lastOrNull()?.let { existing -> val params = existing.layoutParams as? WindowManager.LayoutParams if (params != null) { refreshExistingOverlayLayout(existing, params) Log.i(TAG, "overlay already shown roots=${overlayRoots.size}") return@withOverlayLock } removeOverlayRoot(existing) synchronized(overlayRoots) { overlayRoots.remove(existing) } if (rootView === existing) { rootView = null layoutParams = null } } attachNewOverlayRoot() } private fun replaceOverlay() = withOverlayLock { windowManager = windowManager ?: getSystemService(WINDOW_SERVICE) as WindowManager reconcileOverlayRoots() val removed = clearAllOverlayRoots() if (removed > 0) { Log.i(TAG, "overlay replace cleared removed=$removed") } if (!canDrawOverlays()) { Log.i(TAG, "overlay replace skipped no overlay permission") return@withOverlayLock } if (!attachNewOverlayRoot()) { return@withOverlayLock } if (recording) { tryPromoteRecordingForeground() } Log.i(TAG, "overlay replaced roots=1") } private fun hideOverlay() = withOverlayLock { val removed = clearAllOverlayRoots() if (removed > 0) { Log.i(TAG, "overlay hidden removed=$removed") } } private fun clearAllOverlayRoots(): Int { windowManager = windowManager ?: getSystemService(WINDOW_SERVICE) as WindowManager val views = synchronized(overlayRoots) { (overlayRoots + listOfNotNull(rootView)).distinct().also { overlayRoots.clear() } } views.forEach { view -> removeOverlayRoot(view) } rootView = null layoutParams = null return views.size } private fun attachNewOverlayRoot(): Boolean { val savedPosition = loadSavedPosition() val params = WindowManager.LayoutParams( WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY } else { @Suppress("DEPRECATION") WindowManager.LayoutParams.TYPE_PHONE }, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN, PixelFormat.TRANSLUCENT, ) .apply { gravity = Gravity.TOP or Gravity.START x = savedPosition.first y = savedPosition.second } layoutParams = params val root = FrameLayout(this).apply { contentDescription = "OpenLess" isClickable = true isFocusable = false setOnClickListener { handleIconClick() } } iconContainer = root iconButton = buildIconButton() root.addView( iconButton, FrameLayout.LayoutParams(1, 1, Gravity.CENTER), ) applyOverlaySize(root) attachDragHandler(root, params) return try { windowManager?.addView(root, params) rootView = root synchronized(overlayRoots) { overlayRoots.add(root) } Log.i(TAG, "overlay shown x=${params.x} y=${params.y} roots=${overlayRoots.size}") applyVisualState( when { recording -> OverlayVisualState.Recording processing -> OverlayVisualState.Processing armed -> OverlayVisualState.Armed else -> OverlayVisualState.Idle } ) true } catch (error: Throwable) { Log.w(TAG, "show overlay failed", error) layoutParams = null false } } private fun canDrawOverlays(): Boolean { return OpenLessPermissionBridge.canDrawOverlaysSafely(this) } private fun refreshExistingOverlayLayout( root: FrameLayout, params: WindowManager.LayoutParams, ) { rootView = root layoutParams = params iconContainer = root (root.getChildAt(0) as? ImageView)?.let { iconButton = it } applyOverlaySize(root) attachDragHandler(root, params) clampToScreen(params) windowManager?.updateViewLayout(root, params) } private fun refreshOverlayLayout() = withOverlayLock { windowManager = windowManager ?: getSystemService(WINDOW_SERVICE) as WindowManager reconcileOverlayRoots() val root = overlayRoots.lastOrNull() ?: rootView if (root == null || !root.isAttachedToWindow) { Log.i(TAG, "overlay refresh skipped roots=0") return@withOverlayLock } val params = root.layoutParams as? WindowManager.LayoutParams if (params == null) { Log.i(TAG, "overlay refresh skipped roots=0") return@withOverlayLock } refreshExistingOverlayLayout(root, params) val sizeDp = OpenLessAndroidPreferences.overlaySizeDp(this) Log.i(TAG, "overlay layout refreshed sizeDp=$sizeDp roots=1") } private fun reconcileOverlayRoots() { val roots = synchronized(overlayRoots) { (overlayRoots + listOfNotNull(rootView)) .distinct() .filter { it.isAttachedToWindow } .also { overlayRoots.clear() overlayRoots.addAll(it) } } if (roots.isEmpty()) { rootView = null layoutParams = null return } roots.dropLast(1).forEach { staleRoot -> removeOverlayRoot(staleRoot) synchronized(overlayRoots) { overlayRoots.remove(staleRoot) } } val activeRoot = roots.last() synchronized(overlayRoots) { overlayRoots.clear() overlayRoots.add(activeRoot) } rootView = activeRoot layoutParams = activeRoot.layoutParams as? WindowManager.LayoutParams Log.i(TAG, "reconciled overlay roots kept=1 removed=${roots.size - 1}") } private fun removeOverlayRoot(view: FrameLayout) { try { if (view.isAttachedToWindow) { windowManager?.removeViewImmediate(view) } } catch (error: Throwable) { Log.w(TAG, "remove overlay root failed", error) } } private fun buildIconButton(): ImageView { return ImageView(this).apply { setImageResource(R.drawable.ic_overlay_logo) scaleType = ImageView.ScaleType.CENTER_INSIDE setPadding(0, 0, 0, 0) contentDescription = "OpenLess" isClickable = false isFocusable = false } } private fun applyOverlaySize(container: FrameLayout) { if (!::iconButton.isInitialized) return val sizeDp = OpenLessAndroidPreferences.overlaySizeDp(this) val paddingDp = overlayPaddingDp(sizeDp) val imageSizePx = dp((sizeDp - paddingDp * 2).coerceAtLeast(MIN_ICON_IMAGE_SIZE_DP)) val paddingPx = dp(paddingDp) container.setPadding(paddingPx, paddingPx, paddingPx, paddingPx) (iconButton.layoutParams as? FrameLayout.LayoutParams)?.let { childParams -> childParams.width = imageSizePx childParams.height = imageSizePx childParams.gravity = Gravity.CENTER iconButton.layoutParams = childParams } container.requestLayout() Log.i(TAG, "overlay size applied sizeDp=$sizeDp imagePx=$imageSizePx") } private fun overlayPaddingDp(sizeDp: Int): Int { return (sizeDp * ICON_PADDING_DP / DEFAULT_ICON_SIZE_DP).coerceIn(8, 20) } private fun handleIconClick() { if (processing) return if (recording) { stopRecordingFromOverlay() return } if (!isTapActivationMode()) { return } startRecordingFromOverlay() } private fun handleKeyboardChanged(intent: Intent) { val visible = intent.getBooleanExtra(EXTRA_KEYBOARD_VISIBLE, false) keyboardVisible = visible Log.i(TAG, "keyboard changed visible=$visible") if (visible) { showOverlay() return } if (!recording && !processing) { hideOverlay() } } private fun attachDragHandler(view: View, params: WindowManager.LayoutParams) { view.setOnTouchListener { touchedView, event -> when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { dragging = false swipeConsumed = false longPressRecording = false pendingSwipe = null if (processing) { return@setOnTouchListener true } dragStartX = event.rawX.toInt() dragStartY = event.rawY.toInt() paramStartX = params.x paramStartY = params.y if (!isTapActivationMode() && !recording && !processing) { longPressRecording = true startRecordingFromOverlay() } true } MotionEvent.ACTION_MOVE -> { val dx = event.rawX.toInt() - dragStartX val dy = event.rawY.toInt() - dragStartY val verticalSwipe = detectVerticalSwipe(dx, dy) if ( recording && verticalSwipe != null && gestureAction(verticalSwipe) != "none" && !swipeConsumed ) { pendingSwipe = verticalSwipe swipeConsumed = true applySwipePreview(verticalSwipe) return@setOnTouchListener true } val swipe = detectHorizontalSwipe(dx, dy) if ( (recording || armed || longPressRecording) && swipe != null && gestureAction(swipe) != "none" && !swipeConsumed ) { pendingSwipe = swipe swipeConsumed = true applySwipePreview(swipe) return@setOnTouchListener true } if ( !processing && !armed && !recording && !longPressRecording && (abs(dx) > DRAG_SLOP_PX || abs(dy) > DRAG_SLOP_PX) ) { dragging = true params.x = paramStartX + dx params.y = paramStartY + dy clampToScreen(params) rootView?.let { windowManager?.updateViewLayout(it, params) } } true } MotionEvent.ACTION_UP -> { if (!dragging) { val swipe = pendingSwipe if (swipe != null) { commitSwipe(swipe) } else if (longPressRecording || (!isTapActivationMode() && recording)) { stopRecordingFromOverlay() } else if (!isTapActivationMode()) { setArmed(false) } else if (!swipeConsumed) { touchedView.performClick() } } else { savePosition(params.x, params.y) } longPressRecording = false pendingSwipe = null swipeConsumed = false true } MotionEvent.ACTION_CANCEL -> { if (longPressRecording || (!isTapActivationMode() && recording)) { stopRecordingFromOverlay() } longPressRecording = false pendingSwipe = null swipeConsumed = false true } else -> false } } } private fun applyVisualState(state: OverlayVisualState) { if (!::iconContainer.isInitialized || !::iconButton.isInitialized) return val (alpha, fill, stroke, strokeWidth, enabled) = when (state) { OverlayVisualState.Idle -> VisualStyle( alpha = 0.58f, fill = Color.parseColor("#66202A36"), stroke = Color.parseColor("#66FFFFFF"), strokeWidth = 1, enabled = true, ) OverlayVisualState.Armed -> VisualStyle( alpha = 1f, fill = Color.parseColor("#E6111827"), stroke = Color.parseColor("#38BDF8"), strokeWidth = 3, enabled = true, ) OverlayVisualState.Recording -> VisualStyle( alpha = 1f, fill = Color.parseColor("#E6111827"), stroke = Color.parseColor("#F43F5E"), strokeWidth = 3, enabled = true, ) OverlayVisualState.Processing -> VisualStyle( alpha = 0.86f, fill = Color.parseColor("#D1111827"), stroke = Color.parseColor("#38BDF8"), strokeWidth = 2, enabled = true, ) OverlayVisualState.Error -> VisualStyle( alpha = 0.95f, fill = Color.parseColor("#E67F1D1D"), stroke = Color.parseColor("#EF4444"), strokeWidth = 2, enabled = true, ) } iconContainer.alpha = alpha iconContainer.isEnabled = enabled iconContainer.background = circleDrawable(fill, stroke, dp(strokeWidth)) iconButton.isEnabled = enabled } private fun setArmed(value: Boolean) { armed = value if (!recording && !processing) { applyVisualState(if (value) OverlayVisualState.Armed else OverlayVisualState.Idle) } } private fun detectHorizontalSwipe(dx: Int, dy: Int): SwipeDirection? { if (abs(dx) < dp(SWIPE_THRESHOLD_DP)) return null if (abs(dy) > abs(dx) * SWIPE_VERTICAL_RATIO) return null return if (dx < 0) SwipeDirection.Left else SwipeDirection.Right } private fun detectVerticalSwipe(dx: Int, dy: Int): SwipeDirection? { if (abs(dy) < dp(SWIPE_THRESHOLD_DP)) return null if (abs(dx) > abs(dy) * SWIPE_VERTICAL_RATIO) return null return if (dy < 0) SwipeDirection.Up else SwipeDirection.Down } private fun gestureAction(direction: SwipeDirection): String { return OpenLessAndroidPreferences.overlayGestureAction(this, direction.name.lowercase()) } private fun applySwipePreview(direction: SwipeDirection) { when (gestureAction(direction)) { "quick_note", "translation", "style_pack" -> applyVisualState(OverlayVisualState.Processing) "qa" -> applyVisualState(OverlayVisualState.Processing) "cancel" -> applyVisualState(OverlayVisualState.Error) else -> Unit } } private fun commitSwipe(direction: SwipeDirection) { Log.i(TAG, "commit swipe direction=$direction recording=$recording processing=$processing") when (gestureAction(direction)) { "quick_note" -> stopQuickNoteFromOverlay() "translation" -> stopRecordingFromOverlay(translation = true) "style_pack" -> { switchStylePackFromOverlay() if (recording) stopRecordingFromOverlay() } "qa" -> finalizeQaFromOverlay() "cancel" -> cancelRecordingFromOverlay(direction) else -> Unit } } private fun cancelRecordingFromOverlay(direction: SwipeDirection) { if (!recording || gestureAction(direction) != "cancel") { return } try { OpenLessNative.nativeCancelDictation() recording = false processing = false longPressRecording = false setArmed(false) applyVisualState(OverlayVisualState.Idle) Log.i(TAG, "recording cancelled from overlay direction=$direction") } catch (error: Throwable) { Log.w(TAG, "cancel dictation bridge unavailable", error) applyVisualState(OverlayVisualState.Error) showToast("语音服务未就绪,请打开 OpenLess 后重试") } } private fun switchStylePackFromOverlay() { try { OpenLessNative.nativeSwitchStylePack() setArmed(false) } catch (error: Throwable) { Log.w(TAG, "switch style pack bridge unavailable", error) applyVisualState(OverlayVisualState.Error) showToast("语音服务未就绪,请打开 OpenLess 后重试") } } private fun finalizeQaFromOverlay() { try { Log.i(TAG, "finalize QA from overlay") OpenLessNative.nativeFinalizeQaFromOverlay() recording = false processing = true setArmed(false) applyVisualState(OverlayVisualState.Processing) } catch (error: Throwable) { Log.w(TAG, "finalize QA bridge unavailable", error) processing = false applyVisualState(OverlayVisualState.Error) showToast("问答服务未就绪,请打开 OpenLess 后重试") } } private fun abortRecordingStart(startId: Int) { recording = false processing = false try { OpenLessNative.nativeCancelDictation() } catch (error: Throwable) { Log.w(TAG, "cancel dictation after foreground failure", error) } stopSelf(startId) } private fun beginDictationFromOverlay(translation: Boolean = false) { try { if (translation) { OpenLessNative.nativeStartDictationWithTranslation(true) } else { OpenLessNative.nativeStartDictation() } recording = true processing = false setArmed(false) applyVisualState(OverlayVisualState.Recording) } catch (error: Throwable) { Log.w(TAG, "start dictation bridge unavailable", error) // recording was never set true on this path (the native start // call itself threw), so the `recording` setter's own // true->false demote never fires — but tryPromoteRecordingForeground() // already posted the notification before this function was even // called, so it needs tearing down here explicitly or it's // orphaned with nothing left to ever clear it. demoteFromRecordingForeground() recording = false processing = false applyVisualState(OverlayVisualState.Error) showToast("语音服务未就绪,请打开 OpenLess 后重试") } } private fun startRecordingFromOverlay(translation: Boolean = false) { showOverlay() if (tryPromoteRecordingForeground()) { beginDictationFromOverlay(translation) return } applyVisualState(OverlayVisualState.Error) } private fun stopRecordingFromOverlay(translation: Boolean = false) { try { recording = false processing = true applyVisualState(OverlayVisualState.Processing) if (translation) { OpenLessNative.nativeStopDictationWithTranslation(true) } else { OpenLessNative.nativeStopDictation() } } catch (error: Throwable) { Log.w(TAG, "stop dictation bridge unavailable", error) recording = false processing = false applyVisualState(OverlayVisualState.Error) showToast("语音服务未就绪,请打开 OpenLess 后重试") } } /** Tears down the mic foreground-service notification — see `recording`'s own setter for why every recording-ends path routes through this one place. Safe to call even when not currently foreground-promoted (stopForeground() no-ops then). */ private fun demoteFromRecordingForeground() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { stopForeground(STOP_FOREGROUND_REMOVE) } else { @Suppress("DEPRECATION") stopForeground(true) } } private fun stopQuickNoteFromOverlay() { try { recording = false processing = true applyVisualState(OverlayVisualState.Processing) OpenLessNative.nativeStopDictationAsQuickNote() } catch (error: Throwable) { Log.w(TAG, "stop quick note bridge unavailable", error) recording = false processing = false applyVisualState(OverlayVisualState.Error) showToast("语音服务未就绪,请打开 OpenLess 后重试") } } private fun tryPromoteRecordingForeground(): Boolean { if ( checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED ) { showToast("请先授予麦克风权限") return false } val notification = buildNotification("录音中") return try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { startForeground( NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, ) } else { startForeground(NOTIFICATION_ID, notification) } true } catch (error: SecurityException) { Log.w(TAG, "microphone foreground service not allowed from current state", error) showToast("系统限制后台录音,请在 OpenLess 内开始") false } } private fun buildNotification(contentText: String): Notification { val channelId = "openless_overlay" if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val nm = getSystemService(NotificationManager::class.java) nm.createNotificationChannel( NotificationChannel( channelId, "OpenLess Overlay", NotificationManager.IMPORTANCE_LOW, ) ) } return Notification.Builder(this, channelId) .setContentTitle("OpenLess") .setContentText(contentText) .setSmallIcon(R.mipmap.ic_launcher) .build() } private fun circleDrawable(color: Int, strokeColor: Int, strokeWidth: Int): GradientDrawable { return GradientDrawable().apply { shape = GradientDrawable.OVAL setColor(color) setStroke(strokeWidth, strokeColor) } } private fun overlaySize(): Int { val root = rootView val measured = maxOf(root?.width ?: 0, root?.height ?: 0) return measured.takeIf { it > 0 } ?: dp(OpenLessAndroidPreferences.overlaySizeDp(this)) } private fun clampToScreen(params: WindowManager.LayoutParams) { val iconSize = overlaySize() val margin = dp(8) val maxX = (resources.displayMetrics.widthPixels - iconSize - margin).coerceAtLeast(margin) val maxY = (resources.displayMetrics.heightPixels - iconSize - margin).coerceAtLeast(margin) params.x = params.x.coerceIn(margin, maxX) params.y = params.y.coerceIn(margin, maxY) } private fun loadSavedPosition(): Pair { val prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE) val defaultX = dp(24) val defaultY = dp(120) val x = prefs.getInt(PREF_KEY_X, defaultX) val y = prefs.getInt(PREF_KEY_Y, defaultY) return x to y } private fun savePosition(x: Int, y: Int) { getSharedPreferences(PREFS_NAME, MODE_PRIVATE) .edit() .putInt(PREF_KEY_X, x) .putInt(PREF_KEY_Y, y) .apply() } private fun isTapActivationMode(): Boolean { return OpenLessAndroidPreferences.overlayActivationMode(this) == "tap" } private fun showToast(message: String) { Toast.makeText(this, message, Toast.LENGTH_SHORT).show() } private fun dp(value: Int): Int { return (value * resources.displayMetrics.density).toInt() } private inline fun withOverlayLock(block: () -> T): T { return synchronized(overlayLock) { block() } } private data class VisualStyle( val alpha: Float, val fill: Int, val stroke: Int, val strokeWidth: Int, val enabled: Boolean, ) private enum class OverlayVisualState { Idle, Armed, Recording, Processing, Error, } private enum class SwipeDirection { Left, Right, Up, Down, } companion object { const val ACTION_SHOW = "com.openless.app.overlay.SHOW" const val ACTION_HIDE = "com.openless.app.overlay.HIDE" const val ACTION_REPLACE_OVERLAY = "com.openless.app.overlay.REPLACE_OVERLAY" const val ACTION_REFRESH_LAYOUT = "com.openless.app.overlay.REFRESH_LAYOUT" const val ACTION_TOGGLE_EXPAND = "com.openless.app.overlay.TOGGLE_EXPAND" const val ACTION_START_RECORDING = "com.openless.app.overlay.START_RECORDING" const val ACTION_KEYBOARD_CHANGED = "com.openless.app.overlay.KEYBOARD_CHANGED" const val EXTRA_KEYBOARD_VISIBLE = "keyboard_visible" const val EXTRA_KEYBOARD_TOP = "keyboard_top" const val EXTRA_KEYBOARD_BOTTOM = "keyboard_bottom" private const val DEFAULT_ICON_SIZE_DP = 72 private const val MIN_ICON_IMAGE_SIZE_DP = 32 private const val ICON_PADDING_DP = 12 private const val DRAG_SLOP_PX = 8 private const val SWIPE_THRESHOLD_DP = 56 private const val SWIPE_VERTICAL_RATIO = 0.6f private const val PREFS_NAME = "openless_overlay" private const val PREF_KEY_X = "overlay_x" private const val PREF_KEY_Y = "overlay_y" private const val NOTIFICATION_ID = 42001 private const val TAG = "OpenLessOverlayService" private val overlayLock = Any() private val overlayRoots = mutableListOf() @Volatile var instance: OpenLessOverlayService? = null private set } }