Skip to content

Commit 2710ef4

Browse files
authored
Develop - Circle to search (#348)
This pull request introduces a new "Circle to Search" gesture feature, allowing users to trigger the Google "Circle to Search" action via a long-press gesture overlay or as a remappable button action. The implementation includes UI options, persistent settings, gesture handling, and system integration. **Feature: Circle to Search Gesture** * Added a persistent setting key `circle_to_search_gesture_enabled` to `SettingsRepository` to store user preference for enabling the gesture. * Introduced UI toggle in `OtherCustomizationsSettingsUI` for enabling/disabling the "Circle to Search" gesture, which is only active if the gesture bar is hidden. * Updated `MainViewModel` to manage the state of the new gesture setting and keep it in sync with preferences. [[1]](diffhunk://#diff-7206d3db36c3aca0462993b0a3d76694ccfeb5c838f0545ec55dd0883e4d1669R136) [[2]](diffhunk://#diff-7206d3db36c3aca0462993b0a3d76694ccfeb5c838f0545ec55dd0883e4d1669R490-R493) [[3]](diffhunk://#diff-7206d3db36c3aca0462993b0a3d76694ccfeb5c838f0545ec55dd0883e4d1669R528) **Gesture Overlay Implementation** * Added `OmniGestureOverlayHandler`, a new class that displays an overlay for the gesture, handles long-press detection, and provides haptic feedback. The overlay is shown/hidden based on the relevant settings and device state. * Integrated the overlay handler into `ScreenOffAccessibilityService`, including lifecycle management, configuration change handling, and reacting to preference changes. [[1]](diffhunk://#diff-ab4ea5a51ba8ccf8cc20e4f0f32b59b50ba9aa425d69f6d4fb6215ddef315b08L21-R21) [[2]](diffhunk://#diff-ab4ea5a51ba8ccf8cc20e4f0f32b59b50ba9aa425d69f6d4fb6215ddef315b08R42) [[3]](diffhunk://#diff-ab4ea5a51ba8ccf8cc20e4f0f32b59b50ba9aa425d69f6d4fb6215ddef315b08R56-R62) [[4]](diffhunk://#diff-ab4ea5a51ba8ccf8cc20e4f0f32b59b50ba9aa425d69f6d4fb6215ddef315b08R74) [[5]](diffhunk://#diff-ab4ea5a51ba8ccf8cc20e4f0f32b59b50ba9aa425d69f6d4fb6215ddef315b08R88-R96) [[6]](diffhunk://#diff-ab4ea5a51ba8ccf8cc20e4f0f32b59b50ba9aa425d69f6d4fb6215ddef315b08R132-R134) [[7]](diffhunk://#diff-ab4ea5a51ba8ccf8cc20e4f0f32b59b50ba9aa425d69f6d4fb6215ddef315b08R163-R170) [[8]](diffhunk://#diff-ab4ea5a51ba8ccf8cc20e4f0f32b59b50ba9aa425d69f6d4fb6215ddef315b08R184-R188) [[9]](diffhunk://#diff-ab4ea5a51ba8ccf8cc20e4f0f32b59b50ba9aa425d69f6d4fb6215ddef315b08R222-R226) **Remappable Action Support** * Added "Circle to Search" as a remappable action in the button remap UI, and updated `ButtonRemapHandler` to support triggering the gesture (with haptic feedback) when this action is selected. [[1]](diffhunk://#diff-1693a4833f938163e799bccd625f4706231fb893d85d02b25652bb7dbea7d76eR424-R429) [[2]](diffhunk://#diff-2aae72ac57237bc7a4b107c2a3537ec0a2534457a42e1fb3f4622274c5b0bf25L200-R211) **System Integration Utility** * Implemented `OmniTriggerUtil`, a utility for triggering the Google "Circle to Search" session using reflection and Shizuku, handling both root and non-root scenarios.
2 parents e0d3b63 + dd11cf5 commit 2710ef4

File tree

10 files changed

+322
-12
lines changed

10 files changed

+322
-12
lines changed

app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ class SettingsRepository(private val context: Context) {
113113
const val KEY_HIDE_SYSTEM_ICONS = "hide_system_icons"
114114
const val KEY_HIDE_SYSTEM_ICONS_LOCKED_ONLY = "hide_system_icons_locked_only"
115115
const val KEY_HIDE_GESTURE_BAR_ENABLED = "hide_gesture_bar_enabled"
116+
const val KEY_CIRCLE_TO_SEARCH_GESTURE_ENABLED = "circle_to_search_gesture_enabled"
116117
const val KEY_AUTO_UPDATE_ENABLED = "auto_update_enabled"
117118
const val KEY_UPDATE_NOTIFICATION_ENABLED = "update_notification_enabled"
118119
const val KEY_LAST_UPDATE_CHECK_TIME = "last_update_check_time"

app/src/main/java/com/sameerasw/essentials/services/handlers/ButtonRemapHandler.kt

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -197,11 +197,18 @@ class ButtonRemapHandler(
197197
"Take screenshot" -> takeScreenshot()
198198
"Cycle sound modes" -> cycleSoundModes()
199199
"Toggle media volume" -> toggleMediaVolume()
200-
"Like current song" -> service.sendBroadcast(
201-
Intent("com.sameerasw.essentials.ACTION_LIKE_CURRENT_SONG").setPackage(
202-
service.packageName
200+
"Like current song" -> {
201+
service.sendBroadcast(
202+
Intent("com.sameerasw.essentials.ACTION_LIKE_CURRENT_SONG").setPackage(
203+
service.packageName
204+
)
203205
)
204-
)
206+
triggerHapticFeedback()
207+
}
208+
"Circle to Search" -> {
209+
com.sameerasw.essentials.utils.OmniTriggerUtil.trigger(service)
210+
triggerHapticFeedback()
211+
}
205212
}
206213
}
207214

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package com.sameerasw.essentials.services.handlers
2+
3+
import android.accessibilityservice.AccessibilityService
4+
import android.content.Context
5+
import android.graphics.Color
6+
import android.graphics.PixelFormat
7+
import android.os.*
8+
import android.view.*
9+
import com.sameerasw.essentials.utils.OmniTriggerUtil
10+
11+
class OmniGestureOverlayHandler(private val service: AccessibilityService) {
12+
private val windowManager = service.getSystemService(Context.WINDOW_SERVICE) as WindowManager
13+
private val vibrator = getVibratorInstance()
14+
15+
private var overlayView: View? = null
16+
private val handler = Handler(Looper.getMainLooper())
17+
private val touchSlop = ViewConfiguration.get(service).scaledTouchSlop
18+
19+
private var startX = 0f
20+
private var startY = 0f
21+
private var isLongPressActive = false
22+
23+
private val longPressRunnable = Runnable {
24+
isLongPressActive = false
25+
OmniTriggerUtil.trigger(service)
26+
triggerFinalTick()
27+
}
28+
29+
private val fallbackEffect: VibrationEffect? by lazy {
30+
val segments = 25
31+
val timings = LongArray(segments) { 20L }
32+
val amplitudes = IntArray(segments) { i ->
33+
val progress = (i + 1).toFloat() / segments
34+
val curve = progress * progress
35+
(3 + (57 * curve)).toInt().coerceAtMost(60)
36+
}
37+
runCatching { VibrationEffect.createWaveform(timings, amplitudes, -1) }.getOrNull()
38+
}
39+
40+
fun updateOverlay(enabled: Boolean) {
41+
handler.post {
42+
if (enabled) showOverlay() else removeOverlay()
43+
}
44+
}
45+
46+
private fun showOverlay() {
47+
if (overlayView != null) return
48+
49+
overlayView = View(service).apply {
50+
setBackgroundColor(Color.TRANSPARENT)
51+
setOnTouchListener { _, event ->
52+
handleTouch(event)
53+
true
54+
}
55+
}
56+
57+
val params = WindowManager.LayoutParams(
58+
dpToPx(WIDTH_DP),
59+
dpToPx(HEIGHT_DP),
60+
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
61+
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
62+
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
63+
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
64+
PixelFormat.TRANSLUCENT
65+
).apply {
66+
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
67+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
68+
layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
69+
}
70+
}
71+
72+
runCatching { windowManager.addView(overlayView, params) }
73+
}
74+
75+
private fun handleTouch(event: MotionEvent) {
76+
when (event.action) {
77+
MotionEvent.ACTION_DOWN -> {
78+
startX = event.x
79+
startY = event.y
80+
isLongPressActive = true
81+
handler.postDelayed(longPressRunnable, LONG_PRESS_TIMEOUT)
82+
startRampingHaptic()
83+
}
84+
MotionEvent.ACTION_MOVE -> {
85+
if (Math.abs(event.x - startX) > touchSlop || Math.abs(event.y - startY) > touchSlop) {
86+
cancelLongPress()
87+
}
88+
}
89+
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> cancelLongPress()
90+
}
91+
}
92+
93+
private fun cancelLongPress() {
94+
if (!isLongPressActive) return
95+
isLongPressActive = false
96+
handler.removeCallbacks(longPressRunnable)
97+
vibrator?.cancel()
98+
}
99+
100+
fun removeOverlay() {
101+
cancelLongPress()
102+
overlayView?.let {
103+
runCatching { windowManager.removeView(it) }
104+
overlayView = null
105+
}
106+
}
107+
108+
private fun startRampingHaptic() {
109+
val v = vibrator ?: return
110+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
111+
runCatching {
112+
val effect = VibrationEffect.startComposition()
113+
.addPrimitive(VibrationEffect.Composition.PRIMITIVE_SLOW_RISE, 0.6f)
114+
.compose()
115+
v.vibrate(effect)
116+
}.onFailure { fallbackRampingWaveform(v) }
117+
} else {
118+
fallbackRampingWaveform(v)
119+
}
120+
}
121+
122+
private fun fallbackRampingWaveform(v: Vibrator) {
123+
fallbackEffect?.let {
124+
runCatching { v.vibrate(it) }
125+
} ?: v.vibrate(VibrationEffect.createOneShot(LONG_PRESS_TIMEOUT, VibrationEffect.DEFAULT_AMPLITUDE))
126+
}
127+
128+
private fun triggerFinalTick() {
129+
val v = vibrator ?: return
130+
runCatching {
131+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
132+
v.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK))
133+
} else {
134+
v.vibrate(VibrationEffect.createOneShot(30, 180))
135+
}
136+
}
137+
}
138+
139+
private fun getVibratorInstance(): Vibrator? {
140+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
141+
service.getSystemService(VibratorManager::class.java)?.defaultVibrator
142+
} else {
143+
@Suppress("DEPRECATION")
144+
service.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
145+
}
146+
}
147+
148+
private fun dpToPx(dp: Float) = (dp * service.resources.displayMetrics.density).toInt()
149+
150+
companion object {
151+
private const val LONG_PRESS_TIMEOUT = 500L
152+
private const val WIDTH_DP = 240f
153+
private const val HEIGHT_DP = 48f
154+
}
155+
}

