diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index 76360dfa0..c80247166 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -113,6 +113,7 @@ class SettingsRepository(private val context: Context) { const val KEY_HIDE_SYSTEM_ICONS = "hide_system_icons" const val KEY_HIDE_SYSTEM_ICONS_LOCKED_ONLY = "hide_system_icons_locked_only" const val KEY_HIDE_GESTURE_BAR_ENABLED = "hide_gesture_bar_enabled" + const val KEY_CIRCLE_TO_SEARCH_GESTURE_ENABLED = "circle_to_search_gesture_enabled" const val KEY_AUTO_UPDATE_ENABLED = "auto_update_enabled" const val KEY_UPDATE_NOTIFICATION_ENABLED = "update_notification_enabled" const val KEY_LAST_UPDATE_CHECK_TIME = "last_update_check_time" diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/ButtonRemapHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/ButtonRemapHandler.kt index d16b65ef5..b483194b9 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/ButtonRemapHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/ButtonRemapHandler.kt @@ -197,11 +197,18 @@ class ButtonRemapHandler( "Take screenshot" -> takeScreenshot() "Cycle sound modes" -> cycleSoundModes() "Toggle media volume" -> toggleMediaVolume() - "Like current song" -> service.sendBroadcast( - Intent("com.sameerasw.essentials.ACTION_LIKE_CURRENT_SONG").setPackage( - service.packageName + "Like current song" -> { + service.sendBroadcast( + Intent("com.sameerasw.essentials.ACTION_LIKE_CURRENT_SONG").setPackage( + service.packageName + ) ) - ) + triggerHapticFeedback() + } + "Circle to Search" -> { + com.sameerasw.essentials.utils.OmniTriggerUtil.trigger(service) + triggerHapticFeedback() + } } } diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/OmniGestureOverlayHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/OmniGestureOverlayHandler.kt new file mode 100644 index 000000000..888398ac9 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/OmniGestureOverlayHandler.kt @@ -0,0 +1,155 @@ +package com.sameerasw.essentials.services.handlers + +import android.accessibilityservice.AccessibilityService +import android.content.Context +import android.graphics.Color +import android.graphics.PixelFormat +import android.os.* +import android.view.* +import com.sameerasw.essentials.utils.OmniTriggerUtil + +class OmniGestureOverlayHandler(private val service: AccessibilityService) { + private val windowManager = service.getSystemService(Context.WINDOW_SERVICE) as WindowManager + private val vibrator = getVibratorInstance() + + private var overlayView: View? = null + private val handler = Handler(Looper.getMainLooper()) + private val touchSlop = ViewConfiguration.get(service).scaledTouchSlop + + private var startX = 0f + private var startY = 0f + private var isLongPressActive = false + + private val longPressRunnable = Runnable { + isLongPressActive = false + OmniTriggerUtil.trigger(service) + triggerFinalTick() + } + + private val fallbackEffect: VibrationEffect? by lazy { + val segments = 25 + val timings = LongArray(segments) { 20L } + val amplitudes = IntArray(segments) { i -> + val progress = (i + 1).toFloat() / segments + val curve = progress * progress + (3 + (57 * curve)).toInt().coerceAtMost(60) + } + runCatching { VibrationEffect.createWaveform(timings, amplitudes, -1) }.getOrNull() + } + + fun updateOverlay(enabled: Boolean) { + handler.post { + if (enabled) showOverlay() else removeOverlay() + } + } + + private fun showOverlay() { + if (overlayView != null) return + + overlayView = View(service).apply { + setBackgroundColor(Color.TRANSPARENT) + setOnTouchListener { _, event -> + handleTouch(event) + true + } + } + + val params = WindowManager.LayoutParams( + dpToPx(WIDTH_DP), + dpToPx(HEIGHT_DP), + WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or + WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or + WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, + PixelFormat.TRANSLUCENT + ).apply { + gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + } + } + + runCatching { windowManager.addView(overlayView, params) } + } + + private fun handleTouch(event: MotionEvent) { + when (event.action) { + MotionEvent.ACTION_DOWN -> { + startX = event.x + startY = event.y + isLongPressActive = true + handler.postDelayed(longPressRunnable, LONG_PRESS_TIMEOUT) + startRampingHaptic() + } + MotionEvent.ACTION_MOVE -> { + if (Math.abs(event.x - startX) > touchSlop || Math.abs(event.y - startY) > touchSlop) { + cancelLongPress() + } + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> cancelLongPress() + } + } + + private fun cancelLongPress() { + if (!isLongPressActive) return + isLongPressActive = false + handler.removeCallbacks(longPressRunnable) + vibrator?.cancel() + } + + fun removeOverlay() { + cancelLongPress() + overlayView?.let { + runCatching { windowManager.removeView(it) } + overlayView = null + } + } + + private fun startRampingHaptic() { + val v = vibrator ?: return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + runCatching { + val effect = VibrationEffect.startComposition() + .addPrimitive(VibrationEffect.Composition.PRIMITIVE_SLOW_RISE, 0.6f) + .compose() + v.vibrate(effect) + }.onFailure { fallbackRampingWaveform(v) } + } else { + fallbackRampingWaveform(v) + } + } + + private fun fallbackRampingWaveform(v: Vibrator) { + fallbackEffect?.let { + runCatching { v.vibrate(it) } + } ?: v.vibrate(VibrationEffect.createOneShot(LONG_PRESS_TIMEOUT, VibrationEffect.DEFAULT_AMPLITUDE)) + } + + private fun triggerFinalTick() { + val v = vibrator ?: return + runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + v.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK)) + } else { + v.vibrate(VibrationEffect.createOneShot(30, 180)) + } + } + } + + private fun getVibratorInstance(): Vibrator? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + service.getSystemService(VibratorManager::class.java)?.defaultVibrator + } else { + @Suppress("DEPRECATION") + service.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator + } + } + + private fun dpToPx(dp: Float) = (dp * service.resources.displayMetrics.density).toInt() + + companion object { + private const val LONG_PRESS_TIMEOUT = 500L + private const val WIDTH_DP = 240f + private const val HEIGHT_DP = 48f + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt index 5fd479c5a..c8f620ea6 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt @@ -18,13 +18,7 @@ import android.view.accessibility.AccessibilityEvent import com.sameerasw.essentials.data.repository.SettingsRepository import com.sameerasw.essentials.domain.HapticFeedbackType import com.sameerasw.essentials.services.InputEventListenerService -import com.sameerasw.essentials.services.handlers.AmbientGlanceHandler -import com.sameerasw.essentials.services.handlers.AodForceTurnOffHandler -import com.sameerasw.essentials.services.handlers.AppFlowHandler -import com.sameerasw.essentials.services.handlers.ButtonRemapHandler -import com.sameerasw.essentials.services.handlers.FlashlightHandler -import com.sameerasw.essentials.services.handlers.NotificationLightingHandler -import com.sameerasw.essentials.services.handlers.SecurityHandler +import com.sameerasw.essentials.services.handlers.* import com.sameerasw.essentials.services.receivers.FlashlightActionReceiver import com.sameerasw.essentials.utils.FreezeManager import com.sameerasw.essentials.utils.performHapticFeedback @@ -45,6 +39,7 @@ class ScreenOffAccessibilityService : AccessibilityService(), SensorEventListene private lateinit var securityHandler: SecurityHandler private lateinit var ambientGlanceHandler: AmbientGlanceHandler private lateinit var aodForceTurnOffHandler: AodForceTurnOffHandler + private lateinit var omniGestureOverlayHandler: OmniGestureOverlayHandler private var screenReceiver: BroadcastReceiver? = null @@ -58,6 +53,13 @@ class ScreenOffAccessibilityService : AccessibilityService(), SensorEventListene FreezeManager.freezeAll(this) } + private val preferenceChangeListener = + android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == "circle_to_search_gesture_enabled" || key == "hide_gesture_bar_enabled") { + updateOmniOverlay() + } + } + override fun onCreate() { super.onCreate() @@ -69,6 +71,7 @@ class ScreenOffAccessibilityService : AccessibilityService(), SensorEventListene securityHandler = SecurityHandler(this) ambientGlanceHandler = AmbientGlanceHandler(this) aodForceTurnOffHandler = AodForceTurnOffHandler(this) + omniGestureOverlayHandler = OmniGestureOverlayHandler(this) flashlightHandler.register() @@ -82,6 +85,7 @@ class ScreenOffAccessibilityService : AccessibilityService(), SensorEventListene aodForceTurnOffHandler.removeOverlay() freezeHandler.removeCallbacks(freezeRunnable) stopInputEventListener() + updateOmniOverlay() } Intent.ACTION_SCREEN_OFF -> { @@ -89,6 +93,7 @@ class ScreenOffAccessibilityService : AccessibilityService(), SensorEventListene scheduleFreeze() startInputEventListenerIfEnabled() ambientGlanceHandler.checkAndShowOnScreenOff() + omniGestureOverlayHandler.updateOverlay(false) // Always hide when screen is off } Intent.ACTION_USER_PRESENT -> { @@ -124,6 +129,9 @@ class ScreenOffAccessibilityService : AccessibilityService(), SensorEventListene proximitySensor?.let { sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_NORMAL) } + + getSharedPreferences("essentials_prefs", MODE_PRIVATE) + .registerOnSharedPreferenceChangeListener(preferenceChangeListener) } private fun scheduleFreeze() { @@ -152,6 +160,14 @@ class ScreenOffAccessibilityService : AccessibilityService(), SensorEventListene serviceInfo = serviceInfo.apply { flags = flags or AccessibilityServiceInfo.FLAG_REQUEST_FILTER_KEY_EVENTS } + updateOmniOverlay() + } + + private fun updateOmniOverlay() { + val prefs = getSharedPreferences("essentials_prefs", MODE_PRIVATE) + val isHideBarEnabled = prefs.getBoolean("hide_gesture_bar_enabled", false) + val isGestureEnabled = prefs.getBoolean("circle_to_search_gesture_enabled", false) + omniGestureOverlayHandler.updateOverlay(isHideBarEnabled && isGestureEnabled) } override fun onDestroy() { @@ -165,8 +181,11 @@ class ScreenOffAccessibilityService : AccessibilityService(), SensorEventListene notificationLightingHandler.removeOverlay() ambientGlanceHandler.removeOverlay() aodForceTurnOffHandler.removeOverlay() + omniGestureOverlayHandler.removeOverlay() stopInputEventListener() serviceScope.cancel() + getSharedPreferences("essentials_prefs", MODE_PRIVATE) + .unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) super.onDestroy() } @@ -200,6 +219,11 @@ class ScreenOffAccessibilityService : AccessibilityService(), SensorEventListene override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {} + override fun onConfigurationChanged(newConfig: android.content.res.Configuration) { + super.onConfigurationChanged(newConfig) + updateOmniOverlay() // Force refresh overlay on rotation + } + override fun onKeyEvent(event: KeyEvent): Boolean { val keyCode = event.keyCode val isVolumeKey = keyCode == KeyEvent.KEYCODE_VOLUME_UP || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/ButtonRemapSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/ButtonRemapSettingsUI.kt index 99256f5a2..b811fd1a1 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/ButtonRemapSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/ButtonRemapSettingsUI.kt @@ -421,6 +421,12 @@ fun ButtonRemapSettingsUI( hasSettings = true, onSettingsClick = { showLikeSongOptions.value = true } ) + RemapActionItem( + title = stringResource(R.string.action_circle_to_search), + isSelected = currentAction == "Circle to Search", + onClick = { onActionSelected("Circle to Search") }, + iconRes = R.drawable.frame_inspect_24px, + ) if (selectedScreenTab == 1) { RemapActionItem( title = stringResource(R.string.action_take_screenshot), diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/OtherCustomizationsSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/OtherCustomizationsSettingsUI.kt index 9d6b276a2..fdea93656 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/OtherCustomizationsSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/OtherCustomizationsSettingsUI.kt @@ -105,6 +105,18 @@ fun OtherCustomizationsSettingsUI( iconRes = R.drawable.rounded_home_24, modifier = Modifier.highlight(highlightSetting == "hide_gesture_bar_toggle") ) + + IconToggleItem( + title = stringResource(R.string.feat_circle_to_search_gesture_title), + description = stringResource(R.string.feat_circle_to_search_gesture_desc), + isChecked = viewModel.isCircleToSearchGestureEnabled.value, + onCheckedChange = { enabled -> + viewModel.setCircleToSearchGestureEnabled(enabled, context) + }, + enabled = viewModel.isHideGestureBarEnabled.value, + iconRes = R.drawable.rounded_touch_app_24, + modifier = Modifier.highlight(highlightSetting == "circle_to_search_gesture_toggle") + ) } } } diff --git a/app/src/main/java/com/sameerasw/essentials/utils/OmniTriggerUtil.kt b/app/src/main/java/com/sameerasw/essentials/utils/OmniTriggerUtil.kt new file mode 100644 index 000000000..17359f274 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/utils/OmniTriggerUtil.kt @@ -0,0 +1,81 @@ +package com.sameerasw.essentials.utils + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.os.Bundle +import android.os.IBinder +import android.os.SystemClock +import android.util.Log +import androidx.annotation.RequiresApi +import com.sameerasw.essentials.shizuku.ShizukuPermissionHelper +import org.lsposed.hiddenapibypass.HiddenApiBypass +import java.lang.reflect.Method + +object OmniTriggerUtil { + private const val TAG = "OmniTriggerUtil" + + private var iVimsClass: Class<*>? = null + private var vimsInterfaceMethod: Method? = null + private var serviceManagerClass: Class<*>? = null + private var getServiceMethod: Method? = null + + @SuppressLint("PrivateApi") + private fun ensureReflection() { + if (iVimsClass != null) return + runCatching { + iVimsClass = Class.forName("com.android.internal.app.IVoiceInteractionManagerService") + vimsInterfaceMethod = Class.forName("com.android.internal.app.IVoiceInteractionManagerService\$Stub") + .getMethod("asInterface", IBinder::class.java) + serviceManagerClass = Class.forName("android.os.ServiceManager") + getServiceMethod = serviceManagerClass?.getMethod("getService", String::class.java) + } + } + + fun trigger(context: Context): Boolean { + ensureReflection() + + val bundle = Bundle().apply { + putLong("invocation_time_ms", SystemClock.elapsedRealtime()) + putInt("omni.entry_point", 1) + putBoolean("micts_trigger", true) + } + + // 1. Try Shizuku logic + val shizukuHelper = ShizukuPermissionHelper(context) + if (shizukuHelper.isReady() && shizukuHelper.hasPermission()) { + val result = runCatching { + val vis = ShizukuUtils.getSystemBinder("voiceinteraction") + val vims = vimsInterfaceMethod?.invoke(null, vis) + val clazz = iVimsClass + if (vims != null && clazz != null) { + invokeShowSession(clazz, vims, bundle) + } else false + }.getOrDefault(false) + + if (result) return true + } + + // 2. Fallback to Non-Root Reflection + return runCatching { + val vis = getServiceMethod?.invoke(null, "voiceinteraction") as IBinder? + val vims = vimsInterfaceMethod?.invoke(null, vis) + val clazz = iVimsClass + if (vims != null && clazz != null) { + invokeShowSession(clazz, vims, bundle) + } else false + }.onFailure { e -> + Log.e(TAG, "Trigger failed", e) + }.getOrDefault(false) + } + + private fun invokeShowSession(clazz: Class<*>, vims: Any, bundle: Bundle): Boolean { + return runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + HiddenApiBypass.invoke(clazz, vims, "showSessionFromSession", null, bundle, 7, "hyperOS_home") as Boolean? ?: false + } else { + HiddenApiBypass.invoke(clazz, vims, "showSessionFromSession", null, bundle, 7) as Boolean? ?: false + } + }.getOrDefault(false) + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index 32f06d4bc..dae097b66 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -133,6 +133,7 @@ class MainViewModel : ViewModel() { val dnsPresets = mutableStateListOf() val addedQSTiles = mutableStateOf>(emptySet()) val isHideGestureBarEnabled = mutableStateOf(false) + val isCircleToSearchGestureEnabled = mutableStateOf(false) @@ -486,6 +487,10 @@ class MainViewModel : ViewModel() { SettingsRepository.KEY_FLASHLIGHT_PULSE_MAX_INTENSITY -> { flashlightPulseMaxIntensity.floatValue = settingsRepository.getFloat(key, 0.5f) } + + SettingsRepository.KEY_CIRCLE_TO_SEARCH_GESTURE_ENABLED -> { + isCircleToSearchGestureEnabled.value = settingsRepository.getBoolean(key) + } } } } @@ -520,6 +525,7 @@ class MainViewModel : ViewModel() { isShizukuPermissionGranted.value = ShizukuUtils.hasPermission() isAutoAccessibilityEnabled.value = settingsRepository.getBoolean(SettingsRepository.KEY_AUTO_ACCESSIBILITY_ENABLED) isHideGestureBarEnabled.value = settingsRepository.getBoolean(SettingsRepository.KEY_HIDE_GESTURE_BAR_ENABLED, false) + isCircleToSearchGestureEnabled.value = settingsRepository.getBoolean(SettingsRepository.KEY_CIRCLE_TO_SEARCH_GESTURE_ENABLED, false) notificationLightingSystemMode.intValue = settingsRepository.getNotificationLightingSystemMode() if (isHideGestureBarEnabled.value) { applyHideGestureBar(context, true) @@ -1261,6 +1267,11 @@ class MainViewModel : ViewModel() { applyHideGestureBar(context, enabled) } + fun setCircleToSearchGestureEnabled(enabled: Boolean, context: Context) { + isCircleToSearchGestureEnabled.value = enabled + settingsRepository.putBoolean(SettingsRepository.KEY_CIRCLE_TO_SEARCH_GESTURE_ENABLED, enabled) + } + private fun applyHideGestureBar(context: Context, enabled: Boolean) { if (enabled) { com.sameerasw.essentials.utils.StatusBarManager.requestDisable( diff --git a/app/src/main/res/drawable/frame_inspect_24px.xml b/app/src/main/res/drawable/frame_inspect_24px.xml new file mode 100644 index 000000000..eddca4fc2 --- /dev/null +++ b/app/src/main/res/drawable/frame_inspect_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e485cf29c..7eb1837c6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -55,6 +55,7 @@ AI assistant Take screenshot Cycle sound modes + Circle to Search Like current song Like song settings This feature requires notification access to detect the currently playing media and trigger the like action. Please enable it below. @@ -350,9 +351,11 @@ Hide notifications Hide all incoming notification icons Hide gesture bar + Hide the navigation pill at the bottom + Circle to Search gesture + Long-press the bottom area to trigger Circle to Search Other customizations Additional system tweaks and modifications - In gesture navigation Please note that the implementation of these options may depend on the OEM and some may not be functional at all.