Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
- uses: actions/checkout@v5

- name: Set up JDK 21
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'
Expand Down
8 changes: 4 additions & 4 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,14 @@ android {
}

dependencies {
implementation(platform("org.jetbrains.kotlin:kotlin-bom:2.2.10"))
implementation("androidx.core:core-ktx:1.16.0")
implementation(platform("org.jetbrains.kotlin:kotlin-bom:2.2.20"))
implementation("androidx.core:core-ktx:1.17.0")

// Compose BOM (Bill of Materials)
implementation(platform("androidx.compose:compose-bom:2025.08.00"))
implementation(platform("androidx.compose:compose-bom:2025.09.00"))

// Compose dependencies
implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.activity:activity-compose:1.11.0")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3:1.3.2")
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@
</intent-filter>
</activity>

<receiver
android:name="ru.hepolise.volumekeytrackcontrol.receiver.HookBroadcastReceiver"
android:exported="false">
<intent-filter>
<action android:name="${applicationId}.HOOK_UPDATE" />
</intent-filter>
</receiver>

<!-- metadata -->
<meta-data
android:name="xposedmodule"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,42 +23,43 @@ class VolumeControlModule : IXposedHookLoadPackage {

private fun log(text: String) =
LogHelper.log(VolumeControlModule::class.java.simpleName, text)

private val initMethodSignatures = mapOf(
// Android 14, 15 and 16 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
// https://android.googlesource.com/platform/frameworks/base/+/refs/heads/android16-release/services/core/java/com/android/server/policy/PhoneWindowManager.java#2359
arrayOf(
Context::class.java,
CLASS_WINDOW_MANAGER_FUNCS
) to "Using Android 14, 15 or 16 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"
)
}

@Throws(Throwable::class)
override fun handleLoadPackage(lpparam: LoadPackageParam) {
log("handleLoadPackage: ${lpparam.packageName}")
if (lpparam.packageName != "android") {
return
}
init(lpparam.classLoader)
}

