diff --git a/MotionEventMod/build.gradle.kts b/MotionEventMod/build.gradle.kts index f795483..eb3db92 100644 --- a/MotionEventMod/build.gradle.kts +++ b/MotionEventMod/build.gradle.kts @@ -1,17 +1,22 @@ plugins { alias(libs.plugins.buildlogic.android.application) + alias(libs.plugins.buildlogic.kotlin.android) } android { - val packageName = "com.programminghoch10.MotionEventMod" - namespace = packageName + namespace = "com.programminghoch10.MotionEventMod" defaultConfig { - applicationId = packageName minSdk = 14 - targetSdk = 33 + targetSdk = 37 + buildConfigField("String", "SHARED_PREFERENCES_NAME", "\"MotionEventMod\"") + buildConfigField("String", "TAG", "\"${namespace!!.split(".").last()}\"") } buildFeatures { buildConfig = true } } + +dependencies { + implementation(libs.androidx.preference.ktx) +} diff --git a/MotionEventMod/src/main/AndroidManifest.xml b/MotionEventMod/src/main/AndroidManifest.xml index e64c5c4..0434c65 100644 --- a/MotionEventMod/src/main/AndroidManifest.xml +++ b/MotionEventMod/src/main/AndroidManifest.xml @@ -4,8 +4,21 @@ + + + + + + 0) supportFragmentManager.popBackStack() + else finish() + return true + } + + class SettingsFragment : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + preferenceManager.sharedPreferencesName = SHARED_PREFERENCES_NAME + preferenceManager.sharedPreferencesMode = MODE_WORLD_READABLE + setPreferencesFromResource(R.xml.root_preferences, rootKey) + + val disableTouchTimeoutPreference = findPreference("disableTouchTimeout")!! + val disableTouchDuringPenPreference = findPreference("disableTouchDuringPen")!! + val disableTouchDuringHoverPreference = findPreference("disableTouchDuringHover")!! + val replayOngoingEventsPreference = findPreference("replayOngoingEvents")!! + + fun recalculateDependencies() { + disableTouchDuringHoverPreference.isEnabled = disableTouchDuringPenPreference.isEnabledAndChecked + disableTouchTimeoutPreference.isEnabled = + disableTouchDuringPenPreference.isEnabledAndChecked || disableTouchDuringHoverPreference.isEnabledAndChecked + replayOngoingEventsPreference.isEnabled = + disableTouchDuringPenPreference.isEnabledAndChecked || disableTouchDuringHoverPreference.isEnabledAndChecked + } + preferenceManager.sharedPreferences!!.registerOnSharedPreferenceChangeListener { _, _ -> recalculateDependencies() } + recalculateDependencies() + } + } + + class TypeSelectorFragment : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + preferenceManager.sharedPreferencesName = SHARED_PREFERENCES_NAME + preferenceManager.sharedPreferencesMode = MODE_WORLD_READABLE + preferenceScreen = preferenceManager.createPreferenceScreen(requireContext()) + (toolTypeFields + sourceClassFields + actionTypeFields + buttonFields).map { it.name }.forEach { + val preference = SwitchPreference(requireContext()) + preference.key = typeEnabledKey(it) + preference.title = it + preference.setDefaultValue(true) + preferenceScreen.addPreference(preference) + } + } + } +} + +private val TwoStatePreference.isEnabledAndChecked get() = isEnabled && isChecked diff --git a/MotionEventMod/src/main/kotlin/com/programminghoch10/MotionEventMod/TimeoutPreference.kt b/MotionEventMod/src/main/kotlin/com/programminghoch10/MotionEventMod/TimeoutPreference.kt new file mode 100644 index 0000000..b019681 --- /dev/null +++ b/MotionEventMod/src/main/kotlin/com/programminghoch10/MotionEventMod/TimeoutPreference.kt @@ -0,0 +1,46 @@ +package com.programminghoch10.MotionEventMod + +import android.app.AlertDialog +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.EditText +import android.widget.TextView +import androidx.preference.Preference + +class TimeoutPreference(context: Context, attrs: AttributeSet) : Preference(context, attrs) { + val TAG = TimeoutPreference::class.simpleName + var defaultValue = 0f + + var savedValue: Float + get() = getPersistedFloat(defaultValue) + set(value) { + persistFloat(value) + } + + override fun setDefaultValue(defaultValue: Any?) { + require(defaultValue is Float) + this.defaultValue = defaultValue + super.setDefaultValue(defaultValue) + } + + override fun getSummary(): CharSequence { + return String.format(super.summary.toString(), savedValue) + } + + override fun onClick() { + require(sharedPreferences != null) + val inflater = LayoutInflater.from(context) + val dialogView = inflater.inflate(R.layout.timeoutpreference_dialog, null) + dialogView.findViewById(android.R.id.title).text = title + val editText = dialogView.findViewById(R.id.editText) + editText.setText(savedValue.toString()) + AlertDialog.Builder(context).apply { + setView(dialogView) + }.setPositiveButton(android.R.string.ok) { _, _ -> + val result = editText.text.toString().toFloatOrNull() ?: 0f + if (callChangeListener(result)) savedValue = result + notifyChanged() + }.setNegativeButton(android.R.string.cancel, null).show() + } +} diff --git a/MotionEventMod/src/main/kotlin/com/programminghoch10/MotionEventMod/Utils.kt b/MotionEventMod/src/main/kotlin/com/programminghoch10/MotionEventMod/Utils.kt new file mode 100644 index 0000000..726f1c5 --- /dev/null +++ b/MotionEventMod/src/main/kotlin/com/programminghoch10/MotionEventMod/Utils.kt @@ -0,0 +1,34 @@ +package com.programminghoch10.MotionEventMod + +import android.os.Build +import android.os.Parcel +import android.os.Parcelable +import android.view.InputDevice +import android.view.MotionEvent + + +val toolTypeFields = MotionEvent::class.java.declaredFields.filter { it.name.startsWith("TOOL_TYPE_") && it.type == Int::class.java } +val sourceClassFields = InputDevice::class.java.declaredFields.filter { it.name.startsWith("SOURCE_CLASS_") && it.type == Int::class.java } + .filter { it.name != "SOURCE_CLASS_MASK" } +val actionTypeFields = + MotionEvent::class.java.declaredFields.filter { it.name.startsWith("ACTION_") && it.type == Int::class.java }.filter { it.name != "ACTION_MASK" } +val buttonFields = MotionEvent::class.java.declaredFields.filter { it.name.startsWith("BUTTON_") && it.type == Int::class.java } +val buttonFieldsMap = buttonFields.associate { field -> field.name to field.getInt(null) } +fun typeEnabledKey(fieldName: String): String = "${fieldName.lowercase()}_enabled" + +// thanks https://farhanpatel.dev/index.php/2020/06/14/deep-clones-with-android-parcelable/ +// slightly modified for compatibility, extension functions and nullability +fun T.deepClone(): T { + var parcel = Parcel.obtain() + parcel.writeParcelable(this, 0) + parcel.setDataPosition(0) + val clone = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + parcel.readParcelable(this::class.java.classLoader, this::class.java) + } else { + @Suppress("DEPRECATION") parcel.readParcelable(this::class.java.classLoader) + } + // it is important to recycle parcel and free up resources once done + parcel.recycle() + require(clone != null) + return clone +} diff --git a/MotionEventMod/src/main/kotlin/com/programminghoch10/MotionEventMod/XposedHook.kt b/MotionEventMod/src/main/kotlin/com/programminghoch10/MotionEventMod/XposedHook.kt new file mode 100644 index 0000000..4369cfd --- /dev/null +++ b/MotionEventMod/src/main/kotlin/com/programminghoch10/MotionEventMod/XposedHook.kt @@ -0,0 +1,184 @@ +package com.programminghoch10.MotionEventMod + +import java.lang.reflect.Method +import kotlin.math.roundToLong +import android.util.Log +import android.view.MotionEvent +import android.view.View +import com.programminghoch10.MotionEventMod.BuildConfig.APPLICATION_ID +import com.programminghoch10.MotionEventMod.BuildConfig.SHARED_PREFERENCES_NAME +import com.programminghoch10.MotionEventMod.BuildConfig.TAG +import de.robv.android.xposed.IXposedHookLoadPackage +import de.robv.android.xposed.XC_MethodHook +import de.robv.android.xposed.XC_MethodHook.MethodHookParam +import de.robv.android.xposed.XSharedPreferences +import de.robv.android.xposed.XposedBridge +import de.robv.android.xposed.XposedHelpers +import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam + +class XposedHook : IXposedHookLoadPackage { + val sharedPreferences = XSharedPreferences(APPLICATION_ID, SHARED_PREFERENCES_NAME) + val disableTouchDuringPen get() = sharedPreferences.getBoolean("disableTouchDuringPen", false) + val disableTouchDuringHover get() = disableTouchDuringPen && sharedPreferences.getBoolean("disableTouchDuringHover", false) + val markAsHandled get() = sharedPreferences.getBoolean("markAsHandled", true) + val disableTouchTimeoutMs + get() = if (disableTouchDuringPen || disableTouchDuringHover) (sharedPreferences.getFloat("disableTouchTimeout", 0f) * 1000L).roundToLong() + else 0L + val disableHover get() = sharedPreferences.getBoolean("disableHover", false) + val replayOngoingEvents get() = (disableTouchDuringPen || disableTouchDuringHover) && sharedPreferences.getBoolean("replayOngoingEvents", false) + + fun preventMotionEvent(param: MethodHookParam) = run { param.result = markAsHandled } + + private var isPenDown: Boolean = false + private var isPenHovering: Boolean = false + private var lastPenEventTimestamp: Long = 0L + private var lastHoverEventTimestamp: Long = 0L + fun isInTimeout(compareTime: Long, eventTime: Long): Boolean = + disableTouchTimeoutMs > 0 && eventTime in compareTime..>() + + fun handleTouchEvent(event: MotionEvent, param: MethodHookParam, dispatchingView: View) { + val event = event.deepClone() + require(event.getToolType() == MotionEvent.TOOL_TYPE_FINGER) + var preventTouchEvent = shouldPreventTouchEvent(event.eventTime) + val pointerId = event.getPointerId() + if (preventTouchEvent) when (event.action) { + MotionEvent.ACTION_DOWN -> motionEventStorage[pointerId] = mutableListOf(event) + MotionEvent.ACTION_MOVE -> motionEventStorage[pointerId]?.add(event) + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> motionEventStorage.remove(pointerId) + } + if (!preventTouchEvent && event.action in listOf( + MotionEvent.ACTION_MOVE, + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL, + ) && motionEventStorage.contains(pointerId) + ) { + if (replayOngoingEvents) { + Log.d(TAG, "handleTouchEvent: replaying ongoing MotionEvents size=${motionEventStorage[pointerId]?.size}") + motionEventStorage[pointerId]?.forEach { dispatchingView.dispatchTouchEventUnhooked(it) } + motionEventStorage.remove(pointerId) + } else { + //Log.d(TAG, "handleTouchEvent: prevent because started before enabled") + preventTouchEvent = true + if (event.action == MotionEvent.ACTION_UP) motionEventStorage.remove(pointerId) + } + } + if (preventTouchEvent) preventMotionEvent(param) + } + + fun handleStylusEvent(event: MotionEvent, param: MethodHookParam) { + require(event.getToolType() in listOf(MotionEvent.TOOL_TYPE_STYLUS, MotionEvent.TOOL_TYPE_ERASER)) + when (event.action) { + MotionEvent.ACTION_DOWN -> isPenDown = true + MotionEvent.ACTION_UP -> isPenDown = false + } + lastPenEventTimestamp = event.eventTime + } + + fun handleStylusHoverEvent(event: MotionEvent, param: MethodHookParam) { + require(event.getToolType() in listOf(MotionEvent.TOOL_TYPE_STYLUS, MotionEvent.TOOL_TYPE_ERASER)) + require(event.action in listOf(MotionEvent.ACTION_HOVER_ENTER, MotionEvent.ACTION_HOVER_MOVE, MotionEvent.ACTION_HOVER_EXIT)) + when (event.action) { + MotionEvent.ACTION_HOVER_ENTER -> isPenHovering = true + MotionEvent.ACTION_HOVER_EXIT -> isPenHovering = false + } + lastHoverEventTimestamp = event.eventTime + } + + fun shouldDisableMotionEventByType(event: MotionEvent): Boolean { + return listOfNotNull( + toolTypeFields to event.getToolType(), + sourceClassFields to event.source, + actionTypeFields to event.action, + ).map { it.first.associate { field -> field.getInt(null) to field.name } to it.second } + .mapNotNull { it.first[it.second] } + .map(::typeEnabledKey) + .any { !sharedPreferences.getBoolean(it, true) } + } + + override fun handleLoadPackage(lpparam: LoadPackageParam) { + if (lpparam.packageName == "android") return + if (lpparam.packageName == APPLICATION_ID) return + + XposedHelpers.findAndHookMethod( + View::class.java, + "dispatchTouchEvent", + MotionEvent::class.java, + object : XC_MethodHook() { + override fun beforeHookedMethod(param: MethodHookParam) { + val view = param.thisObject as View + val event = param.args[0] as MotionEvent + val toolType = event.getToolType() + if (shouldDisableMotionEventByType(event)) return preventMotionEvent(param) + when (toolType) { + MotionEvent.TOOL_TYPE_UNKNOWN -> return + MotionEvent.TOOL_TYPE_STYLUS, MotionEvent.TOOL_TYPE_ERASER -> handleStylusEvent(event, param) + MotionEvent.TOOL_TYPE_MOUSE -> TODO("implement mouse") + MotionEvent.TOOL_TYPE_FINGER -> handleTouchEvent(event, param, view) + } + } + }, + ) + + XposedHelpers.findAndHookMethod( + View::class.java, + "dispatchHoverEvent", + MotionEvent::class.java, + object : XC_MethodHook() { + override fun beforeHookedMethod(param: MethodHookParam) { + val event = param.args[0] as MotionEvent + if (shouldDisableMotionEventByType(event)) return preventMotionEvent(param) + if (disableHover) return preventMotionEvent(param) + when (event.getToolType()) { + MotionEvent.TOOL_TYPE_UNKNOWN -> return + MotionEvent.TOOL_TYPE_STYLUS, MotionEvent.TOOL_TYPE_ERASER -> handleStylusHoverEvent(event, param) + } + } + }, + ) + + XposedHelpers.findAndHookMethod( + MotionEvent::class.java, + "getButtonState", + object : XC_MethodHook() { + override fun afterHookedMethod(param: XC_MethodHook.MethodHookParam) { + var buttons = param.result as Int + buttonFields.asSequence() + .map { it.name } + .filterNot { sharedPreferences.getBoolean(typeEnabledKey(it), true) } + .map { buttonFieldsMap[it]!! } + .map { it.inv() } + .forEach { buttons = buttons and it } + param.result = buttons + } + }, + ) + } +} + +fun MotionEvent.getPointerId(): Int = getPointerId(actionIndex) +fun MotionEvent.getToolType(): Int = getToolType(actionIndex) + +private val dispatchTouchEventMethod: Method by lazy { + XposedHelpers.findMethodExact( + View::class.java, + "dispatchTouchEvent", + MotionEvent::class.java, + )!! +} + +fun View.dispatchTouchEventUnhooked(event: MotionEvent) { + XposedBridge.invokeOriginalMethod(dispatchTouchEventMethod, this, arrayOf(event)) +} diff --git a/MotionEventMod/src/main/res/layout/settings_activity.xml b/MotionEventMod/src/main/res/layout/settings_activity.xml new file mode 100644 index 0000000..d4662df --- /dev/null +++ b/MotionEventMod/src/main/res/layout/settings_activity.xml @@ -0,0 +1,13 @@ + + + + diff --git a/MotionEventMod/src/main/res/layout/timeoutpreference_dialog.xml b/MotionEventMod/src/main/res/layout/timeoutpreference_dialog.xml new file mode 100644 index 0000000..edc6429 --- /dev/null +++ b/MotionEventMod/src/main/res/layout/timeoutpreference_dialog.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/MotionEventMod/src/main/res/values-v21/themes.xml b/MotionEventMod/src/main/res/values-v21/themes.xml new file mode 100644 index 0000000..ee02adb --- /dev/null +++ b/MotionEventMod/src/main/res/values-v21/themes.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/MotionEventMod/src/main/res/xml/root_preferences.xml b/MotionEventMod/src/main/res/xml/root_preferences.xml new file mode 100644 index 0000000..ed6494e --- /dev/null +++ b/MotionEventMod/src/main/res/xml/root_preferences.xml @@ -0,0 +1,47 @@ + + + + + + + + + +