app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,7 @@ import android.view.accessibility.AccessibilityEvent
1818
import com.sameerasw.essentials.data.repository.SettingsRepository
1919
import com.sameerasw.essentials.domain.HapticFeedbackType
2020
import com.sameerasw.essentials.services.InputEventListenerService
21-
import com.sameerasw.essentials.services.handlers.AmbientGlanceHandler
22-
import com.sameerasw.essentials.services.handlers.AodForceTurnOffHandler
23-
import com.sameerasw.essentials.services.handlers.AppFlowHandler
24-
import com.sameerasw.essentials.services.handlers.ButtonRemapHandler
25-
import com.sameerasw.essentials.services.handlers.FlashlightHandler
26-
import com.sameerasw.essentials.services.handlers.NotificationLightingHandler
27-
import com.sameerasw.essentials.services.handlers.SecurityHandler
21+
import com.sameerasw.essentials.services.handlers.*
2822
import com.sameerasw.essentials.services.receivers.FlashlightActionReceiver
2923
import com.sameerasw.essentials.utils.FreezeManager
3024
import com.sameerasw.essentials.utils.performHapticFeedback
@@ -45,6 +39,7 @@ class ScreenOffAccessibilityService : AccessibilityService(), SensorEventListene
4539
private lateinit var securityHandler: SecurityHandler
4640
private lateinit var ambientGlanceHandler: AmbientGlanceHandler
4741
private lateinit var aodForceTurnOffHandler: AodForceTurnOffHandler
42+
private lateinit var omniGestureOverlayHandler: OmniGestureOverlayHandler
4843

