Files
TRIPandHKLHaoBin 6f9af89583 release: OpenLess 2.0.0-Beta.4+build.20260930 (#1126)
Integrate ASR/Omni fixes, API Route, Android IME and confirmation-based vocabulary learning. Complete settings transitions and macOS single-item credential storage. Synchronize all application versions to 2.0.0-Beta.4+build.20260930.

Integrated PRs: #1116, #1120, #1121, #1123, #1124, #1067 and #1128. Linux #1117 remains independent and is excluded from automatic release.

Validated: full cross-platform CI, 18 UI motion cases, 112 frontend test files, 1245 Core tests (1 ignored) and 572 macOS native tests.

Co-authored-by: HKLHaoBin <we3q@qq.com>
2026-09-30 20:32:53 +08:00

941 lines
35 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.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<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
}
}