diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c5e0b45..1e8793d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,6 +18,12 @@ on: branches: - main - dev + - test + pull_request: + branches: + - main + - dev + - test env: BUILD_TYPE: ${{ inputs.build-type || 'debug' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3952c22..1ff5c61 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,7 @@ name: Publish Release on: - - workflow_dispatch + workflow_dispatch: jobs: call-build: @@ -9,17 +9,48 @@ jobs: secrets: inherit with: build-type: release + publish-release: runs-on: ubuntu-latest - needs: call-build + needs: call-build steps: - - uses: actions/download-artifact@v4 + - name: Download Build Artifacts + uses: actions/download-artifact@v4 with: name: Build Artifacts - - name: Publish APK - uses: marvinpinto/action-automatic-releases@latest + + - name: Generate Dynamic Tag + id: tag + run: echo "TAG_NAME=$(date +'%Y%m%d-%H%M%S')" >> $GITHUB_ENV + + - name: Create Release + id: create_release + uses: actions/create-release@v1 with: - repo_token: "${{ secrets.GITHUB_TOKEN }}" - files: | - *.apk - automatic_release_tag: "latest" + tag_name: ${{ env.TAG_NAME }} + release_name: Release ${{ env.TAG_NAME }} + body: | + Automatically generated release. + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload APKs + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "Release Upload URL: ${{ steps.create_release.outputs.upload_url }}" + for file in *.apk; do + echo "Uploading $file..." + upload_url="${{ steps.create_release.outputs.upload_url }}" + echo "Extracted upload URL: $upload_url" + # Clean up the URL + clean_url=$(echo "$upload_url" | sed 's/{?name,label}//') + echo "Cleaned upload URL: $clean_url" + curl -X POST \ + -H "Authorization: token $GITHUB_TOKEN" \ + -H "Content-Type: application/vnd.android.package-archive" \ + --data-binary @"$file" \ + "$clean_url?name=$(basename "$file")" + done diff --git a/app/build.gradle b/app/build.gradle index 7da0810..41bc54a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,12 +4,12 @@ plugins { } android { - compileSdk 34 + compileSdk 35 defaultConfig { applicationId "ru.hepolise.volumekeymusicmanagermodule" minSdk 25 - targetSdk 34 + targetSdk 35 versionCode = Integer.parseInt(rootProject.ext["appVersionCode"].toString()) versionName = rootProject.ext["appVersionName"].toString() } @@ -31,10 +31,9 @@ android { } dependencies { - implementation(platform("org.jetbrains.kotlin:kotlin-bom:2.0.20")) - implementation 'androidx.core:core-ktx:1.13.1' + implementation(platform("org.jetbrains.kotlin:kotlin-bom:2.1.0")) + implementation 'androidx.core:core-ktx:1.15.0' // Xposed Framework API dependencies compileOnly 'de.robv.android.xposed:api:82' - compileOnly 'de.robv.android.xposed:api:82:sources' } diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/LogHelper.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/LogHelper.kt new file mode 100644 index 0000000..ababd45 --- /dev/null +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/LogHelper.kt @@ -0,0 +1,9 @@ +package ru.hepolise.volumekeytrackcontrolmodule + +import de.robv.android.xposed.XposedBridge + +object LogHelper { + 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 fa32b87..6c5758d 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/VolumeControlModule.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/VolumeControlModule.kt @@ -1,23 +1,29 @@ package ru.hepolise.volumekeytrackcontrolmodule import android.content.Context -import android.content.Intent -import android.media.AudioManager -import android.os.Handler -import android.os.PowerManager -import android.os.SystemClock -import android.util.Log import android.view.KeyEvent -import android.view.ViewConfiguration import androidx.annotation.Keep import de.robv.android.xposed.IXposedHookLoadPackage -import de.robv.android.xposed.XC_MethodHook -import de.robv.android.xposed.XposedBridge import de.robv.android.xposed.XposedHelpers import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam +import ru.hepolise.volumekeytrackcontrolmodule.VolumeKeyControlModuleHandlers.handleConstructPhoneWindowManager +import ru.hepolise.volumekeytrackcontrolmodule.VolumeKeyControlModuleHandlers.handleInterceptKeyBeforeQueueing +import java.io.Serializable @Keep class VolumeControlModule : IXposedHookLoadPackage { + + companion object { + private const val CLASS_PHONE_WINDOW_MANAGER = + "com.android.server.policy.PhoneWindowManager" + 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) override fun handleLoadPackage(lpparam: LoadPackageParam) { if (lpparam.packageName != "android") { @@ -26,35 +32,39 @@ 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) { - try { - // Try the HyperOS-specific signature - XposedHelpers.findAndHookMethod( - CLASS_PHONE_WINDOW_MANAGER, classLoader, "init", - Context::class.java, CLASS_WINDOW_MANAGER_FUNCS, CLASS_IWINDOW_MANAGER, - handleConstructPhoneWindowManager - ) - log("Using HyperOS-specific method signature") - } catch (e1: NoSuchMethodError) { - try { - // Try the Android 14 (Upside Down Cake) 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 - XposedHelpers.findAndHookMethod( - CLASS_PHONE_WINDOW_MANAGER, classLoader, "init", - Context::class.java, CLASS_WINDOW_MANAGER_FUNCS, - handleConstructPhoneWindowManager - ) - log("Using Android 14 method signature") - } catch (e2: NoSuchMethodError) { - // Fallback to the Android 13 signature - // https://android.googlesource.com/platform/frameworks/base/+/refs/heads/android13-dev/services/core/java/com/android/server/policy/PhoneWindowManager.java#1873 - XposedHelpers.findAndHookMethod( - CLASS_PHONE_WINDOW_MANAGER, classLoader, "init", - Context::class.java, CLASS_IWINDOW_MANAGER, CLASS_WINDOW_MANAGER_FUNCS, - handleConstructPhoneWindowManager - ) - log("Using Android 13 method signature") - } + val foundMethod = initMethodSignatures.any { (params, logMessage) -> + tryHookMethod(classLoader, params, logMessage) + } + + if (!foundMethod) { + log("Method hook failed for init!") + return } // https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-14.0.0_r18/services/core/java/com/android/server/policy/PhoneWindowManager.java#4117 @@ -68,222 +78,20 @@ class VolumeControlModule : IXposedHookLoadPackage { ) } - companion object { - private const val CLASS_PHONE_WINDOW_MANAGER = - "com.android.server.policy.PhoneWindowManager" - private const val CLASS_IWINDOW_MANAGER = "android.view.IWindowManager" - private const val CLASS_WINDOW_MANAGER_FUNCS = - "com.android.server.policy.WindowManagerPolicy.WindowManagerFuncs" - private var mIsLongPress = false - private var mIsDownPressed = false - private var mIsUpPressed = false - - // private static int mButtonsPressed = 0; - private lateinit var mAudioManager: AudioManager - private lateinit var mPowerManager: PowerManager - private fun log(text: String) { - if (BuildConfig.DEBUG) XposedBridge.log(text) - } - - private val handleInterceptKeyBeforeQueueing: XC_MethodHook = object : XC_MethodHook() { - override fun beforeHookedMethod(param: MethodHookParam) { - val event = param.args[0] as KeyEvent - val keyCode = event.keyCode - initManagers(XposedHelpers.getObjectField(param.thisObject, "mContext") as Context) - if (needHook(keyCode, event)) { - if (event.action == KeyEvent.ACTION_DOWN) { - if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) mIsDownPressed = true - if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) mIsUpPressed = true - log("down action received, down: $mIsDownPressed, up: $mIsUpPressed") - mIsLongPress = false - if (mIsUpPressed && mIsDownPressed) { - log("aborting delayed skip") - handleVolumeSkipPressAbort(param.thisObject) - } else { - // only one button pressed - if (isMusicActive) { - log("music is active, creating delayed skip") - handleVolumeSkipPress(param.thisObject, keyCode) - } - log("creating delayed play pause") - handleVolumePlayPausePress(param.thisObject) - } - } else { - if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) mIsDownPressed = false - if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) mIsUpPressed = false - log("up action received, down: $mIsDownPressed, up: $mIsUpPressed") - handleVolumeAllPressAbort(param.thisObject) - if (!mIsLongPress && isMusicActive) { - log("adjusting music volume") - mAudioManager.adjustStreamVolume( - AudioManager.STREAM_MUSIC, - if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) AudioManager.ADJUST_RAISE else AudioManager.ADJUST_LOWER, - 0 - ) - } - } - param.setResult(0) - } - } - } - private val handleConstructPhoneWindowManager: XC_MethodHook = object : XC_MethodHook() { - override fun afterHookedMethod(param: MethodHookParam) { - val mVolumeUpLongPress = Runnable { - log("sending next") - mIsLongPress = true - sendMediaButtonEvent(KeyEvent.KEYCODE_MEDIA_NEXT) - } - val mVolumeDownLongPress = Runnable { - log("sending prev") - mIsLongPress = true - sendMediaButtonEvent(KeyEvent.KEYCODE_MEDIA_PREVIOUS) - } - val mVolumeBothLongPress = Runnable { - if (mIsUpPressed && mIsDownPressed) { - log("sending play/pause") - mIsLongPress = true - sendMediaButtonEvent(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE) - } else { - log("NOT sending play/pause, down: $mIsDownPressed, up: $mIsUpPressed") - } - } - XposedHelpers.setAdditionalInstanceField( - param.thisObject, - "mVolumeUpLongPress", - mVolumeUpLongPress - ) - XposedHelpers.setAdditionalInstanceField( - param.thisObject, - "mVolumeDownLongPress", - mVolumeDownLongPress - ) - XposedHelpers.setAdditionalInstanceField( - param.thisObject, - "mVolumeBothLongPress", - mVolumeBothLongPress - ) - } - } - - private fun needHook(keyCode: Int, event: KeyEvent): Boolean { - log("========") - log("current audio manager mode: ${mAudioManager.mode}, required: ${AudioManager.MODE_NORMAL}") - log("keyCode: ${keyCode}, required: ${KeyEvent.KEYCODE_VOLUME_DOWN} or ${KeyEvent.KEYCODE_VOLUME_UP}") - log("!mPowerManager.isInteractive: ${!mPowerManager.isInteractive}, required: true") - log("mIsDownPressed: ${mIsDownPressed}") - log("mIsUpPressed: ${mIsUpPressed}") - log("needHook: ${(keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || keyCode == KeyEvent.KEYCODE_VOLUME_UP) - && event.flags and KeyEvent.FLAG_FROM_SYSTEM != 0 - && (!mPowerManager.isInteractive || mIsDownPressed || mIsUpPressed) - && mAudioManager.mode == AudioManager.MODE_NORMAL}") - return (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || keyCode == KeyEvent.KEYCODE_VOLUME_UP) - && event.flags and KeyEvent.FLAG_FROM_SYSTEM != 0 - && (!mPowerManager.isInteractive || mIsDownPressed || mIsUpPressed) - && mAudioManager.mode == AudioManager.MODE_NORMAL - } - - private fun initManagers(ctx: Context) { - mAudioManager = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager? - ?: throw NullPointerException("Unable to obtain audio service") - mPowerManager = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager? - ?: throw NullPointerException("Unable to obtain power service") - } - - private val isMusicActive: Boolean - get() { - // check local - if (mAudioManager.isMusicActive) return true - // check remote - try { - if (XposedHelpers.callMethod( - mAudioManager, - "isMusicActiveRemotely" - ) as Boolean - ) return true - } catch (t: Throwable) { - t.localizedMessage?.let { Log.e("xposed", it) } - t.printStackTrace() - } - return false - } - - private fun 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 dispatchMediaButtonEvent(keyEvent: KeyEvent) { - try { - mAudioManager.dispatchMediaKeyEvent(keyEvent) - } catch (t: Throwable) { - t.localizedMessage?.let { Log.e("xposed", it) } - t.printStackTrace() - } - } - - private fun handleVolumePlayPausePress(phoneWindowManager: Any) { - val mHandler = XposedHelpers.getObjectField(phoneWindowManager, "mHandler") as Handler - val mVolumeBothLongPress = XposedHelpers.getAdditionalInstanceField( - phoneWindowManager, - "mVolumeBothLongPress" - ) as Runnable - mHandler.postDelayed( - mVolumeBothLongPress, - ViewConfiguration.getLongPressTimeout().toLong() - ) - } - - private fun handleVolumeSkipPress(phoneWindowManager: Any, keycode: Int) { - val mHandler = XposedHelpers.getObjectField(phoneWindowManager, "mHandler") as Handler - val mVolumeUpLongPress = XposedHelpers.getAdditionalInstanceField( - phoneWindowManager, - "mVolumeUpLongPress" - ) as Runnable - val mVolumeDownLongPress = XposedHelpers.getAdditionalInstanceField( - phoneWindowManager, - "mVolumeDownLongPress" - ) as Runnable - mHandler.postDelayed( - if (keycode == KeyEvent.KEYCODE_VOLUME_UP) mVolumeUpLongPress else mVolumeDownLongPress, - ViewConfiguration.getLongPressTimeout().toLong() + private fun tryHookMethod( + classLoader: ClassLoader, + params: Array, + logMessage: String + ): Boolean { + return try { + XposedHelpers.findAndHookMethod( + CLASS_PHONE_WINDOW_MANAGER, classLoader, "init", + *params, handleConstructPhoneWindowManager ) - } - - private fun handleVolumeSkipPressAbort(phoneWindowManager: Any) { - log("aborting skip") - val mHandler = XposedHelpers.getObjectField(phoneWindowManager, "mHandler") as Handler - val mVolumeUpLongPress = XposedHelpers.getAdditionalInstanceField( - phoneWindowManager, - "mVolumeUpLongPress" - ) as Runnable - val mVolumeDownLongPress = XposedHelpers.getAdditionalInstanceField( - phoneWindowManager, - "mVolumeDownLongPress" - ) as Runnable - mHandler.removeCallbacks(mVolumeUpLongPress) - mHandler.removeCallbacks(mVolumeDownLongPress) - } - - private fun handleVolumePlayPausePressAbort(phoneWindowManager: Any) { - log("aborting play/pause") - val mHandler = XposedHelpers.getObjectField(phoneWindowManager, "mHandler") as Handler - val mVolumeBothLongPress = XposedHelpers.getAdditionalInstanceField( - phoneWindowManager, - "mVolumeBothLongPress" - ) as Runnable - mHandler.removeCallbacks(mVolumeBothLongPress) - } - - private fun handleVolumeAllPressAbort(phoneWindowManager: Any) { - log("aborting all") - handleVolumePlayPausePressAbort(phoneWindowManager) - handleVolumeSkipPressAbort(phoneWindowManager) + log(logMessage) + true + } catch (ignored: NoSuchMethodError) { + false } } } diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/VolumeKeyControlModuleHandlers.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/VolumeKeyControlModuleHandlers.kt new file mode 100644 index 0000000..6b04955 --- /dev/null +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/VolumeKeyControlModuleHandlers.kt @@ -0,0 +1,290 @@ +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.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.extension.VibratorExtension.triggerVibration + + +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 + private var isDownPressed = false + private var isUpPressed = false + + 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) + if (needHook(keyCode, event)) { + doHook(keyCode, event, param) + } + } + } + + val handleConstructPhoneWindowManager: XC_MethodHook = object : XC_MethodHook() { + override fun afterHookedMethod(param: MethodHookParam) { + mapOf( + 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) + } + } + } + + private fun needHook(keyCode: Int, event: KeyEvent): Boolean { + 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("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 + && (!isDisplayInteractive() || isDownPressed || isUpPressed) + && audioManager.mode == AudioManager.MODE_NORMAL + && hasActiveMediaController() + log("needHook: $needHook") + return needHook + } + + 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 + vibratorManager.defaultVibrator + } else { + @Suppress("DEPRECATION") + 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) { + when (event.action) { + KeyEvent.ACTION_DOWN -> handleDownAction(keyCode, param) + KeyEvent.ACTION_UP -> handleUpAction(keyCode, param) + } + param.setResult(0) + } + + private fun handleDownAction(keyCode: Int, param: MethodHookParam) { + when (keyCode) { + KeyEvent.KEYCODE_VOLUME_DOWN -> isDownPressed = true + KeyEvent.KEYCODE_VOLUME_UP -> isUpPressed = true + } + log("down action received, down: $isDownPressed, up: $isUpPressed") + isLongPress = false + if (isUpPressed && isDownPressed) { + log("aborting delayed skip") + handleVolumeSkipPressAbort(param.thisObject) + } else { + // only one button pressed + if (isMusicActive()) { + log("music is active, creating delayed skip") + handleVolumeSkipPress(param.thisObject, keyCode) + } + log("creating delayed play pause") + handleVolumePlayPausePress(param.thisObject) + } + } + + private fun handleUpAction(keyCode: Int, param: MethodHookParam) { + when (keyCode) { + KeyEvent.KEYCODE_VOLUME_DOWN -> isDownPressed = false + KeyEvent.KEYCODE_VOLUME_UP -> isUpPressed = false + } + log("up action received, down: $isDownPressed, up: $isUpPressed") + handleVolumeAllPressAbort(param.thisObject) + if (!isLongPress && isMusicActive()) { + log("adjusting music volume") + val direction = when (keyCode) { + KeyEvent.KEYCODE_VOLUME_UP -> AudioManager.ADJUST_RAISE + KeyEvent.KEYCODE_VOLUME_DOWN -> AudioManager.ADJUST_LOWER + else -> return + } + audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, direction, 0) + } + } + + private fun hasActiveMediaController() = getActiveMediaController() != null + + private fun getActiveMediaController(): MediaController? { + return mediaControllers?.firstOrNull()?.also { log("chosen media controller: ${it.packageName}") } + } + + private fun isMusicActive() = getActiveMediaController()?.let { + when (it.playbackState?.state) { + PlaybackState.STATE_PLAYING, + PlaybackState.STATE_FAST_FORWARDING, + PlaybackState.STATE_REWINDING, + PlaybackState.STATE_BUFFERING -> true + else -> false + } + } ?: 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 + } + vibrator.triggerVibration() + } + } + + private fun handleVolumePlayPausePress(instance: Any) { + val handler = instance.getHandler() + handler.postDelayed(getRunnable(instance, VOLUME_BOTH_LONG_PRESS), TIMEOUT) + } + + private fun handleVolumeSkipPress(instance: Any, keyCode: Int) { + val handler = instance.getHandler() + handler.postDelayed( + when (keyCode) { + KeyEvent.KEYCODE_VOLUME_UP -> getRunnable(instance, VOLUME_UP_LONG_PRESS) + KeyEvent.KEYCODE_VOLUME_DOWN -> getRunnable(instance, VOLUME_DOWN_LONG_PRESS) + else -> return + }, + TIMEOUT + ) + } + + private fun handleVolumeSkipPressAbort(instance: Any) { + log("aborting skip") + val handler = instance.getHandler() + listOf(VOLUME_UP_LONG_PRESS, VOLUME_DOWN_LONG_PRESS).forEach { + handler.removeCallbacks(getRunnable(instance, it)) + } + } + + private fun handleVolumePlayPausePressAbort(instance: Any) { + log("aborting play/pause") + val handler = instance.getHandler() + handler.removeCallbacks(getRunnable(instance, VOLUME_BOTH_LONG_PRESS)) + } + + private fun handleVolumeAllPressAbort(phoneWindowManager: Any) { + log("aborting all") + handleVolumePlayPausePressAbort(phoneWindowManager) + handleVolumeSkipPressAbort(phoneWindowManager) + } + + private fun getRunnable(instance: Any, fieldName: String): Runnable { + 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 + } + + private fun Any.getObjectField(fieldName: String): Any { + return XposedHelpers.getObjectField(this, fieldName) + } +} diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/extension/VibratorExtension.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/extension/VibratorExtension.kt new file mode 100644 index 0000000..e760438 --- /dev/null +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrolmodule/extension/VibratorExtension.kt @@ -0,0 +1,30 @@ +package ru.hepolise.volumekeytrackcontrolmodule.extension + +import android.annotation.SuppressLint +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator + +object VibratorExtension { + + @SuppressLint("MissingPermission") + fun Vibrator.triggerVibration() { + val millis = 50L + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + this.vibrate( + VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK) + ) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + this.vibrate( + VibrationEffect.createOneShot( + millis, + VibrationEffect.DEFAULT_AMPLITUDE + ) + ) + } else { + @Suppress("deprecation") + this.vibrate(millis) // Deprecated in API 26 but still works for lower versions + } + } + +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 3f933af..a15e35e 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.6.0' apply false - id 'com.android.library' version '8.6.0' apply false - id 'org.jetbrains.kotlin.android' version '2.0.20' 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.14.3" -Integer versionCode = 6 +String versionName = "1.15.2" +Integer versionCode = 9 rootProject.ext.set("appVersionName", versionName) rootProject.ext.set("appVersionCode", versionCode) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index be4176d..06760df 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Thu Feb 03 20:40:17 MSK 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/settings.gradle b/settings.gradle index 35ecb80..9eef2a5 100644 --- a/settings.gradle +++ b/settings.gradle @@ -8,12 +8,11 @@ pluginManagement { dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { - google() - jcenter() - mavenCentral() maven { url "https://api.xposed.info/" } + google() + mavenCentral() } } rootProject.name = "VolumeKeyMusicManagerModule"