4944
private var screenReceiver: BroadcastReceiver? = null
5045

@@ -58,6 +53,13 @@ class ScreenOffAccessibilityService : AccessibilityService(), SensorEventListene
5853
FreezeManager.freezeAll(this)
5954
}
6055

56+
private val preferenceChangeListener =
57+
android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
58+
if (key == "circle_to_search_gesture_enabled" || key == "hide_gesture_bar_enabled") {
59+
updateOmniOverlay()
60+
}
61+
}
62+
6163
override fun onCreate() {
6264
super.onCreate()
6365

@@ -69,6 +71,7 @@ class ScreenOffAccessibilityService : AccessibilityService(), SensorEventListene
6971
securityHandler = SecurityHandler(this)
7072
ambientGlanceHandler = AmbientGlanceHandler(this)
7173
aodForceTurnOffHandler = AodForceTurnOffHandler(this)
74+
omniGestureOverlayHandler = OmniGestureOverlayHandler(this)
7275

7376
flashlightHandler.register()
7477

@@ -82,13 +85,15 @@ class ScreenOffAccessibilityService : AccessibilityService(), SensorEventListene
8285
aodForceTurnOffHandler.removeOverlay()
8386
freezeHandler.removeCallbacks(freezeRunnable)
8487
stopInputEventListener()
88+
updateOmniOverlay()
8589
}
8690

