Files
openless/openless-all/app/android/kotlin/OpenLessOverlayService.kt
T

832 lines
30 KiB
Kotlin

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.provider.Settings
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
private var recording = false
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()
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) {
ACTION_SHOW -> showOverlay()
ACTION_START_RECORDING -> {
showOverlay()
startRecordingFromOverlay()
}
ACTION_HIDE -> {
hideOverlay()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
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
}
super.onDestroy()
}
override fun onCapsuleStateChanged(state: String, message: String?) {
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 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Settings.canDrawOverlays(this)
} else {
true
}
}
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 && matchesConfiguredCancelSwipe(verticalSwipe) && !swipeConsumed) {
pendingSwipe = verticalSwipe
swipeConsumed = true
applySwipePreview(verticalSwipe)
return@setOnTouchListener true
}
val swipe = detectHorizontalSwipe(dx, dy)
if ((recording || armed || longPressRecording) && swipe != null && !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 matchesConfiguredCancelSwipe(direction: SwipeDirection): Boolean {
val configured = OpenLessAndroidPreferences.overlayCancelSwipeDirection(this)
return (direction == SwipeDirection.Up && configured == "up") ||
(direction == SwipeDirection.Down && configured == "down")
}
private fun applySwipePreview(direction: SwipeDirection) {
when (direction) {
SwipeDirection.Left -> applyVisualState(OverlayVisualState.Armed)
SwipeDirection.Right -> applyVisualState(OverlayVisualState.Processing)
SwipeDirection.Up,
SwipeDirection.Down -> applyVisualState(OverlayVisualState.Error)
}
}
private fun commitSwipe(direction: SwipeDirection) {
Log.i(TAG, "commit swipe direction=$direction recording=$recording processing=$processing")
when (direction) {
SwipeDirection.Left -> handleLeftSwipe()
SwipeDirection.Right -> finalizeQaFromOverlay()
SwipeDirection.Up,
SwipeDirection.Down -> cancelRecordingFromOverlay(direction)
}
}
private fun cancelRecordingFromOverlay(direction: SwipeDirection) {
if (!recording || !matchesConfiguredCancelSwipe(direction)) {
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 handleLeftSwipe() {
when (OpenLessAndroidPreferences.overlayLeftSwipeAction(this)) {
"style_pack" -> {
switchStylePackFromOverlay()
if (recording) {
stopRecordingFromOverlay()
}
}
else -> stopRecordingFromOverlay(translation = true)
}
}
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 openQaFromOverlay() {
try {
Log.i(TAG, "open QA from overlay")
OpenLessNative.nativeOpenQaFromOverlay()
setArmed(false)
} catch (error: Throwable) {
Log.w(TAG, "open QA 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 startRecordingFromOverlay(translation: Boolean = false) {
showOverlay()
if (tryPromoteRecordingForeground()) {
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 = false
processing = false
applyVisualState(OverlayVisualState.Error)
showToast("语音服务未就绪,请打开 OpenLess 后重试")
}
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 后重试")
}
}
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<Int, Int> {
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 <T> 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<FrameLayout>()
@Volatile
var instance: OpenLessOverlayService? = null
private set
}
}