diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/KotlinModule.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/KotlinModule.kt index e2d5046..69ad51a 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/KotlinModule.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/KotlinModule.kt @@ -2,22 +2,9 @@ package me.kavishdevar.librepods.utils import android.annotation.SuppressLint import android.content.Context -import android.content.Intent import android.content.pm.ApplicationInfo -import android.content.res.ColorStateList -import android.graphics.Color -import android.graphics.drawable.GradientDrawable -import android.os.ParcelUuid import android.util.Log -import android.view.Gravity -import android.view.View -import android.view.ViewGroup -import android.view.animation.AccelerateInterpolator -import android.view.animation.DecelerateInterpolator -import android.widget.FrameLayout -import android.widget.ImageButton import android.widget.ImageView -import android.widget.LinearLayout import androidx.core.net.toUri import io.github.libxposed.api.XposedInterface import io.github.libxposed.api.XposedInterface.AfterHookCallback @@ -26,6 +13,7 @@ import io.github.libxposed.api.XposedModuleInterface import io.github.libxposed.api.XposedModuleInterface.ModuleLoadedParam import io.github.libxposed.api.annotations.AfterInvocation import io.github.libxposed.api.annotations.XposedHooker +import kotlin.jvm.java private const val TAG = "AirPodsHook" private lateinit var module: KotlinModule @@ -67,17 +55,6 @@ class KotlinModule(base: XposedInterface, param: ModuleLoadedParam): XposedModul hook(updateIconMethod, BluetoothIconHooker::class.java) Log.i(TAG, "Successfully hooked updateIcon method in Bluetooth settings") - - try { - val displayPreferenceMethod = headerControllerClass.getDeclaredMethod( - "displayPreference", - param.classLoader.loadClass("androidx.preference.PreferenceScreen")) - - hook(displayPreferenceMethod, BluetoothSettingsAirPodsHooker::class.java) - Log.i(TAG, "Successfully hooked displayPreference for AirPods button injection") - } catch (e: Exception) { - Log.e(TAG, "Failed to hook displayPreference: ${e.message}", e) - } } catch (e: Exception) { Log.e(TAG, "Failed to hook Bluetooth icon handler: ${e.message}", e) } @@ -96,110 +73,12 @@ class KotlinModule(base: XposedInterface, param: ModuleLoadedParam): XposedModul hook(updateIconMethod, BluetoothIconHooker::class.java) Log.i(TAG, "Successfully hooked updateIcon method in Bluetooth settings") - - try { - val displayPreferenceMethod = headerControllerClass.getDeclaredMethod( - "displayPreference", - param.classLoader.loadClass("androidx.preference.PreferenceScreen")) - - hook(displayPreferenceMethod, BluetoothSettingsAirPodsHooker::class.java) - Log.i(TAG, "Successfully hooked displayPreference for AirPods button injection") - } catch (e: Exception) { - Log.e(TAG, "Failed to hook displayPreference: ${e.message}", e) - } } catch (e: Exception) { Log.e(TAG, "Failed to hook Bluetooth icon handler: ${e.message}", e) } } } - @XposedHooker - class BluetoothSettingsAirPodsHooker : XposedInterface.Hooker { - companion object { - private const val AIRPODS_UUID = "74ec2172-0bad-4d01-8f77-997b2be0722a" - private const val LIBREPODS_PREFERENCE_KEY = "librepods_open_preference" - private const val ACTION_SET_ANC_MODE = "me.kavishdevar.librepods.SET_ANC_MODE" - private const val EXTRA_ANC_MODE = "anc_mode" - - private const val ANC_MODE_OFF = 1 - private const val ANC_MODE_NOISE_CANCELLATION = 2 - private const val ANC_MODE_TRANSPARENCY = 3 - private const val ANC_MODE_ADAPTIVE = 4 - - private var currentAncMode = ANC_MODE_NOISE_CANCELLATION - - @JvmStatic - @AfterInvocation - fun afterDisplayPreference(callback: AfterHookCallback) { - try { - val controller = callback.thisObject!! - val preferenceScreen = callback.args[0]!! - - val context = preferenceScreen.javaClass.getMethod("getContext").invoke(preferenceScreen) as Context - - val deviceField = controller.javaClass.getDeclaredField("mCachedDevice") - deviceField.isAccessible = true - val cachedDevice = deviceField.get(controller) ?: return - - val getDeviceMethod = cachedDevice.javaClass.getMethod("getDevice") - val bluetoothDevice = getDeviceMethod.invoke(cachedDevice) ?: return - - val uuidsMethod = bluetoothDevice.javaClass.getMethod("getUuids") - val uuids = uuidsMethod.invoke(bluetoothDevice) as? Array - - if (uuids != null) { - val isAirPods = uuids.any { it.uuid.toString() == AIRPODS_UUID } - - if (isAirPods) { - Log.i(TAG, "AirPods device detected in settings, injecting controls") - - val findPreferenceMethod = preferenceScreen.javaClass.getMethod("findPreference", CharSequence::class.java) - val existingPref = findPreferenceMethod.invoke(preferenceScreen, LIBREPODS_PREFERENCE_KEY) - - if (existingPref != null) { - Log.i(TAG, "LIBREPODS button already exists, skipping") - return - } - - val preferenceClass = preferenceScreen.javaClass.classLoader.loadClass("androidx.preference.Preference") - val preference = preferenceClass.getConstructor(Context::class.java).newInstance(context) - - val setKeyMethod = preferenceClass.getMethod("setKey", String::class.java) - setKeyMethod.invoke(preference, LIBREPODS_PREFERENCE_KEY) - - val setTitleMethod = preferenceClass.getMethod("setTitle", CharSequence::class.java) - setTitleMethod.invoke(preference, "Open LibrePods") - - val setSummaryMethod = preferenceClass.getMethod("setSummary", CharSequence::class.java) - setSummaryMethod.invoke(preference, "Control AirPods features") - - val setIconMethod = preferenceClass.getMethod("setIcon", Int::class.java) - setIconMethod.invoke(preference, android.R.drawable.ic_menu_manage) - - val setOrderMethod = preferenceClass.getMethod("setOrder", Int::class.java) - setOrderMethod.invoke(preference, 1000) - - val intent = Intent().apply { - setClassName("me.kavishdevar.librepods", "me.kavishdevar.librepods.MainActivity") - flags = Intent.FLAG_ACTIVITY_NEW_TASK - } - val setIntentMethod = preferenceClass.getMethod("setIntent", Intent::class.java) - setIntentMethod.invoke(preference, intent) - - val addPreferenceMethod = preferenceScreen.javaClass.getMethod("addPreference", preferenceClass) - addPreferenceMethod.invoke(preferenceScreen, preference) - - Log.i(TAG, "Successfully added Open LIBREPODS button to AirPods settings") - } - } - } catch (e: Exception) { - Log.e(TAG, "Error in BluetoothSettingsAirPodsHooker: ${e.message}", e) - e.printStackTrace() - } - } - } - } - @XposedHooker class BluetoothIconHooker : XposedInterface.Hooker { companion object { @@ -267,497 +146,4 @@ class KotlinModule(base: XposedInterface, param: ModuleLoadedParam): XposedModul override fun getApplicationInfo(): ApplicationInfo { return super.applicationInfo } - - companion object { - private const val ANC_MODE_OFF = 1 - private const val ANC_MODE_NOISE_CANCELLATION = 2 - private const val ANC_MODE_TRANSPARENCY = 3 - private const val ANC_MODE_ADAPTIVE = 4 - - private var currentANCMode = ANC_MODE_NOISE_CANCELLATION - - private const val ACTION_SET_ANC_MODE = "me.kavishdevar.librepods.SET_ANC_MODE" - private const val EXTRA_ANC_MODE = "anc_mode" - private const val ANIMATION_DURATION = 250L - - private fun addAirPodsControlsToDialog(volumeDialog: Any) { - try { - val contextField = volumeDialog.javaClass.getDeclaredField("mContext") - contextField.isAccessible = true - val context = contextField.get(volumeDialog) as Context - - val dialogViewField = volumeDialog.javaClass.getDeclaredField("mDialogView") - dialogViewField.isAccessible = true - val dialogView = dialogViewField.get(volumeDialog) as ViewGroup - - val dialogRowsViewField = volumeDialog.javaClass.getDeclaredField("mDialogRowsView") - dialogRowsViewField.isAccessible = true - val dialogRowsView = dialogRowsViewField.get(volumeDialog) as ViewGroup - - Log.d(TAG, "Found dialogRowsView: ${dialogRowsView.javaClass.name}") - - val existingContainer = dialogView.findViewWithTag("airpods_container") - if (existingContainer != null) { - Log.d(TAG, "AirPods container already exists, ensuring visibility state") - val drawer = existingContainer.findViewWithTag("airpods_drawer_container") - drawer?.visibility = View.GONE - drawer?.alpha = 0f - drawer?.translationY = 0f - val button = existingContainer.findViewWithTag("airpods_button") - button?.visibility = View.VISIBLE - button?.alpha = 1f - if (button != null) { - updateMainButtonIcon(context, button, currentANCMode) - } - return - } - - val newAirPodsButton = ImageButton(context).apply { - tag = "airpods_button" - - try { - val airPodsPackage = context.createPackageContext( - "me.kavishdevar.librepods", - Context.CONTEXT_IGNORE_SECURITY - ) - val airPodsIconRes = airPodsPackage.resources.getIdentifier( - "airpods", "drawable", "me.kavishdevar.librepods") - - if (airPodsIconRes != 0) { - val airPodsDrawable = airPodsPackage.resources.getDrawable( - airPodsIconRes, airPodsPackage.theme) - setImageDrawable(airPodsDrawable) - } else { - setImageResource(android.R.drawable.ic_media_play) - Log.d(TAG, "Using fallback icon because airpods icon resource not found") - } - } catch (e: Exception) { - setImageResource(android.R.drawable.ic_media_play) - Log.e(TAG, "Failed to load AirPods icon: ${e.message}") - } - - val shape = GradientDrawable() - shape.shape = GradientDrawable.RECTANGLE - shape.setColor(Color.BLACK) - background = shape - - imageTintList = ColorStateList.valueOf(Color.WHITE) - scaleType = ImageView.ScaleType.CENTER_INSIDE - - setPadding(24, 24, 24, 24) - - val params = LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - 90 - ) - params.gravity = Gravity.CENTER - params.setMargins(0, 0, 0, 0) - layoutParams = params - - setOnClickListener { - Log.d(TAG, "AirPods button clicked, toggling drawer") - val container = findAirPodsContainer(this) - val drawerContainer = container?.findViewWithTag("airpods_drawer_container") - if (drawerContainer != null && container != null) { - if (drawerContainer.visibility == View.VISIBLE) { - hideAirPodsDrawer(container, this, drawerContainer) - } else { - showAirPodsDrawer(container, this, drawerContainer) - } - } else { - Log.e(TAG, "Could not find container or drawer for toggle") - } - } - - contentDescription = "AirPods Settings" - } - - val airPodsContainer = FrameLayout(context).apply { - layoutParams = LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ) - tag = "airpods_container" - } - - newAirPodsButton.setOnLongClickListener { - Log.d(TAG, "AirPods button long-pressed, opening QuickSettingsDialogActivity") - val intent = Intent().apply { - setClassName("me.kavishdevar.librepods", "me.kavishdevar.librepods.QuickSettingsDialogActivity") - flags = Intent.FLAG_ACTIVITY_NEW_TASK - } - context.startActivity(intent) - try { - val dismissMethod = volumeDialog.javaClass.getMethod("dismissH") - dismissMethod.invoke(volumeDialog) - } catch (e: Exception) { - Log.w(TAG, "Could not dismiss volume dialog: ${e.message}") - } - true - } - - val airPodsDrawer = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - layoutParams = FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.WRAP_CONTENT - ).apply { - gravity = Gravity.TOP - } - tag = "airpods_drawer_container" - visibility = View.GONE - alpha = 0f - - val drawerShape = GradientDrawable() - drawerShape.shape = GradientDrawable.RECTANGLE - drawerShape.setColor(Color.BLACK) - background = drawerShape - - setPadding(16, 8, 16, 8) - } - - val buttonContainer = LinearLayout(context).apply { - orientation = LinearLayout.VERTICAL - layoutParams = FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.WRAP_CONTENT - ).apply { - gravity = Gravity.TOP - } - tag = "airpods_button_container" - } - - val modes = listOf(ANC_MODE_OFF, ANC_MODE_TRANSPARENCY, ANC_MODE_ADAPTIVE, ANC_MODE_NOISE_CANCELLATION) - for (mode in modes) { - val modeOption = createAncModeOption(context, mode, mode == currentANCMode, newAirPodsButton) - airPodsDrawer.addView(modeOption) - } - - buttonContainer.addView(newAirPodsButton) - - airPodsContainer.addView(airPodsDrawer) - airPodsContainer.addView(buttonContainer) - - val settingsViewField = try { - val field = volumeDialog.javaClass.getDeclaredField("mSettingsView") - field.isAccessible = true - field.get(volumeDialog) as? View - } catch (e: Exception) { - Log.e(TAG, "Failed to get settings view field: ${e.message}") - null - } - - if (settingsViewField != null && settingsViewField.parent is ViewGroup) { - val settingsParent = settingsViewField.parent as ViewGroup - val settingsIndex = findViewIndexInParent(settingsParent, settingsViewField) - - if (settingsIndex >= 0) { - settingsParent.addView(airPodsContainer, settingsIndex) - Log.i(TAG, "Added AirPods controls before settings button") - } else { - settingsParent.addView(airPodsContainer) - Log.i(TAG, "Added AirPods controls to the end of settings parent") - } - } else { - dialogView.addView(airPodsContainer) - Log.i(TAG, "Fallback: Added AirPods controls to dialog view") - } - - updateMainButtonIcon(context, newAirPodsButton, currentANCMode) - - Log.i(TAG, "Successfully added AirPods button and drawer to volume dialog") - } catch (e: Exception) { - Log.e(TAG, "Error adding AirPods button to volume panel: ${e.message}") - e.printStackTrace() - } - } - - private fun findViewIndexInParent(parent: ViewGroup, view: View): Int { - for (i in 0 until parent.childCount) { - if (parent.getChildAt(i) == view) { - return i - } - } - return -1 - } - - private fun updateMainButtonIcon(context: Context, button: ImageButton, mode: Int) { - try { - val pkgContext = context.createPackageContext( - "me.kavishdevar.librepods", - Context.CONTEXT_IGNORE_SECURITY - ) - - val resName = when (mode) { - ANC_MODE_OFF -> "noise_cancellation" - ANC_MODE_TRANSPARENCY -> "transparency" - ANC_MODE_ADAPTIVE -> "adaptive" - ANC_MODE_NOISE_CANCELLATION -> "noise_cancellation" - else -> "noise_cancellation" - } - - val resId = pkgContext.resources.getIdentifier( - resName, "drawable", "me.kavishdevar.librepods" - ) - - if (resId != 0) { - val drawable = pkgContext.resources.getDrawable(resId, pkgContext.theme) - button.setImageDrawable(drawable) - button.setColorFilter(Color.WHITE) - } else { - button.setImageResource(getIconResourceForMode(mode)) - button.setColorFilter(Color.WHITE) - } - } catch (e: Exception) { - button.setImageResource(getIconResourceForMode(mode)) - button.setColorFilter(Color.WHITE) - } - } - - private fun createAncModeOption(context: Context, mode: Int, isSelected: Boolean, mainButton: ImageButton): LinearLayout { - return LinearLayout(context).apply { - orientation = LinearLayout.HORIZONTAL - layoutParams = LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ).apply { - setMargins(0, 6, 0, 6) - } - gravity = Gravity.CENTER - setPadding(24, 16, 24, 16) - tag = "anc_mode_${mode}" - - val icon = ImageView(context).apply { - layoutParams = LinearLayout.LayoutParams(60, 60).apply { - gravity = Gravity.CENTER - } - tag = "mode_icon_$mode" - - try { - val packageContext = context.createPackageContext( - "me.kavishdevar.librepods", - Context.CONTEXT_IGNORE_SECURITY - ) - - val resourceName = when (mode) { - ANC_MODE_OFF -> "noise_cancellation" - ANC_MODE_TRANSPARENCY -> "transparency" - ANC_MODE_ADAPTIVE -> "adaptive" - ANC_MODE_NOISE_CANCELLATION -> "noise_cancellation" - else -> "noise_cancellation" - } - - val resourceId = packageContext.resources.getIdentifier( - resourceName, "drawable", "me.kavishdevar.librepods" - ) - - if (resourceId != 0) { - val drawable = packageContext.resources.getDrawable( - resourceId, packageContext.theme - ) - setImageDrawable(drawable) - } else { - setImageResource(getIconResourceForMode(mode)) - } - } catch (e: Exception) { - setImageResource(getIconResourceForMode(mode)) - Log.e(TAG, "Failed to load custom drawable for mode $mode: ${e.message}") - } - - if (isSelected) { - setColorFilter(Color.BLACK) - } else { - setColorFilter(Color.WHITE) - } - } - - addView(icon) - - background = if (isSelected) { - createSelectedBackground(context) - } else { - null - } - - setOnClickListener { - Log.d(TAG, "ANC mode selected: $mode (was: $currentANCMode)") - val container = findAirPodsContainer(this) - val drawerContainer = container?.findViewWithTag("airpods_drawer_container") - - if (currentANCMode == mode) { - if (drawerContainer != null && container != null) { - hideAirPodsDrawer(container, mainButton, drawerContainer) - } - return@setOnClickListener - } - - currentANCMode = mode - - val parentDrawer = parent as? ViewGroup - if (parentDrawer != null) { - for (i in 0 until parentDrawer.childCount) { - val child = parentDrawer.getChildAt(i) as? LinearLayout - if (child != null && child.tag.toString().startsWith("anc_mode_")) { - val childModeStr = child.tag.toString().substringAfter("anc_mode_") - val childMode = childModeStr.toIntOrNull() ?: -1 - val childIcon = child.findViewWithTag("mode_icon_${childMode}") - - if (childMode == mode) { - child.background = createSelectedBackground(context) - childIcon?.setColorFilter(Color.BLACK) - } else { - child.background = null - childIcon?.setColorFilter(Color.WHITE) - } - } - } - } - - val intent = Intent(ACTION_SET_ANC_MODE).apply { - setPackage("me.kavishdevar.librepods") - putExtra(EXTRA_ANC_MODE, mode) - } - context.sendBroadcast(intent) - Log.d(TAG, "Sent broadcast to change ANC mode to: ${getLabelForMode(currentANCMode)}") - - - updateMainButtonIcon(context, mainButton, mode) - - if (drawerContainer != null && container != null) { - android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ - hideAirPodsDrawer(container, mainButton, drawerContainer) - }, 50) - } - } - } - } - - private fun createSelectedBackground(context: Context): GradientDrawable { - return GradientDrawable().apply { - shape = GradientDrawable.RECTANGLE - setColor(Color.WHITE) - cornerRadius = 50f - } - } - - private fun findAirPodsContainer(view: View): ViewGroup? { - var current: View? = view - while (current != null) { - if (current is ViewGroup && current.tag == "airpods_container") { - return current - } - val parent = current.parent - if (parent is ViewGroup && parent.tag == "airpods_container") { - return parent - } - current = parent as? View - } - Log.w(TAG, "Could not find airpods_container ancestor") - return null - } - - private fun showAirPodsDrawer(container: ViewGroup, mainButton: ImageButton, drawerContainer: View) { - Log.d(TAG, "Showing AirPods drawer") - val selectedModeView = drawerContainer.findViewWithTag("anc_mode_$currentANCMode") - val selectedModeIcon = selectedModeView?.findViewWithTag("mode_icon_$currentANCMode") - val buttonContainer = container.findViewWithTag("airpods_button_container") - - if (selectedModeView == null || selectedModeIcon == null) { - Log.e(TAG, "Cannot find selected mode view or icon for show animation") - - drawerContainer.alpha = 0f - drawerContainer.visibility = View.VISIBLE - - drawerContainer.animate() - .alpha(1f) - .setDuration(ANIMATION_DURATION) - .start() - - buttonContainer?.animate() - ?.alpha(0f) - ?.setDuration(ANIMATION_DURATION / 2) - ?.setStartDelay(ANIMATION_DURATION / 2) - ?.withEndAction { - buttonContainer.visibility = View.GONE - } - ?.start() - - return - } - - drawerContainer.measure( - View.MeasureSpec.makeMeasureSpec(container.width, View.MeasureSpec.EXACTLY), - View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) - ) - - val drawerHeight = drawerContainer.measuredHeight - - drawerContainer.alpha = 0f - drawerContainer.visibility = View.VISIBLE - drawerContainer.translationY = -drawerHeight.toFloat() - - drawerContainer.animate() - .translationY(0f) - .alpha(1f) - .setDuration(ANIMATION_DURATION) - .setInterpolator(DecelerateInterpolator()) - .start() - - buttonContainer?.animate() - ?.alpha(0f) - ?.setDuration(ANIMATION_DURATION / 2) - ?.setStartDelay(ANIMATION_DURATION / 3) - ?.withEndAction { - buttonContainer.visibility = View.GONE - } - ?.start() - } - - private fun hideAirPodsDrawer(container: ViewGroup, mainButton: ImageButton, drawerContainer: View) { - Log.d(TAG, "Hiding AirPods drawer") - val buttonContainer = container.findViewWithTag("airpods_button_container") - - if (buttonContainer != null && buttonContainer.visibility != View.VISIBLE) { - buttonContainer.alpha = 0f - buttonContainer.visibility = View.VISIBLE - } - - buttonContainer?.animate() - ?.alpha(1f) - ?.setDuration(ANIMATION_DURATION / 2) - ?.start() - - drawerContainer.animate() - .translationY(-drawerContainer.height.toFloat()) - .alpha(0f) - .setDuration(ANIMATION_DURATION) - .setInterpolator(AccelerateInterpolator()) - .setStartDelay(ANIMATION_DURATION / 4) - .withEndAction { - drawerContainer.visibility = View.GONE - drawerContainer.translationY = 0f - } - .start() - } - - private fun getIconResourceForMode(mode: Int): Int { - return when (mode) { - ANC_MODE_OFF -> android.R.drawable.ic_lock_silent_mode - ANC_MODE_TRANSPARENCY -> android.R.drawable.ic_lock_silent_mode_off - ANC_MODE_ADAPTIVE -> android.R.drawable.ic_menu_compass - ANC_MODE_NOISE_CANCELLATION -> android.R.drawable.ic_lock_idle_charging - else -> android.R.drawable.ic_lock_silent_mode_off - } - } - - private fun getLabelForMode(mode: Int): String { - return when (mode) { - ANC_MODE_OFF -> "Off" - ANC_MODE_TRANSPARENCY -> "Transparency" - ANC_MODE_ADAPTIVE -> "Adaptive" - ANC_MODE_NOISE_CANCELLATION -> "Noise Cancellation" - else -> "Unknown" - } - } - } }