diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 64b6ac9..1e8793d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,6 +19,11 @@ on: - main - dev - test + pull_request: + branches: + - main + - dev + - test env: BUILD_TYPE: ${{ inputs.build-type || 'debug' }} diff --git a/app/build.gradle b/app/build.gradle index 08c1103..41bc54a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,7 +31,7 @@ android { } dependencies { - implementation(platform("org.jetbrains.kotlin:kotlin-bom:2.0.21")) + implementation(platform("org.jetbrains.kotlin:kotlin-bom:2.1.0")) implementation 'androidx.core:core-ktx:1.15.0' // Xposed Framework API dependencies diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/LogHelper.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/LogHelper.kt index 9f0a0e4..ababd45 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/LogHelper.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/LogHelper.kt @@ -3,7 +3,7 @@ package ru.hepolise.volumekeytrackcontrolmodule import de.robv.android.xposed.XposedBridge object LogHelper { - fun log(text: String) { - if (BuildConfig.DEBUG) XposedBridge.log(text) + fun log(prefix: String, text: String) { + if (BuildConfig.DEBUG) XposedBridge.log("[$prefix] $text") } } \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/VolumeControlModule.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/VolumeControlModule.kt index 85f3c4f..6c5758d 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/VolumeControlModule.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/VolumeControlModule.kt @@ -6,10 +6,9 @@ import androidx.annotation.Keep import de.robv.android.xposed.IXposedHookLoadPackage import de.robv.android.xposed.XposedHelpers import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam -import ru.hepolise.volumekeytrackcontrolmodule.LogHelper.log -import ru.hepolise.volumekeytrackcontrolmodule.VolumeKeyHandlers.handleConstructPhoneWindowManager -import ru.hepolise.volumekeytrackcontrolmodule.VolumeKeyHandlers.handleInterceptKeyBeforeQueueing -import ru.hepolise.volumekeytrackcontrolmodule.model.HookInfo +import ru.hepolise.volumekeytrackcontrolmodule.VolumeKeyControlModuleHandlers.handleConstructPhoneWindowManager +import ru.hepolise.volumekeytrackcontrolmodule.VolumeKeyControlModuleHandlers.handleInterceptKeyBeforeQueueing +import java.io.Serializable @Keep class VolumeControlModule : IXposedHookLoadPackage { @@ -20,6 +19,9 @@ class VolumeControlModule : IXposedHookLoadPackage { private const val CLASS_IWINDOW_MANAGER = "android.view.IWindowManager" private const val CLASS_WINDOW_MANAGER_FUNCS = "com.android.server.policy.WindowManagerPolicy.WindowManagerFuncs" + + private fun log(text: String) = + LogHelper.log(VolumeControlModule::class.java.simpleName, text) } @Throws(Throwable::class) @@ -30,61 +32,66 @@ class VolumeControlModule : IXposedHookLoadPackage { init(lpparam.classLoader) } + private val initMethodSignatures = mapOf( + // Android 14 & 15 signature + // https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-14.0.0_r18/services/core/java/com/android/server/policy/PhoneWindowManager.java#2033 + // https://android.googlesource.com/platform/frameworks/base/+/refs/heads/android15-release/services/core/java/com/android/server/policy/PhoneWindowManager.java#2199 + arrayOf( + Context::class.java, + CLASS_WINDOW_MANAGER_FUNCS + ) to "Using Android 14 or 15 method signature", + + // Android 13 signature + // https://android.googlesource.com/platform/frameworks/base/+/refs/heads/android13-dev/services/core/java/com/android/server/policy/PhoneWindowManager.java#1873 + arrayOf( + Context::class.java, + CLASS_IWINDOW_MANAGER, + CLASS_WINDOW_MANAGER_FUNCS + ) to "Using Android 13 method signature", + + // HyperOS-specific signature + arrayOf( + Context::class.java, + CLASS_WINDOW_MANAGER_FUNCS, + CLASS_IWINDOW_MANAGER + ) to "Using HyperOS-specific method signature" + ) + private fun init(classLoader: ClassLoader) { - val hookInfoList = listOf( - // Android 14 & 15 signature - // https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-14.0.0_r18/services/core/java/com/android/server/policy/PhoneWindowManager.java#2033 - // https://android.googlesource.com/platform/frameworks/base/+/refs/heads/android15-release/services/core/java/com/android/server/policy/PhoneWindowManager.java#2199 - HookInfo( - params = arrayOf(Context::class.java, CLASS_WINDOW_MANAGER_FUNCS), - logMessage = "Using Android 14 or 15 method signature" - ), - // Android 13 signature - // https://android.googlesource.com/platform/frameworks/base/+/refs/heads/android13-dev/services/core/java/com/android/server/policy/PhoneWindowManager.java#1873 - HookInfo( - params = arrayOf( - Context::class.java, - CLASS_IWINDOW_MANAGER, - CLASS_WINDOW_MANAGER_FUNCS - ), - logMessage = "Using Android 13 method signature" - ), - // HyperOS-specific signature - HookInfo( - params = arrayOf( - Context::class.java, - CLASS_WINDOW_MANAGER_FUNCS, - CLASS_IWINDOW_MANAGER - ), - logMessage = "Using HyperOS-specific method signature" - ), - ) + val foundMethod = initMethodSignatures.any { (params, logMessage) -> + tryHookMethod(classLoader, params, logMessage) + } - var foundMethod = false - for (hookInfo in hookInfoList) { - try { - XposedHelpers.findAndHookMethod( - CLASS_PHONE_WINDOW_MANAGER, classLoader, "init", - *hookInfo.params, handleConstructPhoneWindowManager - ) - foundMethod = true - log(hookInfo.logMessage) - break - } catch (ignored: NoSuchMethodError) { - } + if (!foundMethod) { + log("Method hook failed for init!") + return } - if (foundMethod) { - // https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-14.0.0_r18/services/core/java/com/android/server/policy/PhoneWindowManager.java#4117 + + // https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-14.0.0_r18/services/core/java/com/android/server/policy/PhoneWindowManager.java#4117 + XposedHelpers.findAndHookMethod( + CLASS_PHONE_WINDOW_MANAGER, + classLoader, + "interceptKeyBeforeQueueing", + KeyEvent::class.java, + Int::class.javaPrimitiveType, + handleInterceptKeyBeforeQueueing + ) + } + + private fun tryHookMethod( + classLoader: ClassLoader, + params: Array, + logMessage: String + ): Boolean { + return try { XposedHelpers.findAndHookMethod( - CLASS_PHONE_WINDOW_MANAGER, - classLoader, - "interceptKeyBeforeQueueing", - KeyEvent::class.java, - Int::class.javaPrimitiveType, - handleInterceptKeyBeforeQueueing + CLASS_PHONE_WINDOW_MANAGER, classLoader, "init", + *params, handleConstructPhoneWindowManager ) - } else { - log("Method hook failed for init!") + log(logMessage) + true + } catch (ignored: NoSuchMethodError) { + false } } } diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/VolumeKeyHandlers.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/VolumeKeyControlModuleHandlers.kt similarity index 58% rename from app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/VolumeKeyHandlers.kt rename to app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/VolumeKeyControlModuleHandlers.kt index 77a9eb1..000e5d3 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/VolumeKeyHandlers.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/VolumeKeyControlModuleHandlers.kt @@ -1,28 +1,34 @@ package ru.hepolise.volumekeytrackcontrolmodule import android.content.Context +import android.hardware.display.DisplayManager import android.media.AudioManager +import android.media.session.MediaController +import android.media.session.PlaybackState import android.os.Build import android.os.Handler import android.os.PowerManager import android.os.Vibrator import android.os.VibratorManager -import android.util.Log +import android.view.Display import android.view.KeyEvent import android.view.ViewConfiguration import de.robv.android.xposed.XC_MethodHook import de.robv.android.xposed.XC_MethodHook.MethodHookParam import de.robv.android.xposed.XposedHelpers -import ru.hepolise.volumekeytrackcontrolmodule.LogHelper.log -import ru.hepolise.volumekeytrackcontrolmodule.extension.AudioManagerExtension.sendMediaButtonEvent import ru.hepolise.volumekeytrackcontrolmodule.extension.VibratorExtension.triggerVibration -object VolumeKeyHandlers { + +object VolumeKeyControlModuleHandlers { private const val VOLUME_UP_LONG_PRESS = "mVolumeUpLongPress" private const val VOLUME_DOWN_LONG_PRESS = "mVolumeDownLongPress" private const val VOLUME_BOTH_LONG_PRESS = "mVolumeBothLongPress" + private const val CLASS_MEDIA_SESSION_LEGACY_HELPER = + "android.media.session.MediaSessionLegacyHelper" + private const val CLASS_COMPONENT_NAME = "android.content.ComponentName" + private val TIMEOUT = ViewConfiguration.getLongPressTimeout().toLong() private var isLongPress = false @@ -31,13 +37,18 @@ object VolumeKeyHandlers { private lateinit var audioManager: AudioManager private lateinit var powerManager: PowerManager + private lateinit var displayManager: DisplayManager private lateinit var vibrator: Vibrator + private var mediaControllers: List? = null + + private fun log(text: String) = LogHelper.log(VolumeControlModule::class.java.simpleName, text) + val handleInterceptKeyBeforeQueueing: XC_MethodHook = object : XC_MethodHook() { override fun beforeHookedMethod(param: MethodHookParam) { val event = param.args[0] as KeyEvent val keyCode = event.keyCode - initManagers(param.thisObject.getObjectField("mContext") as Context) + initManagers(param) if (needHook(keyCode, event)) { doHook(keyCode, event, param) } @@ -46,30 +57,28 @@ object VolumeKeyHandlers { val handleConstructPhoneWindowManager: XC_MethodHook = object : XC_MethodHook() { override fun afterHookedMethod(param: MethodHookParam) { - val volumeUpLongPress = Runnable { - log("sending next") - KeyEvent::KEYCODE_MEDIA_PREVIOUS.name - isLongPress = true - sendMediaButtonEventAndTriggerVibration(KeyEvent.KEYCODE_MEDIA_NEXT) - } - val volumeDownLongPress = Runnable { - log("sending prev") - isLongPress = true - sendMediaButtonEventAndTriggerVibration(KeyEvent.KEYCODE_MEDIA_PREVIOUS) - } - val volumeBothLongPress = Runnable { - if (isUpPressed && isDownPressed) { - log("sending play/pause") - isLongPress = true - sendMediaButtonEventAndTriggerVibration(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE) - } else { - log("NOT sending play/pause, down: $isDownPressed, up: $isUpPressed") - } - } mapOf( - VOLUME_UP_LONG_PRESS to volumeUpLongPress, - VOLUME_DOWN_LONG_PRESS to volumeDownLongPress, - VOLUME_BOTH_LONG_PRESS to volumeBothLongPress, + VOLUME_UP_LONG_PRESS to Runnable { + log("sending next") + isLongPress = true + sendMediaButtonEventAndTriggerVibration(KeyEvent.KEYCODE_MEDIA_NEXT) + }, + VOLUME_DOWN_LONG_PRESS to Runnable { + log("sending prev") + isLongPress = true + sendMediaButtonEventAndTriggerVibration(KeyEvent.KEYCODE_MEDIA_PREVIOUS) + }, + VOLUME_BOTH_LONG_PRESS to Runnable { + if (isUpPressed && isDownPressed) { + log("sending play/pause") + isLongPress = true + getActiveMediaController()?.transportControls?.also { + sendMediaButtonEventAndTriggerVibration(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE) + } + } else { + log("NOT sending play/pause, down: $isDownPressed, up: $isUpPressed") + } + }, ).forEach { (key, runnable) -> XposedHelpers.setAdditionalInstanceField(param.thisObject, key, runnable) } @@ -80,24 +89,46 @@ object VolumeKeyHandlers { log("========") log("current audio manager mode: ${audioManager.mode}, required: ${AudioManager.MODE_NORMAL}") log("keyCode: ${keyCode}, required: ${KeyEvent.KEYCODE_VOLUME_DOWN} or ${KeyEvent.KEYCODE_VOLUME_UP}") - log("!powerManager.isInteractive: ${!powerManager.isInteractive}, required: true") + log("displayInteractive: ${isDisplayInteractive()}, required: false") log("isDownPressed: $isDownPressed") log("isUpPressed: $isUpPressed") + log("hasActiveMediaController: ${hasActiveMediaController()}, required: true") val needHook = (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || keyCode == KeyEvent.KEYCODE_VOLUME_UP) && event.flags and KeyEvent.FLAG_FROM_SYSTEM != 0 - && (!powerManager.isInteractive || isDownPressed || isUpPressed) + && (!isDisplayInteractive() || isDownPressed || isUpPressed) && audioManager.mode == AudioManager.MODE_NORMAL + && hasActiveMediaController() log("needHook: $needHook") return needHook } - private fun initManagers(ctx: Context) { - with(ctx) { + private fun isDisplayInteractive(): Boolean { + log("powerManager.isInteractive: ${powerManager.isInteractive}") + if (!powerManager.isInteractive) { + return false + } + log("displays count: ${displayManager.displays.size}") + // TODO + if (displayManager.displays.size > 1) { + return true + } + val display = displayManager.displays[0] + val disabledStates = + listOf(Display.STATE_OFF, Display.STATE_DOZE, Display.STATE_DOZE_SUSPEND) + log("checking display: ${display.displayId}, state: ${display.state}, required: $disabledStates") + return !disabledStates.contains(display.state) + } + + private fun initManagers(param: MethodHookParam) { + val context = param.thisObject.getContext() + with(context) { audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager? ?: throw NullPointerException("Unable to obtain audio service") powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager? ?: throw NullPointerException("Unable to obtain power service") + displayManager = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager? + ?: throw NullPointerException("Unable to obtain display service") vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val vibratorManager = getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager @@ -107,13 +138,29 @@ object VolumeKeyHandlers { getSystemService(Context.VIBRATOR_SERVICE) as Vibrator } } + + val mediaSessionHelperClass = XposedHelpers.findClass( + CLASS_MEDIA_SESSION_LEGACY_HELPER, + param.thisObject.javaClass.classLoader + ) + val helper = XposedHelpers.callStaticMethod(mediaSessionHelperClass, "getHelper", context) + val mSessionManager = XposedHelpers.getObjectField(helper, "mSessionManager") + val componentNameClass = + XposedHelpers.findClass(CLASS_COMPONENT_NAME, param.thisObject.javaClass.classLoader) + + @Suppress("UNCHECKED_CAST") + mediaControllers = XposedHelpers.callMethod( + mSessionManager, + "getActiveSessions", + arrayOf(componentNameClass), + null + ) as List? } private fun doHook(keyCode: Int, event: KeyEvent, param: MethodHookParam) { - if (event.action == KeyEvent.ACTION_DOWN) { - handleDownAction(keyCode, param) - } else { - handleUpAction(keyCode, param) + when (event.action) { + KeyEvent.ACTION_DOWN -> handleDownAction(keyCode, param) + KeyEvent.ACTION_UP -> handleUpAction(keyCode, param) } param.setResult(0) } @@ -130,7 +177,7 @@ object VolumeKeyHandlers { handleVolumeSkipPressAbort(param.thisObject) } else { // only one button pressed - if (isMusicActive) { + if (isMusicActive()) { log("music is active, creating delayed skip") handleVolumeSkipPress(param.thisObject, keyCode) } @@ -146,7 +193,7 @@ object VolumeKeyHandlers { } log("up action received, down: $isDownPressed, up: $isUpPressed") handleVolumeAllPressAbort(param.thisObject) - if (!isLongPress && isMusicActive) { + if (!isLongPress && isMusicActive()) { log("adjusting music volume") val direction = when (keyCode) { KeyEvent.KEYCODE_VOLUME_UP -> AudioManager.ADJUST_RAISE @@ -157,27 +204,29 @@ object VolumeKeyHandlers { } } - private val isMusicActive: Boolean - get() { - // check local - if (audioManager.isMusicActive) return true - // check remote - try { - if (XposedHelpers.callMethod( - audioManager, - "isMusicActiveRemotely" - ) as Boolean - ) return true - } catch (t: Throwable) { - t.localizedMessage?.let { Log.e("xposed", it) } - t.printStackTrace() + private fun hasActiveMediaController() = getActiveMediaController() != null + + private fun getActiveMediaController(): MediaController? { + return mediaControllers?.firstOrNull()?.also { log("chosen media controller: ${it.packageName}") } + } + + private fun isMusicActive() = + getActiveMediaController()?.let { it.playbackState?.state == PlaybackState.STATE_PLAYING } + ?: false + + private fun sendMediaButtonEventAndTriggerVibration(keyCode: Int) { + getActiveMediaController()?.transportControls?.also { controls -> + when (keyCode) { + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { + if (isMusicActive()) controls.pause() else controls.play() + } + + KeyEvent.KEYCODE_MEDIA_NEXT -> controls.skipToNext() + KeyEvent.KEYCODE_MEDIA_PREVIOUS -> controls.skipToPrevious() + else -> return } - return false + vibrator.triggerVibration() } - - private fun sendMediaButtonEventAndTriggerVibration(code: Int) { - audioManager.sendMediaButtonEvent(code) - vibrator.triggerVibration() } private fun handleVolumePlayPausePress(instance: Any) { @@ -221,6 +270,10 @@ object VolumeKeyHandlers { return XposedHelpers.getAdditionalInstanceField(instance, fieldName) as Runnable } + private fun Any.getContext(): Context { + return getObjectField("mContext") as Context + } + private fun Any.getHandler(): Handler { return getObjectField("mHandler") as Handler } @@ -228,4 +281,4 @@ object VolumeKeyHandlers { private fun Any.getObjectField(fieldName: String): Any { return XposedHelpers.getObjectField(this, fieldName) } -} \ No newline at end of file +} diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/extension/AudioManagerExtension.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/extension/AudioManagerExtension.kt deleted file mode 100644 index 788761f..0000000 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/extension/AudioManagerExtension.kt +++ /dev/null @@ -1,30 +0,0 @@ -package ru.hepolise.volumekeytrackcontrolmodule.extension - -import android.content.Intent -import android.media.AudioManager -import android.os.SystemClock -import android.util.Log -import android.view.KeyEvent - -object AudioManagerExtension { - - fun AudioManager.sendMediaButtonEvent(code: Int) { - val eventTime = SystemClock.uptimeMillis() - val keyIntent = Intent(Intent.ACTION_MEDIA_BUTTON, null) - var keyEvent = KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, code, 0) - keyIntent.putExtra(Intent.EXTRA_KEY_EVENT, keyEvent) - dispatchMediaButtonEvent(keyEvent) - keyEvent = KeyEvent.changeAction(keyEvent, KeyEvent.ACTION_UP) - keyIntent.putExtra(Intent.EXTRA_KEY_EVENT, keyEvent) - dispatchMediaButtonEvent(keyEvent) - } - - private fun AudioManager.dispatchMediaButtonEvent(keyEvent: KeyEvent) { - try { - this.dispatchMediaKeyEvent(keyEvent) - } catch (t: Throwable) { - t.localizedMessage?.let { Log.e("xposed", it) } - t.printStackTrace() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/model/HookInfo.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/model/HookInfo.kt deleted file mode 100644 index 5922a49..0000000 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/model/HookInfo.kt +++ /dev/null @@ -1,21 +0,0 @@ -package ru.hepolise.volumekeytrackcontrolmodule.model - -data class HookInfo(val params: Array, val logMessage: String) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as HookInfo - - if (!params.contentEquals(other.params)) return false - if (logMessage != other.logMessage) return false - - return true - } - - override fun hashCode(): Int { - var result = params.contentHashCode() - result = 31 * result + logMessage.hashCode() - return result - } -} diff --git a/build.gradle b/build.gradle index 007c1cd..ff07c29 100644 --- a/build.gradle +++ b/build.gradle @@ -1,12 +1,12 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '8.7.2' apply false - id 'com.android.library' version '8.7.2' apply false - id 'org.jetbrains.kotlin.android' version '2.0.21' apply false + id 'com.android.application' version '8.7.3' apply false + id 'com.android.library' version '8.7.3' apply false + id 'org.jetbrains.kotlin.android' version '2.1.0' apply false } -String versionName = "1.15.0" -Integer versionCode = 7 +String versionName = "1.15.1" +Integer versionCode = 8 rootProject.ext.set("appVersionName", versionName) rootProject.ext.set("appVersionCode", versionCode)