private val initMethodSignatures = mapOf(
// Android 14, 15 and 16 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
// https://android.googlesource.com/platform/frameworks/base/+/refs/heads/android16-release/services/core/java/com/android/server/policy/PhoneWindowManager.java#2359
arrayOf(
Context::class.java,
CLASS_WINDOW_MANAGER_FUNCS
) to "Using Android 14, 15 or 16 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) {
initMethodSignatures.any { (params, logMessage) ->
tryHookInitMethod(classLoader, params, logMessage)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package ru.hepolise.volumekeytrackcontrol.module

import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.hardware.display.DisplayManager
import android.media.AudioManager
import android.media.session.MediaController
Expand All @@ -14,13 +19,16 @@ import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.XC_MethodHook.MethodHookParam
import de.robv.android.xposed.XposedHelpers
import ru.hepolise.volumekeytrackcontrol.module.util.LogHelper
import ru.hepolise.volumekeytrackcontrol.receiver.HookBroadcastReceiver
import ru.hepolise.volumekeytrackcontrol.util.Constants
import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil
import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getAppFilterType
import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getApps
import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getLongPressDuration
import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.isSwapButtons
import ru.hepolise.volumekeytrackcontrol.util.VibratorUtil.getVibrator
import ru.hepolise.volumekeytrackcontrol.util.VibratorUtil.triggerVibration
import ru.hepolise.volumekeytrackcontrolmodule.BuildConfig


object VolumeKeyControlModuleHandlers {
Expand All @@ -37,12 +45,15 @@ object VolumeKeyControlModuleHandlers {
private lateinit var displayManager: DisplayManager
private lateinit var vibrator: Vibrator

private var prefs: SharedPreferences? = null

private var mediaControllers: List<MediaController>? = 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) {
initPrefs()
with(param) {
val event = args[0] as KeyEvent
try {
Expand All @@ -64,10 +75,22 @@ object VolumeKeyControlModuleHandlers {

val handleConstructPhoneWindowManager: XC_MethodHook = object : XC_MethodHook() {
override fun afterHookedMethod(param: MethodHookParam) {
log("handleConstructPhoneWindowManager: initialized")
val context = param.getContext()
MediaEvent.entries.forEach { event ->
val runnable = Runnable { event.handle() }
val runnable = Runnable { event.handle(context) }
XposedHelpers.setAdditionalInstanceField(param.thisObject, event.field, runnable)
}

val filter = IntentFilter(Intent.ACTION_USER_UNLOCKED)
context.registerReceiver(object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
context.sendBroadcast {
putExtra(Constants.HOOKED, true)
}
ctx.unregisterReceiver(this)
}
}, filter)
}
}

Expand Down Expand Up @@ -117,6 +140,10 @@ object VolumeKeyControlModuleHandlers {
vibrator = getVibrator()
}

private fun initPrefs() {
prefs = SharedPreferencesUtil.prefs()
}

private fun Context.initControllers() {
val context = this
val classLoader = javaClass.classLoader
Expand All @@ -143,7 +170,7 @@ object VolumeKeyControlModuleHandlers {
}

private fun MethodHookParam.doHook(event: KeyEvent) {
val action = Action.entries.find { it.actionCode == event.action }!!
val action = Action.entries.single { it.actionCode == event.action }
val keyHelper = KeyHelper(event.keyCode)
keyHelper.updateFlags(action)
when (action) {
Expand All @@ -160,7 +187,6 @@ object VolumeKeyControlModuleHandlers {
log("Aborting delayed skip")
abortSkip()
} else {
// only one button pressed
if (getMediaController().isMusicActive()) {
log("Music is active, creating delayed skip")
delay(keyHelper.mediaEvent)
Expand Down Expand Up @@ -194,10 +220,9 @@ object VolumeKeyControlModuleHandlers {
}

private fun getMediaController(): MediaController? {
val prefs = SharedPreferencesUtil.prefs()
val filterType = prefs.getAppFilterType()
val apps = prefs.getApps(filterType)
return mediaControllers?.find {
val filterType = prefs.getAppFilterType()
val apps = prefs.getApps(filterType)
when (filterType) {
SharedPreferencesUtil.AppFilterType.DISABLED -> true
SharedPreferencesUtil.AppFilterType.WHITE_LIST -> it.packageName in apps
Expand Down Expand Up @@ -230,11 +255,26 @@ object VolumeKeyControlModuleHandlers {
}
}

private fun Context.sendBroadcast(block: Intent.() -> Unit) {
val modulePackage = BuildConfig.APPLICATION_ID

val intent = Intent().apply {
component = ComponentName(
modulePackage,
HookBroadcastReceiver::class.java.name
)
action = Constants.HOOK_UPDATE
block()
setPackage(modulePackage)
}
sendBroadcast(intent)
}

private fun MethodHookParam.delay(event: MediaEvent) {
val handler = getHandler()
handler.postDelayed(
getRunnable(event.field),
SharedPreferencesUtil.prefs().getLongPressDuration().toLong()
prefs.getLongPressDuration().toLong()
)
}

Expand Down Expand Up @@ -267,16 +307,19 @@ object VolumeKeyControlModuleHandlers {

private sealed class MediaEvent(val field: String) {

open fun handle() {
open fun handle(context: Context) {
log("Sending ${this::class.simpleName}")
isLongPress = true
sendMediaButtonEventAndTriggerVibration(this)
context.sendBroadcast {
putExtra(Constants.INCREMENT_LAUNCH_COUNT, true)
}
}

object PlayPause : MediaEvent("mVolumeBothLongPress") {
override fun handle() {
override fun handle(context: Context) {
if (isUpPressed && isDownPressed) {
super.handle()
super.handle(context)
} else {
log("Not sending ${this::class.simpleName}, down: $isDownPressed, up: $isUpPressed")
}
Expand All @@ -295,7 +338,7 @@ object VolumeKeyControlModuleHandlers {

private class KeyHelper(keyCode: Int) {
private val origKey = Key.entries.find { it.keyCode == keyCode }!!
private val isSwap = SharedPreferencesUtil.prefs().isSwapButtons()
private val isSwap = prefs.isSwapButtons()
private val key = if (isSwap) {
when (origKey) {
Key.UP -> Key.DOWN
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package ru.hepolise.volumekeytrackcontrol.receiver

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.content.edit
import ru.hepolise.volumekeytrackcontrol.util.Constants
import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.HOOK_PREFS_NAME
import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.LAST_INIT_HOOK_TIME
import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.LAUNCHED_COUNT
import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getLaunchedCount

class HookBroadcastReceiver : BroadcastReceiver() {

override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Constants.HOOK_UPDATE) {
val prefs = context.getSharedPreferences(HOOK_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit {
if (intent.getBooleanExtra(Constants.HOOKED, false)) {
putLong(LAST_INIT_HOOK_TIME, System.currentTimeMillis())
}
if (intent.getBooleanExtra(Constants.INCREMENT_LAUNCH_COUNT, false)) {
val current = prefs.getLaunchedCount()
putInt(LAUNCHED_COUNT, current + 1)
}
}
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.core.animation.doOnEnd
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import ru.hepolise.volumekeytrackcontrol.ui.component.ModuleIsNotEnabled
import ru.hepolise.volumekeytrackcontrol.ui.navigation.AppNavigation
import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.HOOK_PREFS_NAME
import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.SETTINGS_PREFS_NAME
import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.isHooked
import ru.hepolise.volumekeytrackcontrol.util.VibratorUtil.getVibrator
import kotlin.system.exitProcess

Expand All @@ -44,14 +45,9 @@ class SettingsActivity : ComponentActivity() {
setUpSplashScreenAnimation()
enableEdgeToEdge()
setContent {
val prefs = tryLoadPrefs(this)

val prefs = tryLoadPrefs()
MaterialTheme(colorScheme = dynamicColorScheme(context = this)) {
if (prefs == null) {
ModuleIsNotEnabled()
} else {
AppNavigation(sharedPreferences = prefs, vibrator = getVibrator())
}
AppNavigation(settingsPrefs = prefs, vibrator = getVibrator())
}
}
}
Expand All @@ -63,10 +59,12 @@ class SettingsActivity : ComponentActivity() {
}
}

private fun tryLoadPrefs(context: Context): SharedPreferences? = try {
private fun Context.tryLoadPrefs(): SharedPreferences? = try {
isHooked = getSharedPreferences(HOOK_PREFS_NAME, MODE_PRIVATE).isHooked()
shouldRemoveFromRecents = !isHooked
@SuppressLint("WorldReadableFiles")
@Suppress("DEPRECATION")
context.getSharedPreferences(SETTINGS_PREFS_NAME, MODE_WORLD_READABLE)
getSharedPreferences(SETTINGS_PREFS_NAME, MODE_WORLD_READABLE)
} catch (_: SecurityException) {
shouldRemoveFromRecents = true
null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,6 @@ fun <T> State<T>.debounce(
.collect { debouncedState.value = it }
}
return debouncedState
}
}

var isHooked = false
Loading