8791
Intent.ACTION_SCREEN_OFF -> {
8892
appFlowHandler.clearAuthenticated()
8993
scheduleFreeze()
9094
startInputEventListenerIfEnabled()
9195
ambientGlanceHandler.checkAndShowOnScreenOff()
96+
omniGestureOverlayHandler.updateOverlay(false) // Always hide when screen is off
9297
}
9398

9499
Intent.ACTION_USER_PRESENT -> {
@@ -124,6 +129,9 @@ class ScreenOffAccessibilityService : AccessibilityService(), SensorEventListene
124129
proximitySensor?.let {
125130
sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_NORMAL)
126131
}
132+
133+
getSharedPreferences("essentials_prefs", MODE_PRIVATE)
134+
.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
127135
}
128136

129137
private fun scheduleFreeze() {
@@ -152,6 +160,14 @@ class ScreenOffAccessibilityService : AccessibilityService(), SensorEventListene
152160
serviceInfo = serviceInfo.apply {
153161
flags = flags or AccessibilityServiceInfo.FLAG_REQUEST_FILTER_KEY_EVENTS
154162
}
163+
updateOmniOverlay()
164+
}
165+
166+
private fun updateOmniOverlay() {
167+
val prefs = getSharedPreferences("essentials_prefs", MODE_PRIVATE)
168+
val isHideBarEnabled = prefs.getBoolean("hide_gesture_bar_enabled", false)
169+
val isGestureEnabled = prefs.getBoolean("circle_to_search_gesture_enabled", false)
170+
omniGestureOverlayHandler.updateOverlay(isHideBarEnabled && isGestureEnabled)
155171
}
156172

157173
override fun onDestroy() {
@@ -165,8 +181,11 @@ class ScreenOffAccessibilityService : AccessibilityService(), SensorEventListene
165181
notificationLightingHandler.removeOverlay()
166182
ambientGlanceHandler.removeOverlay()
167183
aodForceTurnOffHandler.removeOverlay()
184+
omniGestureOverlayHandler.removeOverlay()
168185
stopInputEventListener()
169186
serviceScope.cancel()
187+
getSharedPreferences("essentials_prefs", MODE_PRIVATE)
188+
.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
170189
super.onDestroy()
171190
}
172191

@@ -200,6 +219,11 @@ class ScreenOffAccessibilityService : AccessibilityService(), SensorEventListene
200219

201220
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
202221

222+
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
223+
super.onConfigurationChanged(newConfig)
224+
updateOmniOverlay() // Force refresh overlay on rotation
225+
}
226+
203227
override fun onKeyEvent(event: KeyEvent): Boolean {
204228
val keyCode = event.keyCode
205229
val isVolumeKey = keyCode == KeyEvent.KEYCODE_VOLUME_UP || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN

app/src/main/java/com/sameerasw/essentials/ui/composables/configs/ButtonRemapSettingsUI.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,12 @@ fun ButtonRemapSettingsUI(
421421
hasSettings = true,
422422
onSettingsClick = { showLikeSongOptions.value = true }
423423
)
424+
RemapActionItem(
425+
title = stringResource(R.string.action_circle_to_search),
426+
isSelected = currentAction == "Circle to Search",
427+
onClick = { onActionSelected("Circle to Search") },
428+
iconRes = R.drawable.frame_inspect_24px,
429+
)
424430
if (selectedScreenTab == 1) {
425431
RemapActionItem(
426432
title = stringResource(R.string.action_take_screenshot),

app/src/main/java/com/sameerasw/essentials/ui/composables/configs/OtherCustomizationsSettingsUI.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,18 @@ fun OtherCustomizationsSettingsUI(
105105
iconRes = R.drawable.rounded_home_24,
106106
modifier = Modifier.highlight(highlightSetting == "hide_gesture_bar_toggle")
107107
)
108+
109+
IconToggleItem(
110+
title = stringResource(R.string.feat_circle_to_search_gesture_title),
111+
description = stringResource(R.string.feat_circle_to_search_gesture_desc),
112+
isChecked = viewModel.isCircleToSearchGestureEnabled.value,
113+
onCheckedChange = { enabled ->
114+
viewModel.setCircleToSearchGestureEnabled(enabled, context)
115+
},
116+
enabled = viewModel.isHideGestureBarEnabled.value,
117+
iconRes = R.drawable.rounded_touch_app_24,
118+
modifier = Modifier.highlight(highlightSetting == "circle_to_search_gesture_toggle")
119+
)
108120
}
109121
}
110122
}

0 commit comments

Comments
 (0)