From c1a3c8b5ce839c3dd22bd32c97338cb571051d8a Mon Sep 17 00:00:00 2001 From: Hepolise Date: Sun, 21 Jun 2026 17:20:14 +0300 Subject: [PATCH] refactor: migrate module to LibXposed and consolidate media hooks - migrate from legacy Xposed API to LibXposed - replace dual media session hooks with a single hook entrypoint - extract media/session/state handling into dedicated components - add hot reload support for hook updates - simplify module initialization flow - remove obsolete Xposed infrastructure and boot hooks - update build configuration and resources --- app/build.gradle.kts | 34 +- app/proguard-rules.pro | 42 +- app/src/main/AndroidManifest.xml | 32 +- .../ru/hepolise/volumekeytrackcontrol/App.kt | 82 +++ .../module/MediaEvent.kt | 130 ++++ .../module/VolumeControlModule.kt | 231 +++++--- .../module/VolumeKeyControlModuleHandlers.kt | 560 ------------------ .../module/util/LogHelper.kt | 10 - .../module/util/MediaSessionManager.kt | 126 ++++ .../module/util/RemotePrefsHelper.kt | 4 +- .../module/util/StateManager.kt | 47 ++ .../module/util/StatusHelper.kt | 68 --- .../module/util/Utils.kt | 29 + .../module/util/VolumeHandlerState.kt | 25 - .../module/util/VolumeKeyHandler.kt | 281 +++++++++ .../provider/RemotePrefProvider.kt | 2 +- .../receiver/BootReceiver.kt | 17 - .../repository/BootRepository.kt | 71 --- .../service/RewindActionTileService.kt | 22 +- .../ui/LocalHotReloadResult.kt | 6 + .../ui/LocalModuleScope.kt | 5 + .../ui/LocalXposedService.kt | 6 + .../ui/SettingsActivity.kt | 104 +++- .../hepolise/volumekeytrackcontrol/ui/Util.kt | 21 +- .../ui/component/LongPressActionSetting.kt | 2 +- .../ui/component/LongPressSetting.kt | 2 +- .../ui/component/ModuleInfoCard.kt | 400 +++++++++++++ .../ui/component/NumberAlertDialog.kt | 2 +- .../ui/component/SwapButtonsSetting.kt | 2 +- .../ui/component/VibrationEffectSetting.kt | 11 +- .../ui/navigation/AppNavigation.kt | 18 +- .../ui/screen/AppFilterScreen.kt | 2 +- .../ui/screen/SettingsScreen.kt | 202 ++----- .../util/AppFilterType.kt | 2 +- .../volumekeytrackcontrol/util/Constants.kt | 1 - .../util/LSPosedLogger.kt | 10 - .../util/SharedPreferencesUtil.kt | 38 +- .../util/StatusSysPropsHelper.kt | 44 -- .../volumekeytrackcontrol/util/SystemProps.kt | 30 - .../util/VibratorUtil.kt | 2 +- .../viewmodel/BootViewModel.kt | 70 --- .../viewmodel/BootViewModelFactory.kt | 17 - app/src/main/res/values-ru-rRU/strings.xml | 9 + app/src/main/res/values-zh-rCN/strings.xml | 9 + app/src/main/res/values-zh-rTW/strings.xml | 9 + app/src/main/res/values/module_scope.xml | 6 - app/src/main/res/values/strings.xml | 9 + .../META-INF/xposed/java_init.list} | 0 .../resources/META-INF/xposed/module.prop | 4 + .../main/resources/META-INF/xposed/scope.list | 1 + build.gradle.kts | 4 +- 51 files changed, 1516 insertions(+), 1345 deletions(-) create mode 100644 app/src/main/java/ru/hepolise/volumekeytrackcontrol/App.kt create mode 100644 app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/MediaEvent.kt delete mode 100644 app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/VolumeKeyControlModuleHandlers.kt delete mode 100644 app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/LogHelper.kt create mode 100644 app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/MediaSessionManager.kt create mode 100644 app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/StateManager.kt delete mode 100644 app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/StatusHelper.kt create mode 100644 app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/Utils.kt delete mode 100644 app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/VolumeHandlerState.kt create mode 100644 app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/VolumeKeyHandler.kt delete mode 100644 app/src/main/java/ru/hepolise/volumekeytrackcontrol/receiver/BootReceiver.kt delete mode 100644 app/src/main/java/ru/hepolise/volumekeytrackcontrol/repository/BootRepository.kt create mode 100644 app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/LocalHotReloadResult.kt create mode 100644 app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/LocalModuleScope.kt create mode 100644 app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/LocalXposedService.kt create mode 100644 app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/ModuleInfoCard.kt delete mode 100644 app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/LSPosedLogger.kt delete mode 100644 app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/StatusSysPropsHelper.kt delete mode 100644 app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/SystemProps.kt delete mode 100644 app/src/main/java/ru/hepolise/volumekeytrackcontrol/viewmodel/BootViewModel.kt delete mode 100644 app/src/main/java/ru/hepolise/volumekeytrackcontrol/viewmodel/BootViewModelFactory.kt delete mode 100644 app/src/main/res/values/module_scope.xml rename app/src/main/{assets/xposed_init => resources/META-INF/xposed/java_init.list} (100%) create mode 100644 app/src/main/resources/META-INF/xposed/module.prop create mode 100644 app/src/main/resources/META-INF/xposed/scope.list diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0c36eda..84f7268 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,43 +1,44 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - plugins { id("com.android.application") id("org.jetbrains.kotlin.plugin.compose") } android { - compileSdk = 36 + compileSdk = 37 + buildToolsVersion = "37.0.0" defaultConfig { applicationId = "ru.hepolise.volumekeymusicmanagermodule" minSdk = 27 - targetSdk = 36 + targetSdk = 37 versionCode = rootProject.ext["appVersionCode"].toString().toInt() versionName = rootProject.ext["appVersionName"].toString() } buildTypes { - getByName("release") { + release { isMinifyEnabled = true - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + isShrinkResources = true + proguardFiles("proguard-rules.pro") } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) - } + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } - namespace = "ru.hepolise.volumekeytrackcontrolmodule" + namespace = "ru.hepolise.volumekeytrackcontrol" buildFeatures { buildConfig = true } + + packaging { + resources { + merges += "META-INF/xposed/*" + excludes += "**" + } + } } dependencies { @@ -66,7 +67,8 @@ dependencies { debugImplementation("androidx.compose.ui:ui-tooling") // Xposed Framework API dependencies - compileOnly("de.robv.android.xposed:api:82") + compileOnly("io.github.libxposed:api:102.0.0") + implementation("io.github.libxposed:service:102.0.0") // RemotePreferences implementation("com.crossbowffs.remotepreferences:remotepreferences:0.8") diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 481bb43..2992ec7 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,21 +1,21 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +-dontwarn io.github.libxposed.annotation.** +-adaptresourcefilecontents META-INF/xposed/java_init.list +-keep,allowoptimization,allowobfuscation public class * extends io.github.libxposed.api.XposedModule { + public (); +} +-dontwarn androidx.window.extensions.WindowExtensions +-dontwarn androidx.window.extensions.WindowExtensionsProvider +-dontwarn androidx.window.extensions.area.ExtensionWindowAreaPresentation +-dontwarn androidx.window.extensions.core.util.function.Consumer +-dontwarn androidx.window.extensions.core.util.function.Function +-dontwarn androidx.window.extensions.core.util.function.Predicate +-dontwarn androidx.window.extensions.layout.DisplayFeature +-dontwarn androidx.window.extensions.layout.FoldingFeature +-dontwarn androidx.window.extensions.layout.WindowLayoutComponent +-dontwarn androidx.window.extensions.layout.WindowLayoutInfo +-dontwarn androidx.window.sidecar.SidecarDeviceState +-dontwarn androidx.window.sidecar.SidecarDisplayFeature +-dontwarn androidx.window.sidecar.SidecarInterface$SidecarCallback +-dontwarn androidx.window.sidecar.SidecarInterface +-dontwarn androidx.window.sidecar.SidecarProvider +-dontwarn androidx.window.sidecar.SidecarWindowLayoutInfo \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0c8485c..f439b52 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,9 +6,10 @@ - @@ -29,23 +30,14 @@ - - - - - - - - - - - - diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/App.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/App.kt new file mode 100644 index 0000000..5023539 --- /dev/null +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/App.kt @@ -0,0 +1,82 @@ +package ru.hepolise.volumekeytrackcontrol + +import android.app.Application +import androidx.core.content.edit +import io.github.libxposed.service.XposedService +import io.github.libxposed.service.XposedServiceHelper +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getStatusSharedPreferences +import java.util.concurrent.CopyOnWriteArraySet +import kotlin.concurrent.Volatile + +class App : Application(), XposedServiceHelper.OnServiceListener { + + companion object { + @Volatile + var xposedService: XposedService? = null + private set + private val serviceStateListeners = + CopyOnWriteArraySet() + + private fun dispatchServiceState( + listener: ServiceStateListener, + service: XposedService? + ) { + if (serviceStateListeners.contains(listener)) { + listener.onServiceStateChanged(service) + } + } + + fun addServiceStateListener( + listener: ServiceStateListener, + notifyImmediately: Boolean + ) { + serviceStateListeners.add(listener) + if (notifyImmediately) { + dispatchServiceState(listener, xposedService) + } + } + + fun removeServiceStateListener(listener: ServiceStateListener) { + serviceStateListeners.remove(listener) + } + } + + private fun notifyServiceStateChanged(service: XposedService?) { + for (listener in serviceStateListeners) { + dispatchServiceState(listener, service) + } + } + + override fun onCreate() { + super.onCreate() + migratePreferences() + XposedServiceHelper.registerListener(this) + } + + interface ServiceStateListener { + fun onServiceStateChanged(service: XposedService?) + } + + override fun onServiceBind(service: XposedService) { + xposedService = service + notifyServiceStateChanged(xposedService) + } + + override fun onServiceDied(service: XposedService) { + xposedService = null + notifyServiceStateChanged(xposedService) + } + + private fun migratePreferences() { + val prefs = getStatusSharedPreferences() + val currentVersion = BuildConfig.VERSION_CODE + val savedVersion = prefs.getInt("app_version", 0) + + if (savedVersion < 19) { + prefs.edit { + clear() + putInt("app_version", currentVersion) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/MediaEvent.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/MediaEvent.kt new file mode 100644 index 0000000..4e10208 --- /dev/null +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/MediaEvent.kt @@ -0,0 +1,130 @@ +package ru.hepolise.volumekeytrackcontrol.module + +import android.content.SharedPreferences +import android.media.MediaMetadata +import android.media.session.MediaController +import ru.hepolise.volumekeytrackcontrol.module.util.StateManager +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getRewindDuration +import kotlin.math.max +import kotlin.math.min + +sealed class MediaEvent { + abstract fun execute(context: ExecutionContext): Boolean + + abstract fun canHandle(context: ExecutionContext): Boolean + + object PlayPause : MediaEvent() { + override fun canHandle(context: ExecutionContext): Boolean { + val state = context.stateManager + return state.isUpPressed && state.isDownPressed + } + + override fun execute(context: ExecutionContext): Boolean { + if (!canHandle(context)) { + context.logger("PlayPause canHandle = false, not sending") + return false + } + context.logger("Sending PlayPause") + if (context.controller.isMusicActive()) { + context.controls.pause() + } else { + context.controls.play() + } + return true + } + } + + abstract class BaseEvent : MediaEvent() { + abstract val isUpBtnEvent: Boolean + + override fun canHandle(context: ExecutionContext): Boolean { + context.logger("canHandle: isPrimary=${context.isPrimary}, event=${if (isUpBtnEvent) "UP" else "DOWN"}, state=${context.stateManager}") + if (!context.isPrimary) return true + val state = context.stateManager + val isPressed = if (isUpBtnEvent) state.isUpPressed else state.isDownPressed + return !isPressed + } + } + + object Next : BaseEvent() { + override val isUpBtnEvent = true + + override fun execute(context: ExecutionContext): Boolean { + if (!canHandle(context)) { + context.logger("Next canHandle = false, not sending") + return false + } + context.logger("Sending Next") + context.controls.skipToNext() + return true + } + } + + object Prev : BaseEvent() { + override val isUpBtnEvent = false + + override fun execute(context: ExecutionContext): Boolean { + if (!canHandle(context)) { + context.logger("Prev canHandle = false, not sending") + return false + } + context.logger("Sending Prev") + context.controls.skipToPrevious() + return true + } + } + + object FastForward : BaseEvent() { + override val isUpBtnEvent = true + + override fun execute(context: ExecutionContext): Boolean { + if (!canHandle(context)) { + context.logger("FastForward canHandle = false, not sending") + return false + } + context.logger("Sending FastForward") + val current = context.controller.playbackState?.position ?: 0L + val duration = context.controller.metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION) + ?: Long.MAX_VALUE + val newPos = min(current + context.prefs.getRewindDuration() * 1000L, duration) + context.controls.seekTo(newPos) + return true + } + } + + object Rewind : BaseEvent() { + override val isUpBtnEvent = false + + override fun execute(context: ExecutionContext): Boolean { + if (!canHandle(context)) { + context.logger("Rewind canHandle = false, not sending") + return false + } + context.logger("Sending Rewind") + val current = context.controller.playbackState?.position ?: 0L + val newPos = max(current - context.prefs.getRewindDuration() * 1000L, 0L) + context.controls.seekTo(newPos) + return true + } + } +} + +data class ExecutionContext( + val controller: MediaController, + val controls: MediaController.TransportControls, + val prefs: SharedPreferences, + val logger: (String) -> Unit, + val stateManager: StateManager, + val isPrimary: Boolean +) + +fun MediaController.isMusicActive(): Boolean { + return when (playbackState?.state) { + android.media.session.PlaybackState.STATE_PLAYING, + android.media.session.PlaybackState.STATE_FAST_FORWARDING, + android.media.session.PlaybackState.STATE_REWINDING, + android.media.session.PlaybackState.STATE_BUFFERING -> true + + else -> false + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/VolumeControlModule.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/VolumeControlModule.kt index a8ad82e..b9bd560 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/VolumeControlModule.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/VolumeControlModule.kt @@ -1,106 +1,175 @@ package ru.hepolise.volumekeytrackcontrol.module +import android.annotation.SuppressLint import android.content.Context +import android.os.Handler +import android.util.Log import android.view.KeyEvent -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.volumekeytrackcontrol.module.VolumeKeyControlModuleHandlers.handleConstructPhoneWindowManager -import ru.hepolise.volumekeytrackcontrol.module.VolumeKeyControlModuleHandlers.handleInterceptKeyBeforeQueueing -import ru.hepolise.volumekeytrackcontrol.module.util.LogHelper -import java.io.Serializable - -@Keep -class VolumeControlModule : IXposedHookLoadPackage { +import io.github.libxposed.api.XposedInterface +import io.github.libxposed.api.XposedModule +import io.github.libxposed.api.XposedModuleInterface +import ru.hepolise.volumekeytrackcontrol.module.util.MediaSessionManager +import ru.hepolise.volumekeytrackcontrol.module.util.StateManager +import ru.hepolise.volumekeytrackcontrol.module.util.VolumeKeyHandler +import ru.hepolise.volumekeytrackcontrol.module.util.getContext +import ru.hepolise.volumekeytrackcontrol.module.util.getHandler +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.SETTINGS_PREFS +class VolumeControlModule : XposedModule() { companion object { + const val TAG = "VolumeControl" + 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) - - 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}") - with(lpparam) { - when (packageName) { - "android" -> classLoader.init() - } - } + private lateinit var stateManager: StateManager + private lateinit var prefs: android.content.SharedPreferences + + private var interceptHookHandle: XposedInterface.HookHandle? = null + + private data class Runtime( + val context: Context, + val handler: Handler, + val mediaSessionManager: MediaSessionManager, + val volumeKeyHandler: VolumeKeyHandler + ) + + private var runtime: Runtime? = null + + private fun log(msg: String) = log(Log.INFO, TAG, msg) + + override fun onSystemServerStarting(param: XposedModuleInterface.SystemServerStartingParam) { + super.onSystemServerStarting(param) + log("onSystemServerStarting") + setupHooks(param.classLoader) + } + + override fun onHotReloading(param: XposedModuleInterface.HotReloadingParam): Boolean { + log(Log.INFO, TAG, "onHotReloading") + return interceptHookHandle != null } - private fun ClassLoader.init() { - initMethodSignatures.any { (params, logMessage) -> - tryHookInitMethod(params, logMessage) - }.also { hooked -> - if (!hooked) { - log("Method hook failed for init!") - return + override fun onHotReloaded(param: XposedModuleInterface.HotReloadedParam) { + log( + Log.INFO, + TAG, + "onHotReloaded: ${param.processName}, ${param.oldHookHandles.size} old hooks" + ) + + for (oldHandle in param.oldHookHandles) { + val executable = oldHandle.executable + if (executable.name == "interceptKeyBeforeQueueing") { + val newHooker = createInterceptHooker() + interceptHookHandle = oldHandle.replaceHook(newHooker) + log("Replaced interceptKeyBeforeQueueing hook") + } else { + oldHandle.unhook() } } - try { - // 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, - this, + stateManager = StateManager() + prefs = getRemotePreferences(SETTINGS_PREFS) + } + + private fun setupHooks(classLoader: ClassLoader) { + log("Setting up hooks") + + stateManager = StateManager() + prefs = getRemotePreferences(SETTINGS_PREFS) + + interceptHookHandle = hookInterceptKeyBeforeQueueing(classLoader) + } + + @SuppressLint("PrivateApi") + private fun hookInterceptKeyBeforeQueueing(classLoader: ClassLoader): XposedInterface.HookHandle? { + return try { + val clazz = Class.forName(CLASS_PHONE_WINDOW_MANAGER, true, classLoader) + val method = clazz.getDeclaredMethod( "interceptKeyBeforeQueueing", KeyEvent::class.java, - Int::class.javaPrimitiveType, - handleInterceptKeyBeforeQueueing + Int::class.javaPrimitiveType ) + + val handle = hook(method).intercept(createInterceptHooker()) + log("Hooked interceptKeyBeforeQueueing") + handle } catch (t: Throwable) { - log("Method hook failed for interceptKeyBeforeQueueing!") - t.message?.let { log(it) } + log("Failed to hook interceptKeyBeforeQueueing: ${t.message}") + log(t.stackTraceToString()) + null } + } + + private fun createInterceptHooker(): XposedInterface.Hooker { + return XposedInterface.Hooker { chain -> + val event = chain.args[0] as KeyEvent + + val context = try { + chain.getContext() + } catch (e: Throwable) { + log("Failed to get context: ${e.message}") + log(e.stackTraceToString()) + throw e + } + + val runtime = chain.getRuntime(context) + try { + val volumeKeyHandler = runtime.volumeKeyHandler + + volumeKeyHandler.refreshControllers() + if (volumeKeyHandler.shouldIntercept(event)) { + log("Intercepting key event: ${event.keyCode}") + volumeKeyHandler.handleKeyEvent(event) + return@Hooker null + } else { + volumeKeyHandler.logInterceptDecision(event) + } + } catch (e: Throwable) { + log("Error handling key event: ${e.message}") + log(e.stackTraceToString()) + } + + chain.proceed() + } } - private fun ClassLoader.tryHookInitMethod( - params: Array, - logMessage: String - ): Boolean { - return try { - XposedHelpers.findAndHookMethod( - CLASS_PHONE_WINDOW_MANAGER, this, "init", - *params, handleConstructPhoneWindowManager - ) - log(logMessage) - true - } catch (_: NoSuchMethodError) { - false + private fun XposedInterface.Chain.getRuntime(context: Context): Runtime { + runtime?.let { existing -> + if (existing.context === context) { + return existing + } + + log("Context changed, recreating runtime") + } + + val mediaSessionManager = MediaSessionManager(context) + + val handler = try { + getHandler() + } catch (e: Throwable) { + log("Failed to get handler: ${e.message}") + log(e.stackTraceToString()) + throw e + } + + val volumeKeyHandler = VolumeKeyHandler( + context = context, + handler = handler, + stateManager = stateManager, + mediaSessionManager = mediaSessionManager, + prefs = prefs, + logger = ::log + ) + + return Runtime( + context = context, + handler = handler, + mediaSessionManager = mediaSessionManager, + volumeKeyHandler = volumeKeyHandler + ).also { + runtime = it } } -} +} \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/VolumeKeyControlModuleHandlers.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/VolumeKeyControlModuleHandlers.kt deleted file mode 100644 index 7f9de69..0000000 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/VolumeKeyControlModuleHandlers.kt +++ /dev/null @@ -1,560 +0,0 @@ -package ru.hepolise.volumekeytrackcontrol.module - -import android.content.Context -import android.content.SharedPreferences -import android.hardware.display.DisplayManager -import android.media.AudioManager -import android.media.MediaMetadata -import android.media.session.MediaController -import android.media.session.PlaybackState -import android.os.Handler -import android.os.PowerManager -import android.os.Vibrator -import android.view.Display -import android.view.KeyEvent -import androidx.core.content.edit -import de.robv.android.xposed.XC_MethodHook -import de.robv.android.xposed.XC_MethodHook.MethodHookParam -import de.robv.android.xposed.XposedHelpers -import kotlinx.coroutines.Runnable -import ru.hepolise.volumekeytrackcontrol.module.util.LogHelper -import ru.hepolise.volumekeytrackcontrol.module.util.RemotePrefsHelper -import ru.hepolise.volumekeytrackcontrol.module.util.State -import ru.hepolise.volumekeytrackcontrol.module.util.StatusHelper -import ru.hepolise.volumekeytrackcontrol.util.AppFilterType -import ru.hepolise.volumekeytrackcontrol.util.RewindActionType -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.LAUNCHED_COUNT -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getAppFilterType -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getApps -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getLaunchedCount -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getLongPressDuration -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getRewindActionType -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getRewindDuration -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.isAddSecondaryAction -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.isSwapButtons -import ru.hepolise.volumekeytrackcontrol.util.VibratorUtil.getVibrator -import ru.hepolise.volumekeytrackcontrol.util.VibratorUtil.triggerVibration -import kotlin.concurrent.Volatile -import kotlin.math.max -import kotlin.math.min - - -object VolumeKeyControlModuleHandlers { - private const val CLASS_MEDIA_SESSION_LEGACY_HELPER = - "android.media.session.MediaSessionLegacyHelper" - private const val CLASS_COMPONENT_NAME = "android.content.ComponentName" - - @Volatile - private var state = State() - - private lateinit var audioManager: AudioManager - private lateinit var powerManager: PowerManager - private lateinit var displayManager: DisplayManager - private lateinit var vibrator: Vibrator - private lateinit var sessionHelper: Any - - private var prefs: SharedPreferences? = null - - private var mediaControllers: List? = null - - private val logBuffer = StringBuilder() - - private fun bufferedLog(text: String) { - logBuffer - .append("[${Thread.currentThread().name}] ") - .append(text) - .append("\n") - } - - private fun endLog() { - if (logBuffer.isEmpty()) return - LogHelper.log(VolumeControlModule::class.java.simpleName, logBuffer.toString()) - logBuffer.clear() - } - - 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 handler = param.getHandler() - handler.postDelayed(::endLog, 1_000) - initPrefs() - with(param) { - val event = args[0] as KeyEvent - try { - with(getContext()) { - initManagers() - initControllers() - } - } catch (t: Throwable) { - log("init failed") - t.printStackTrace() - return - } - updateFlags(event) - if (needHook(event)) { - doHook(event) - } - } - } - } - - 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(param) } - XposedHelpers.setAdditionalInstanceField( - param.thisObject, - event.fieldName, - runnable - ) - } - - StatusHelper.handleSuccessHook(context) - } - } - - private fun needHook(event: KeyEvent): Boolean { - val keyCode = event.keyCode - bufferedLog("========") - bufferedLog("audioManager mode: ${audioManager.mode}, required: ${AudioManager.MODE_NORMAL}") - bufferedLog("keyCode: ${keyCode}, required: ${KeyEvent.KEYCODE_VOLUME_DOWN} or ${KeyEvent.KEYCODE_VOLUME_UP}") - bufferedLog("displayInteractive: ${isDisplayInteractive()}, required: false") - bufferedLog("isDownPressed: ${state.isDownPressed}") - bufferedLog("isUpPressed: ${state.isUpPressed}") - bufferedLog("hasPendingEvent: ${state.pendingEvent != null}") - bufferedLog("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() - bufferedLog("needHook: $needHook") - return needHook - } - - private fun isDisplayInteractive(): Boolean { - bufferedLog("powerManager.isInteractive: ${powerManager.isInteractive}") - if (!powerManager.isInteractive) { - return false - } - bufferedLog("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) - bufferedLog("Checking display: ${display.displayId}, state: ${display.state}, required: $disabledStates") - return !disabledStates.contains(display.state) - } - - private fun Context.initManagers() { - 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 = getVibrator() - sessionHelper = getMediaSessionLegacyHelper() - } - - private fun initPrefs() { - prefs = SharedPreferencesUtil.prefs() - } - - private fun Context.getMediaSessionLegacyHelper(): Any { - val context = this - val classLoader = javaClass.classLoader - val mediaSessionHelperClass = XposedHelpers.findClass( - CLASS_MEDIA_SESSION_LEGACY_HELPER, - classLoader - ) - return XposedHelpers.callStaticMethod( - mediaSessionHelperClass, - "getHelper", - context - ) - } - - private fun Context.initControllers() { - val sessionManager = XposedHelpers.getObjectField(sessionHelper, "mSessionManager") - val componentNameClass = - XposedHelpers.findClass(CLASS_COMPONENT_NAME, classLoader) - - @Suppress("UNCHECKED_CAST") - mediaControllers = XposedHelpers.callMethod( - sessionManager, - "getActiveSessions", - arrayOf(componentNameClass), - null - ) as List? - } - - private fun MethodHookParam.doHook(event: KeyEvent) { - event.applyForAction { - val pendingEvent = state.pendingEvent - if (pendingEvent != null) { - bufferedLog("Already having a pending event, using it") - pendingEvent.doHandle(this@doHook) - } else { - when (it) { - Action.PRESSED -> handlePressedAction(this) - Action.UNPRESSED -> handleUnpressedAction(this) - } - } - setResult(0) - } - } - - private fun MethodHookParam.handlePressedAction(keyHelper: KeyHelper) { - bufferedLog("Volume pressed action received, down: ${state.isDownPressed}, up: ${state.isUpPressed}") - updateState { isLongPress = false } - if (state.isUpPressed && state.isDownPressed) { - bufferedLog("Aborting delayed skip") - abortSkip() - } else { - if (getMediaController().isMusicActive()) { - bufferedLog("Music is active, creating delayed skip") - val event = keyHelper.getMatchingMediaEvent() - delay(event) - event.getSecondaryEvent()?.also { - delay(it, 2.4) - } - } - bufferedLog("Creating delayed play pause") - delay(MediaEvent.PlayPause) - } - } - - private fun MethodHookParam.handleUnpressedAction(keyHelper: KeyHelper) { - bufferedLog("Volume unpressed action received, down: ${state.isDownPressed}, up: ${state.isUpPressed}") - abortAll() - val mediaController = getMediaController() - bufferedLog("isMusicActive: ${mediaController.isMusicActive()}") - if (!state.isLongPress && mediaController.isMusicActive()) { - bufferedLog("Adjusting stream volume") - keyHelper.adjustStreamVolume(getHandler()) - } - } - - private fun hasActiveMediaController(): Boolean { - val first = getFirstMediaController() - val active = getMediaController() - if (first != active && first != null) { - return !first.isMusicActive() - } - return active != null - } - - private fun getFirstMediaController(): MediaController? { - return mediaControllers?.firstOrNull() - ?.also { bufferedLog("First media controller: ${it.packageName}") } - } - - private fun getMediaController(): MediaController? { - val filterType = prefs.getAppFilterType() - val apps = prefs.getApps(filterType) - return mediaControllers - ?.sortedByDescending { it.isMusicActive() } - ?.find { - when (filterType) { - AppFilterType.DISABLED -> true - AppFilterType.WHITE_LIST -> it.packageName in apps - AppFilterType.BLACK_LIST -> it.packageName !in apps - } - } - ?.also { bufferedLog("Chosen media controller: ${it.packageName}") } - } - - private fun MediaController?.isMusicActive() = when (this?.playbackState?.state) { - PlaybackState.STATE_PLAYING, - PlaybackState.STATE_FAST_FORWARDING, - PlaybackState.STATE_REWINDING, - PlaybackState.STATE_BUFFERING -> true - - else -> false - } - - private fun MethodHookParam.delay(event: MediaEvent, multiplier: Double = 1.0) { - val handler = getHandler() - handler.postDelayed( - getRunnable(event.fieldName), - (prefs.getLongPressDuration().toDouble() * multiplier).toLong() - ) - } - - private fun MethodHookParam.abortSkip() { - bufferedLog("Aborting skip") - abortEvents(MediaEvent.Prev, MediaEvent.Next) - } - - private fun MethodHookParam.abortAll() { - bufferedLog("Aborting all") - updateState { pendingEvent = null } - abortEvents(*MediaEvent.entries.toTypedArray()) - } - - private fun MethodHookParam.abortEvents(vararg events: MediaEvent) { - val handler = getHandler() - events.forEach { event -> - handler.removeCallbacks(getRunnable(event.fieldName)) - } - } - - private fun MethodHookParam.getRunnable(fieldName: String) = - XposedHelpers.getAdditionalInstanceField(thisObject, fieldName) as Runnable - - private fun MethodHookParam.getContext() = getObjectField("mContext") as Context - - private fun MethodHookParam.getHandler() = getObjectField("mHandler") as Handler - - private fun MethodHookParam.getObjectField(fieldName: String) = - XposedHelpers.getObjectField(thisObject, fieldName) - - sealed class MediaEvent { - val fieldName = - "_volumeKeyControlRunnable_${javaClass.simpleName.replaceFirstChar { it.lowercase() }}" - - protected lateinit var controller: MediaController - protected lateinit var controls: MediaController.TransportControls - - fun handle(param: MethodHookParam) { - updateState { isLongPress = true } - if (initController()) { - vibrator.triggerVibration() - if (getSecondaryEvent() != null) { - updateState { pendingEvent = this@MediaEvent } - } else { - doHandle(param) - } - } - } - - @Synchronized - fun doHandle(param: MethodHookParam) { - val context = param.getContext() - bufferedLog("canHandle[${this::class.simpleName}]: ${canHandle()}") - if (canHandle()) { - bufferedLog("Sending ${this::class.simpleName}") - param.abortAll() - doHandle() - runCatching { - RemotePrefsHelper.withRemotePrefs(context) { - val count = getLaunchedCount() - edit { - putInt(LAUNCHED_COUNT, count + 1) - } - } - } - } else { - bufferedLog("Not sending ${this::class.simpleName}, down: ${state.isDownPressed}, up: ${state.isUpPressed}") - } - } - - abstract fun canHandle(): Boolean - - abstract fun doHandle() - - open fun getSecondaryEvent(): MediaEvent? = null - - private fun initController(): Boolean { - return getMediaController()?.let { - controller = it - controls = it.transportControls - true - } ?: false - } - - object PlayPause : MediaEvent() { - override fun canHandle() = state.isUpPressed && state.isDownPressed - - override fun doHandle() { - if (controller.isMusicActive()) controls.pause() else controls.play() - } - } - - abstract class BaseEvent : MediaEvent() { - abstract val isUpBtnEvent: Boolean - - abstract fun isPrimary(): Boolean - - override fun canHandle(): Boolean { - val isPressed = if (isUpBtnEvent) state.isUpPressed else state.isDownPressed - return if (isPrimary()) !isPressed else true - } - } - - abstract class BaseSkipTrackEvent : BaseEvent() { - override fun isPrimary() = - prefs.isAddSecondaryAction() && prefs.getRewindActionType() == RewindActionType.TRACK_CHANGE - } - - abstract class BaseRewindEvent : BaseEvent() { - override fun isPrimary() = - prefs.isAddSecondaryAction() && prefs.getRewindActionType() == RewindActionType.REWIND - } - - object Next : BaseSkipTrackEvent() { - override val isUpBtnEvent = true - - override fun getSecondaryEvent() = FastForward.takeIf { isPrimary() } - - override fun doHandle() { - controls.skipToNext() - } - } - - object Prev : BaseSkipTrackEvent() { - override val isUpBtnEvent = false - - override fun getSecondaryEvent() = Rewind.takeIf { isPrimary() } - - override fun doHandle() { - controls.skipToPrevious() - } - } - - object FastForward : BaseRewindEvent() { - override val isUpBtnEvent = true - - override fun getSecondaryEvent() = Next.takeIf { isPrimary() } - - override fun doHandle() { - val currentPosition = controller.playbackState?.position ?: 0 - val duration = - controller.metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION) - ?: Long.MAX_VALUE - val newPosition = - min(currentPosition + prefs.getRewindDuration() * 1000, duration) - controls.seekTo(newPosition) - } - } - - object Rewind : BaseRewindEvent() { - override val isUpBtnEvent = false - - override fun getSecondaryEvent() = Prev.takeIf { isPrimary() } - - override fun doHandle() { - val currentPosition = controller.playbackState?.position ?: 0 - val newPosition = max(currentPosition - prefs.getRewindDuration() * 1000, 0) - controls.seekTo(newPosition) - } - } - - companion object { - val entries = listOf(PlayPause, Next, Prev, FastForward, Rewind) - } - } - - private class KeyHelper private constructor(private val origKey: Key) { - private val key = if (prefs.isSwapButtons()) { - when (origKey) { - Key.UP -> Key.DOWN - Key.DOWN -> Key.UP - } - } else { - origKey - } - - fun getMatchingMediaEvent(): MediaEvent { - val isTrackChange = prefs.getRewindActionType() == RewindActionType.TRACK_CHANGE - val isUp = key == Key.UP - return when { - isTrackChange && isUp -> MediaEvent.Next - isTrackChange && !isUp -> MediaEvent.Prev - !isTrackChange && isUp -> MediaEvent.FastForward - else -> MediaEvent.Rewind - } - } - - fun updateFlags(action: Action) { - val pressed = action == Action.PRESSED - updateState { - when (key) { - Key.UP -> isUpPressed = pressed - Key.DOWN -> isDownPressed = pressed - } - } - } - - fun adjustStreamVolume(handler: Handler) { - try { - val keyCode = when (origKey) { - Key.UP -> KeyEvent.KEYCODE_VOLUME_UP - Key.DOWN -> KeyEvent.KEYCODE_VOLUME_DOWN - } - - fun sendEvent(action: Int) { - XposedHelpers.callMethod( - sessionHelper, - "sendVolumeKeyEvent", - arrayOf( - KeyEvent::class.java, - Int::class.javaPrimitiveType, - Boolean::class.javaPrimitiveType - ), - KeyEvent(action, keyCode), - AudioManager.STREAM_MUSIC, - false - ) - } - - sendEvent(KeyEvent.ACTION_DOWN) - handler.postDelayed({ - sendEvent(KeyEvent.ACTION_UP) - }, 20) - } catch (e: Exception) { - bufferedLog("Failed to adjust stream volume: ${e.message}") - bufferedLog("Falling back to adjustStreamVolume") - - val direction = when (origKey) { - Key.UP -> AudioManager.ADJUST_RAISE - Key.DOWN -> AudioManager.ADJUST_LOWER - } - audioManager.adjustStreamVolume(AudioManager.USE_DEFAULT_STREAM_TYPE, direction, 0) - } - } - - companion object { - private enum class Key(val keyCode: Int) { - UP(KeyEvent.KEYCODE_VOLUME_UP), - DOWN(KeyEvent.KEYCODE_VOLUME_DOWN), - } - - fun get(keyCode: Int) = - Key.entries.find { it.keyCode == keyCode }?.let { KeyHelper(it) } - } - } - - private enum class Action(val actionCode: Int) { - PRESSED(KeyEvent.ACTION_DOWN), - UNPRESSED(KeyEvent.ACTION_UP); - } - - private fun updateState(fn: State.Builder.() -> Unit) { - val builder = State.Builder(state) - fn.invoke(builder) - state = builder.build() - bufferedLog("SETTING NEW STATE: $state") - } - - private fun updateFlags(event: KeyEvent) { - event.applyForAction { updateFlags(it) } - } - - private fun KeyEvent.applyForAction(fn: KeyHelper.(Action) -> Unit) { - val action = Action.entries.find { it.actionCode == action } - val keyHelper = KeyHelper.get(keyCode) - if (keyHelper != null && action != null) { - fn.invoke(keyHelper, action) - } - } -} diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/LogHelper.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/LogHelper.kt deleted file mode 100644 index 84c443b..0000000 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/LogHelper.kt +++ /dev/null @@ -1,10 +0,0 @@ -package ru.hepolise.volumekeytrackcontrol.module.util - -import de.robv.android.xposed.XposedBridge -import ru.hepolise.volumekeytrackcontrolmodule.BuildConfig - -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/volumekeytrackcontrol/module/util/MediaSessionManager.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/MediaSessionManager.kt new file mode 100644 index 0000000..92a25ab --- /dev/null +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/MediaSessionManager.kt @@ -0,0 +1,126 @@ +package ru.hepolise.volumekeytrackcontrol.module.util + +import android.annotation.SuppressLint +import android.content.ComponentName +import android.content.Context +import android.hardware.display.DisplayManager +import android.media.AudioManager +import android.media.session.MediaController +import android.media.session.MediaSessionManager +import android.media.session.PlaybackState +import android.os.Handler +import android.os.PowerManager +import android.view.Display +import android.view.KeyEvent +import ru.hepolise.volumekeytrackcontrol.util.AppFilterType +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getAppFilterType +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getApps + +class MediaSessionManager(private val context: Context) { + lateinit var audioManager: AudioManager + private set + private lateinit var powerManager: PowerManager + private lateinit var displayManager: DisplayManager + private lateinit var mediaSessionManager: MediaSessionManager + private lateinit var sessionHelper: Any + private var mediaControllers: List? = null + + init { + initManagers() + } + + private fun initManagers() { + audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + mediaSessionManager = + context.getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager + sessionHelper = getMediaSessionLegacyHelper() + } + + private fun getMediaSessionLegacyHelper(): Any { + val helperClass = + "android.media.session.MediaSessionLegacyHelper".toClass(context.classLoader) + val method = helperClass.getMethod("getHelper", Context::class.java) + return method.invoke(null, context) + ?: throw NullPointerException("Unable to get MediaSessionLegacyHelper") + } + + @SuppressLint("BlockedPrivateApi") + fun refreshControllers() { + val method = MediaSessionManager::class.java.getDeclaredMethod( + "getActiveSessionsForUser", + ComponentName::class.java, + Int::class.javaPrimitiveType + ) + + method.isAccessible = true + + @Suppress("UNCHECKED_CAST") + mediaControllers = method.invoke( + mediaSessionManager, + null, // ComponentName + -1 // android.os.UserHandle.ALL + ) as List + } + + fun getActiveMediaController(prefs: android.content.SharedPreferences): MediaController? { + val filterType = prefs.getAppFilterType() + val apps = prefs.getApps(filterType) + val chosen = mediaControllers + ?.sortedByDescending { isMusicActive(it) } + ?.find { controller -> + when (filterType) { + AppFilterType.DISABLED -> true + AppFilterType.WHITE_LIST -> controller.packageName in apps + AppFilterType.BLACK_LIST -> controller.packageName !in apps + } + } + return chosen + } + + fun isMusicActive(controller: MediaController?): Boolean { + return when (controller?.playbackState?.state) { + PlaybackState.STATE_PLAYING, + PlaybackState.STATE_FAST_FORWARDING, + PlaybackState.STATE_REWINDING, + PlaybackState.STATE_BUFFERING -> true + + else -> false + } + } + + fun isDisplayInteractive(): Boolean { + if (!powerManager.isInteractive) return false + if (displayManager.displays.size > 1) return true + val display = displayManager.displays[0] + return display.state !in setOf( + Display.STATE_OFF, + Display.STATE_DOZE, + Display.STATE_DOZE_SUSPEND + ) + } + + fun adjustStreamVolume(keyCode: Int, handler: Handler) { + try { + val downEvent = KeyEvent(KeyEvent.ACTION_DOWN, keyCode) + val method = sessionHelper.javaClass.getMethod( + "sendVolumeKeyEvent", + KeyEvent::class.java, + Int::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType + ) + method.invoke(sessionHelper, downEvent, AudioManager.STREAM_MUSIC, false) + handler.postDelayed({ + val upEvent = KeyEvent(KeyEvent.ACTION_UP, keyCode) + method.invoke(sessionHelper, upEvent, AudioManager.STREAM_MUSIC, false) + }, 20) + } catch (e: Exception) { + val direction = when (keyCode) { + KeyEvent.KEYCODE_VOLUME_UP -> AudioManager.ADJUST_RAISE + else -> AudioManager.ADJUST_LOWER + } + audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC, direction, 0) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/RemotePrefsHelper.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/RemotePrefsHelper.kt index c40f2d2..440a828 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/RemotePrefsHelper.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/RemotePrefsHelper.kt @@ -3,12 +3,10 @@ package ru.hepolise.volumekeytrackcontrol.module.util import android.content.Context import android.content.SharedPreferences import com.crossbowffs.remotepreferences.RemotePreferences +import ru.hepolise.volumekeytrackcontrol.BuildConfig import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil -import ru.hepolise.volumekeytrackcontrolmodule.BuildConfig object RemotePrefsHelper { - private fun log(text: String) = LogHelper.log(RemotePrefsHelper::class.java.simpleName, text) - fun withRemotePrefs(context: Context, block: SharedPreferences.() -> Unit) { val prefs = RemotePreferences( context, diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/StateManager.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/StateManager.kt new file mode 100644 index 0000000..f0390a7 --- /dev/null +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/StateManager.kt @@ -0,0 +1,47 @@ +package ru.hepolise.volumekeytrackcontrol.module.util + +import ru.hepolise.volumekeytrackcontrol.module.MediaEvent + +data class PendingEventInfo(val event: MediaEvent, val isPrimary: Boolean) + +data class PendingEventInfoHolder(val info: PendingEventInfo?) + +class StateManager { + @Volatile + var isDownPressed = false + + @Volatile + var isUpPressed = false + + @Volatile + var isLongPress = false + + @Volatile + var pendingEventInfo: PendingEventInfo? = null + private set + + @Synchronized + fun update(block: StateBuilder.() -> Unit) { + val builder = StateBuilder(this) + builder.block() + builder.applyChanges() + } + + override fun toString(): String { + return "StateManager(isDownPressed=$isDownPressed, isUpPressed=$isUpPressed, isLongPress=$isLongPress, pendingEventInfo=$pendingEventInfo)" + } + + class StateBuilder(private val target: StateManager) { + var isDownPressed: Boolean? = null + var isUpPressed: Boolean? = null + var isLongPress: Boolean? = null + var pendingEventInfoHolder: PendingEventInfoHolder? = null + + fun applyChanges() { + isDownPressed?.let { target.isDownPressed = it } + isUpPressed?.let { target.isUpPressed = it } + isLongPress?.let { target.isLongPress = it } + pendingEventInfoHolder?.let { target.pendingEventInfo = it.info } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/StatusHelper.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/StatusHelper.kt deleted file mode 100644 index a1ff6ec..0000000 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/StatusHelper.kt +++ /dev/null @@ -1,68 +0,0 @@ -package ru.hepolise.volumekeytrackcontrol.module.util - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import androidx.core.content.edit -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.LAST_INIT_HOOK_TIME -import ru.hepolise.volumekeytrackcontrol.util.StatusSysPropsHelper.dynamicKey -import ru.hepolise.volumekeytrackcontrol.util.SystemProps - -object StatusHelper { - private fun log(text: String) = LogHelper.log(StatusHelper::class.java.simpleName, text) - - fun handleSuccessHook(context: Context) { - val eventsLock = Any() - var bootReceived = false - var unlockReceived = false - var anySuccess = false - var remotePrefsSuccess = false - - fun handleEvent(ctx: Context, action: String) { - synchronized(eventsLock) { - if (!remotePrefsSuccess) { - try { - RemotePrefsHelper.withRemotePrefs(ctx) { - edit { - putLong(LAST_INIT_HOOK_TIME, System.currentTimeMillis()) - } - } - remotePrefsSuccess = true - log("Remote prefs updated successfully for $action") - } catch (t: Throwable) { - log("Remote preferences failed for $action (${t.message})") - } - } - - when (action) { - Intent.ACTION_BOOT_COMPLETED -> bootReceived = true - Intent.ACTION_USER_UNLOCKED -> unlockReceived = true - } - anySuccess = anySuccess || remotePrefsSuccess - - if (bootReceived && unlockReceived) { - if (!anySuccess) { - log("Neither event could connect to content-provider, writing to sysprops") - try { - SystemProps.set(dynamicKey(), "1") - } catch (t: Throwable) { - log("Failed to write to sysprops (${t.message})") - } - } - } - } - } - - listOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_USER_UNLOCKED).forEach { intentAction -> - val receiver = object : BroadcastReceiver() { - override fun onReceive(ctx: Context, intent: Intent) { - val action = intent.action.also { log("onReceive: $it") } ?: return - handleEvent(ctx, action) - ctx.unregisterReceiver(this) - } - } - context.registerReceiver(receiver, IntentFilter(intentAction)) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/Utils.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/Utils.kt new file mode 100644 index 0000000..01dbe7e --- /dev/null +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/Utils.kt @@ -0,0 +1,29 @@ +package ru.hepolise.volumekeytrackcontrol.module.util + +import android.content.Context +import android.os.Handler +import io.github.libxposed.api.XposedInterface + +fun XposedInterface.Chain.getContext() = getObjectField("mContext") as Context + +fun XposedInterface.Chain.getHandler() = getObjectField("mHandler") as Handler + +fun XposedInterface.Chain.getObjectField(fieldName: String): Any? { + return thisObject.getPrivateField(fieldName) +} + +fun Any.getPrivateField(fieldName: String): Any? { + var currentClass: Class<*>? = javaClass + while (currentClass != null && currentClass != Any::class.java) { + try { + val field = currentClass.getDeclaredField(fieldName) + field.isAccessible = true + return field.get(this) + } catch (e: NoSuchFieldException) { + currentClass = currentClass.superclass + } + } + throw NoSuchFieldException("Field '$fieldName' not found in class hierarchy of ${javaClass.name}") +} + +fun String.toClass(classLoader: ClassLoader) = Class.forName(this, true, classLoader) \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/VolumeHandlerState.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/VolumeHandlerState.kt deleted file mode 100644 index 49d2a74..0000000 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/VolumeHandlerState.kt +++ /dev/null @@ -1,25 +0,0 @@ -package ru.hepolise.volumekeytrackcontrol.module.util - -import ru.hepolise.volumekeytrackcontrol.module.VolumeKeyControlModuleHandlers - -data class State( - val isLongPress: Boolean = false, - val isDownPressed: Boolean = false, - val isUpPressed: Boolean = false, - val pendingEvent: VolumeKeyControlModuleHandlers.MediaEvent? = null -) { - - class Builder(state: State) { - var isLongPress: Boolean = state.isLongPress - var isDownPressed: Boolean = state.isDownPressed - var isUpPressed: Boolean = state.isUpPressed - var pendingEvent: VolumeKeyControlModuleHandlers.MediaEvent? = state.pendingEvent - - fun build(): State = State( - isLongPress = isLongPress, - isDownPressed = isDownPressed, - isUpPressed = isUpPressed, - pendingEvent = pendingEvent - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/VolumeKeyHandler.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/VolumeKeyHandler.kt new file mode 100644 index 0000000..5f00ebb --- /dev/null +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/module/util/VolumeKeyHandler.kt @@ -0,0 +1,281 @@ +package ru.hepolise.volumekeytrackcontrol.module.util + +import android.content.Context +import android.content.SharedPreferences +import android.media.AudioManager +import android.media.session.MediaController +import android.os.Handler +import android.view.KeyEvent +import ru.hepolise.volumekeytrackcontrol.module.ExecutionContext +import ru.hepolise.volumekeytrackcontrol.module.MediaEvent +import ru.hepolise.volumekeytrackcontrol.util.RewindActionType +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getLongPressDuration +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getRewindActionType +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.isAddSecondaryAction +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.isSwapButtons +import ru.hepolise.volumekeytrackcontrol.util.VibratorUtil.getVibrator +import ru.hepolise.volumekeytrackcontrol.util.VibratorUtil.triggerVibration + +class VolumeKeyHandler( + private val context: Context, + private val handler: Handler, + private val stateManager: StateManager, + private val mediaSessionManager: MediaSessionManager, + private val prefs: SharedPreferences, + private val logger: (String) -> Unit +) { + private val pendingRunnables = mutableMapOf() + + fun refreshControllers() { + mediaSessionManager.refreshControllers() + } + + private fun logDecision( + title: String, + keyCode: Int, + displayInteractive: Boolean, + audioMode: Int, + isDownPressed: Boolean, + isUpPressed: Boolean, + hasPendingEvent: Boolean, + controller: MediaController? + ) { + logger("======== $title ========") + logger("audioManager mode: $audioMode, required: ${AudioManager.MODE_NORMAL}") + logger("keyCode: $keyCode, required: ${KeyEvent.KEYCODE_VOLUME_DOWN} or ${KeyEvent.KEYCODE_VOLUME_UP}") + logger("displayInteractive: $displayInteractive, required: false") + logger("isDownPressed: $isDownPressed") + logger("isUpPressed: $isUpPressed") + logger("hasPendingEvent: $hasPendingEvent") + logger("controller: $controller, required: not null") + logger("packageName: ${controller?.packageName}") + logger("=========================================") + } + + fun logInterceptDecision(event: KeyEvent) { + val keyCode = event.keyCode + if (keyCode != KeyEvent.KEYCODE_VOLUME_DOWN && keyCode != KeyEvent.KEYCODE_VOLUME_UP) return + + val displayInteractive = mediaSessionManager.isDisplayInteractive() + val audioMode = mediaSessionManager.audioManager.mode + val isDownPressed = stateManager.isDownPressed + val isUpPressed = stateManager.isUpPressed + val hasPendingEvent = stateManager.pendingEventInfo != null + val controller = mediaSessionManager.getActiveMediaController(prefs) + + logDecision( + title = "LOG INTERCEPT DECISION", + keyCode = keyCode, + displayInteractive = displayInteractive, + audioMode = audioMode, + isDownPressed = isDownPressed, + isUpPressed = isUpPressed, + hasPendingEvent = hasPendingEvent, + controller = controller, + ) + } + + fun shouldIntercept(event: KeyEvent): Boolean { + val keyCode = event.keyCode + if (keyCode != KeyEvent.KEYCODE_VOLUME_DOWN && keyCode != KeyEvent.KEYCODE_VOLUME_UP) return false + if (event.flags and KeyEvent.FLAG_FROM_SYSTEM == 0) return false + + val displayInteractive = mediaSessionManager.isDisplayInteractive() + if (displayInteractive) return false + + val audioMode = mediaSessionManager.audioManager.mode + if (audioMode != AudioManager.MODE_NORMAL) return false + + val isDownPressed = stateManager.isDownPressed + val isUpPressed = stateManager.isUpPressed + val hasPendingEvent = stateManager.pendingEventInfo != null + + val controller = mediaSessionManager.getActiveMediaController(prefs) + + logDecision( + title = "NEED HOOK CHECK", + keyCode = keyCode, + displayInteractive = false, + audioMode = audioMode, + isDownPressed = isDownPressed, + isUpPressed = isUpPressed, + hasPendingEvent = hasPendingEvent, + controller = controller, + ) + + return controller != null + } + + fun handleKeyEvent(event: KeyEvent) { + updateState(event) + + stateManager.pendingEventInfo?.let { pendingInfo -> + logger("Executing pending event: ${pendingInfo.event::class.simpleName}") + executeEvent(pendingInfo.event, pendingInfo.isPrimary) + stateManager.update { pendingEventInfoHolder = PendingEventInfoHolder(null) } + return + } + + when (event.action) { + KeyEvent.ACTION_DOWN -> onPressed(event) + KeyEvent.ACTION_UP -> onReleased(event) + } + } + + private fun updateState(event: KeyEvent) { + val pressed = event.action == KeyEvent.ACTION_DOWN + val isUp = event.keyCode == KeyEvent.KEYCODE_VOLUME_UP + stateManager.update { + if (isUp) isUpPressed = pressed else isDownPressed = pressed + } + if (pressed) { + stateManager.update { isLongPress = false } + } + logger("State updated: down=${stateManager.isDownPressed}, up=${stateManager.isUpPressed}, long=${stateManager.isLongPress}") + } + + private fun onPressed(event: KeyEvent) { + logger("Volume pressed action received, down: ${stateManager.isDownPressed}, up: ${stateManager.isUpPressed}") + + if (stateManager.isDownPressed && stateManager.isUpPressed) { + logger("Both buttons pressed, aborting skip") + abortSkip() + return + } + + val controller = mediaSessionManager.getActiveMediaController(prefs) + if (controller != null && mediaSessionManager.isMusicActive(controller)) { + logger("Music is active, creating delayed skip") + val primary = resolvePrimaryEvent(event.keyCode) + val hasSecondary = getSecondaryEvent(primary) != null + scheduleEvent(primary, isPrimary = hasSecondary, hasSecondary = hasSecondary) + getSecondaryEvent(primary)?.let { secondary -> + logger("Scheduling secondary event: ${secondary::class.simpleName} with delay") + scheduleEvent(secondary, isPrimary = false, hasSecondary = false, multiplier = 1.4) + } + } + logger("Creating delayed play pause") + scheduleEvent(MediaEvent.PlayPause, isPrimary = true, hasSecondary = false) + } + + private fun onReleased(event: KeyEvent) { + logger("Volume unpressed action received, down: ${stateManager.isDownPressed}, up: ${stateManager.isUpPressed}") + abortAll() + val controller = mediaSessionManager.getActiveMediaController(prefs) + val isMusicActive = controller != null && mediaSessionManager.isMusicActive(controller) + logger("isMusicActive: $isMusicActive") + if (!stateManager.isLongPress && isMusicActive) { + logger("Adjusting stream volume") + mediaSessionManager.adjustStreamVolume(event.keyCode, handler) + } + } + + private fun resolvePrimaryEvent(keyCode: Int): MediaEvent { + val isUp = keyCode == KeyEvent.KEYCODE_VOLUME_UP + val swapped = prefs.isSwapButtons() + val actualIsUp = if (swapped) !isUp else isUp + val isTrackChange = prefs.getRewindActionType() == RewindActionType.TRACK_CHANGE + return when { + isTrackChange && actualIsUp -> MediaEvent.Next + isTrackChange && !actualIsUp -> MediaEvent.Prev + !isTrackChange && actualIsUp -> MediaEvent.FastForward + else -> MediaEvent.Rewind + } + } + + private fun getSecondaryEvent(primary: MediaEvent): MediaEvent? { + if (!prefs.isAddSecondaryAction()) return null + return when (primary) { + MediaEvent.Next -> MediaEvent.FastForward + MediaEvent.Prev -> MediaEvent.Rewind + MediaEvent.FastForward -> MediaEvent.Next + MediaEvent.Rewind -> MediaEvent.Prev + else -> null + } + } + + private fun scheduleEvent( + event: MediaEvent, + isPrimary: Boolean, + hasSecondary: Boolean, + multiplier: Double = 0.0 + ) { + val delay = (prefs.getLongPressDuration() + multiplier * prefs.getLongPressDuration() + .toDouble()).toLong() + val runnable = Runnable { + onDelayedEvent(event, isPrimary, hasSecondary) + } + pendingRunnables[event] = runnable + handler.postDelayed(runnable, delay) + logger("Scheduled event ${event::class.simpleName} (isPrimary=$isPrimary, hasSecondary=$hasSecondary) with delay $delay ms") + } + + private fun onDelayedEvent(event: MediaEvent, isPrimary: Boolean, hasSecondary: Boolean) { + logger("Delayed event triggered: ${event::class.simpleName}") + context.getVibrator().triggerVibration(prefs) + stateManager.update { isLongPress = true } + + if (hasSecondary) { + logger("Event has secondary, setting as pending") + stateManager.update { + pendingEventInfoHolder = PendingEventInfoHolder(PendingEventInfo(event, isPrimary)) + } + logger(stateManager.toString()) + } else { + logger("Event has no secondary, executing now") + executeEvent(event, isPrimary) + } + } + + @Synchronized + private fun executeEvent(event: MediaEvent, isPrimary: Boolean) { + val controller = mediaSessionManager.getActiveMediaController(prefs) + if (controller == null) { + logger("No active controller, skipping event") + return + } + val executionContext = ExecutionContext( + controller = controller, + controls = controller.transportControls, + prefs = prefs, + logger = logger, + stateManager = stateManager, + isPrimary = isPrimary + ) + logger("Executing event ${event::class.simpleName} with isPrimary=$isPrimary") + if (event.execute(executionContext)) { + // TODO +// runCatching { +// RemotePrefsHelper.withRemotePrefs(context) { +// val count = getLaunchedCount() +// edit { +// putInt(LAUNCHED_COUNT, count + 1) +// } +// } +// } + abortAll() + } + } + + private fun abortSkip() { + logger("Aborting skip") + abortEvents(MediaEvent.Prev, MediaEvent.Next) + } + + private fun abortAll() { + logger("Aborting all") + stateManager.update { pendingEventInfoHolder = PendingEventInfoHolder(null) } + pendingRunnables.values.forEach { handler.removeCallbacks(it) } + pendingRunnables.clear() + } + + private fun abortEvents(vararg events: MediaEvent) { + events.forEach { event -> + pendingRunnables[event]?.let { + handler.removeCallbacks(it) + pendingRunnables.remove(event) + logger("Aborted event ${event::class.simpleName}") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/provider/RemotePrefProvider.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/provider/RemotePrefProvider.kt index 842a012..ebaf5e9 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/provider/RemotePrefProvider.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/provider/RemotePrefProvider.kt @@ -2,8 +2,8 @@ package ru.hepolise.volumekeytrackcontrol.provider import com.crossbowffs.remotepreferences.RemotePreferenceFile import com.crossbowffs.remotepreferences.RemotePreferenceProvider +import ru.hepolise.volumekeytrackcontrol.BuildConfig import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil -import ru.hepolise.volumekeytrackcontrolmodule.BuildConfig class RemotePrefProvider : RemotePreferenceProvider( BuildConfig.APPLICATION_ID, diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/receiver/BootReceiver.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/receiver/BootReceiver.kt deleted file mode 100644 index b84b6d3..0000000 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/receiver/BootReceiver.kt +++ /dev/null @@ -1,17 +0,0 @@ -package ru.hepolise.volumekeytrackcontrol.receiver - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import ru.hepolise.volumekeytrackcontrol.repository.BootRepository -import ru.hepolise.volumekeytrackcontrol.util.LSPosedLogger - - -class BootReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (Intent.ACTION_BOOT_COMPLETED == intent.action) { - LSPosedLogger.log("Setting last boot completed time from receiver") - BootRepository.getBootRepository(context).setBootCompleted() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/repository/BootRepository.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/repository/BootRepository.kt deleted file mode 100644 index 47e4149..0000000 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/repository/BootRepository.kt +++ /dev/null @@ -1,71 +0,0 @@ -package ru.hepolise.volumekeytrackcontrol.repository - -import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.launch -import ru.hepolise.volumekeytrackcontrol.util.LSPosedLogger -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.LAST_BOOT_COMPLETED_TIME -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getStatusSharedPreferences -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.isBootCompleted -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.isHooked -import ru.hepolise.volumekeytrackcontrol.util.StatusSysPropsHelper - -class BootRepository private constructor(private val sharedPreferences: SharedPreferences) { - - companion object { - private var _bootRepository: BootRepository? = null - - fun getBootRepository(context: Context): BootRepository { - return _bootRepository ?: BootRepository( - context.getStatusSharedPreferences() - ).also { _bootRepository = it } - } - } - - - fun isBootCompleted(): Boolean { - return sharedPreferences.isBootCompleted() - } - - fun isHooked(): Boolean { - return StatusSysPropsHelper.isHooked || sharedPreferences.isHooked() - } - - fun setBootCompleted() { - sharedPreferences.edit { - putLong(LAST_BOOT_COMPLETED_TIME, System.currentTimeMillis()) - } - } - - fun observeBootCompleted(): Flow = callbackFlow { - try { - val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> - LSPosedLogger.log("Observer: pref changed: $key") - if (key == LAST_BOOT_COMPLETED_TIME) { - LSPosedLogger.log("Boot completed is changed") - launch { - delay(5_000) - }.invokeOnCompletion { - StatusSysPropsHelper.refreshIsHooked() - trySend(isBootCompleted()) - } - } - } - - sharedPreferences.registerOnSharedPreferenceChangeListener(listener) - - if (isBootCompleted()) trySend(true) - - awaitClose { - sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) - } - } catch (e: Exception) { - close(e) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/service/RewindActionTileService.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/service/RewindActionTileService.kt index e369b15..8379f6f 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/service/RewindActionTileService.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/service/RewindActionTileService.kt @@ -4,26 +4,40 @@ import android.graphics.drawable.Icon import android.service.quicksettings.Tile import android.service.quicksettings.TileService import androidx.core.content.edit +import io.github.libxposed.service.XposedService +import ru.hepolise.volumekeytrackcontrol.App +import ru.hepolise.volumekeytrackcontrol.R import ru.hepolise.volumekeytrackcontrol.util.RewindActionType import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.REWIND_ACTION_TYPE import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getRewindActionType import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getSettingsSharedPreferences -import ru.hepolise.volumekeytrackcontrolmodule.R -class RewindActionTileService : TileService() { +class RewindActionTileService : TileService(), App.ServiceStateListener { + + private var xposedService: XposedService? = null override fun onStartListening() { super.onStartListening() + App.addServiceStateListener(this, true) updateTile() } + override fun onStopListening() { + App.removeServiceStateListener(this) + super.onStopListening() + } + override fun onClick() { super.onClick() toggleActionType() } + override fun onServiceStateChanged(service: XposedService?) { + xposedService = service + } + private fun toggleActionType() { - val prefs = getSettingsSharedPreferences() + val prefs = xposedService?.getSettingsSharedPreferences() val currentType = prefs.getRewindActionType() val newType = when (currentType) { @@ -39,7 +53,7 @@ class RewindActionTileService : TileService() { } private fun updateTile() { - val prefs = getSettingsSharedPreferences() + val prefs = xposedService?.getSettingsSharedPreferences() val currentType = prefs.getRewindActionType() val tile = qsTile ?: return diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/LocalHotReloadResult.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/LocalHotReloadResult.kt new file mode 100644 index 0000000..fbdeace --- /dev/null +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/LocalHotReloadResult.kt @@ -0,0 +1,6 @@ +package ru.hepolise.volumekeytrackcontrol.ui + +import androidx.compose.runtime.compositionLocalOf +import io.github.libxposed.service.HotReloadResult + +val LocalHotReloadResult = compositionLocalOf { null } \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/LocalModuleScope.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/LocalModuleScope.kt new file mode 100644 index 0000000..cc08ffe --- /dev/null +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/LocalModuleScope.kt @@ -0,0 +1,5 @@ +package ru.hepolise.volumekeytrackcontrol.ui + +import androidx.compose.runtime.compositionLocalOf + +val LocalModuleScope = compositionLocalOf?> { null } \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/LocalXposedService.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/LocalXposedService.kt new file mode 100644 index 0000000..640239e --- /dev/null +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/LocalXposedService.kt @@ -0,0 +1,6 @@ +package ru.hepolise.volumekeytrackcontrol.ui + +import androidx.compose.runtime.compositionLocalOf +import io.github.libxposed.service.XposedService + +val LocalXposedService = compositionLocalOf { null } \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/SettingsActivity.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/SettingsActivity.kt index de41068..b2b4291 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/SettingsActivity.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/SettingsActivity.kt @@ -11,7 +11,6 @@ import android.view.animation.AnticipateInterpolator import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.activity.viewModels import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme @@ -20,26 +19,43 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.core.animation.doOnEnd import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import io.github.libxposed.service.HotReloadResult +import io.github.libxposed.service.XposedService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import ru.hepolise.volumekeytrackcontrol.App +import ru.hepolise.volumekeytrackcontrol.ui.component.MODULE_SCOPE import ru.hepolise.volumekeytrackcontrol.ui.navigation.AppNavigation -import ru.hepolise.volumekeytrackcontrol.util.LSPosedLogger import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getSettingsSharedPreferences -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getStatusSharedPreferences -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.isHooked -import ru.hepolise.volumekeytrackcontrol.util.VibratorUtil.getVibrator -import ru.hepolise.volumekeytrackcontrol.viewmodel.BootViewModel -import ru.hepolise.volumekeytrackcontrol.viewmodel.BootViewModelFactory import kotlin.system.exitProcess -class SettingsActivity : ComponentActivity() { +class SettingsActivity : ComponentActivity(), App.ServiceStateListener { - private val bootViewModel: BootViewModel by viewModels { - BootViewModelFactory(applicationContext) + private val _xposedService = MutableStateFlow(null) + private val xposedService = _xposedService.asStateFlow() + + private val _hotReloadResultStatus = MutableStateFlow(null) + private val hotReloadResultStatus = _hotReloadResultStatus.asStateFlow() + + private val _moduleScope = MutableStateFlow?>(null) + private val moduleScope = _moduleScope.asStateFlow() + + override fun onStart() { + super.onStart() + App.addServiceStateListener(this, true) + } + + override fun onStop() { + App.removeServiceStateListener(this) + super.onStop() } @Volatile @@ -53,19 +69,37 @@ class SettingsActivity : ComponentActivity() { keepSplashScreen = false setUpSplashScreenAnimation() enableEdgeToEdge() - setContent { - val hookPrefs = getStatusSharedPreferences() - val prefs = getSettingsSharedPreferences() - val isLoading by bootViewModel.isLoading.collectAsState() + setContent { + val xposed by xposedService.collectAsState() + val result by hotReloadResultStatus.collectAsState() + val scope by moduleScope.collectAsState() + val prefs = xposed?.getSettingsSharedPreferences() + + fun computeIsHooked() = xposed != null + && prefs != null + && result != null && result != HotReloadResult.Status.FAILED + && scope.orEmpty().contains(MODULE_SCOPE) + + val isHooked = remember(result, xposed, prefs, scope) { + computeIsHooked() + } - LaunchedEffect(hookPrefs, prefs, isLoading) { - LSPosedLogger.log("Updating shouldRemoveFromRecents") - shouldRemoveFromRecents = !hookPrefs.isHooked() || prefs == null + CompositionLocalProvider( + LocalXposedService provides xposed, + LocalHotReloadResult provides result, + LocalModuleScope provides scope + ) { + MaterialTheme(colorScheme = dynamicColorScheme(context = this)) { + AppNavigation(isHooked) { + xposed.updateModuleScope() + xposed.updateHotReloadResult() + } + } } - MaterialTheme(colorScheme = dynamicColorScheme(context = this)) { - AppNavigation(settingsPrefs = prefs, vibrator = getVibrator()) + LaunchedEffect(isHooked) { + shouldRemoveFromRecents = !isHooked } } } @@ -77,6 +111,38 @@ class SettingsActivity : ComponentActivity() { } } + override fun onServiceStateChanged(service: XposedService?) { + _xposedService.value = service + service.updateModuleScope() + service.updateHotReloadResult() + } + + private fun XposedService?.updateHotReloadResult() { + fun fallback() { + _hotReloadResultStatus.value = HotReloadResult.Status.FAILED + } + + val service = this ?: run { + fallback() + return + } + + if (!service.scope.contains(MODULE_SCOPE)) { + fallback() + return + } + + service.runningTargets.takeIf { it.isNotEmpty() }?.forEach { target -> + service.hotReloadModule(target, null) { _, result -> + _hotReloadResultStatus.value = result.status + } + } ?: fallback() + } + + private fun XposedService?.updateModuleScope() { + _moduleScope.value = this?.scope + } + private fun setUpSplashScreenAnimation() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { return diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/Util.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/Util.kt index 3dc365b..eb3fba5 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/Util.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/Util.kt @@ -1,8 +1,5 @@ package ru.hepolise.volumekeytrackcontrol.ui -import android.content.Context -import android.content.pm.PackageManager -import android.os.SystemClock import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.snapshotFlow @@ -10,6 +7,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds @OptIn(FlowPreview::class) fun State.debounce( @@ -19,23 +17,8 @@ fun State.debounce( val debouncedState = mutableStateOf(this.value) coroutineScope.launch { snapshotFlow { this@debounce.value } - .debounce(durationMillis) + .debounce(durationMillis.milliseconds) .collect { debouncedState.value = it } } return debouncedState -} - -private fun Context.getAppUpdateTime(): Long { - return try { - val packageInfo = packageManager.getPackageInfo(packageName, 0) - packageInfo.lastUpdateTime - } catch (e: PackageManager.NameNotFoundException) { - -1 - } -} - -fun Context.isInstalledAfterReboot(): Boolean { - val installTime = getAppUpdateTime() - val bootTime = System.currentTimeMillis() - SystemClock.elapsedRealtime() - return installTime > bootTime } \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/LongPressActionSetting.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/LongPressActionSetting.kt index 71e1463..382112c 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/LongPressActionSetting.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/LongPressActionSetting.kt @@ -34,11 +34,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.content.edit +import ru.hepolise.volumekeytrackcontrol.R import ru.hepolise.volumekeytrackcontrol.util.RewindActionType import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.IS_ADD_SECONDARY_ACTION import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.REWIND_ACTION_TYPE import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.REWIND_DURATION -import ru.hepolise.volumekeytrackcontrolmodule.R data class RewindSettingData( val rewindActionType: RewindActionType, diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/LongPressSetting.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/LongPressSetting.kt index dc96181..6679e89 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/LongPressSetting.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/LongPressSetting.kt @@ -17,8 +17,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.core.content.edit +import ru.hepolise.volumekeytrackcontrol.R import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.LONG_PRESS_DURATION -import ru.hepolise.volumekeytrackcontrolmodule.R @Composable fun LongPressSetting( diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/ModuleInfoCard.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/ModuleInfoCard.kt new file mode 100644 index 0000000..408861d --- /dev/null +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/ModuleInfoCard.kt @@ -0,0 +1,400 @@ +package ru.hepolise.volumekeytrackcontrol.ui.component + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink +import androidx.compose.ui.unit.dp +import io.github.libxposed.service.XposedService +import ru.hepolise.volumekeytrackcontrol.R +import ru.hepolise.volumekeytrackcontrol.ui.LocalModuleScope +import ru.hepolise.volumekeytrackcontrol.ui.LocalXposedService +import ru.hepolise.volumekeytrackcontrol.ui.screen.ScopeRequestStatus +import ru.hepolise.volumekeytrackcontrol.util.Constants +import kotlin.time.Duration.Companion.milliseconds + +const val MODULE_SCOPE = "system" + +@Composable +fun ModuleInfoCard( + isHooked: Boolean, + launchedCount: Int, + snackbarHostState: SnackbarHostState, + modifier: Modifier = Modifier, + onScopeRequested: () -> Unit = {} +) { + val xposedService = LocalXposedService.current + val moduleScope = LocalModuleScope.current + val isScopeValid = moduleScope.orEmpty().contains(MODULE_SCOPE) + val context = LocalContext.current + + var scopeRequestStatus by remember { mutableStateOf(null) } + var isScopeRequested by remember { mutableStateOf(false) } + var showSuccessToast by remember { mutableStateOf(false) } + + val scopeCallback = remember { + object : XposedService.OnScopeEventListener { + override fun onScopeRequestApproved(approved: List) { + scopeRequestStatus = ScopeRequestStatus.Success(approved) + isScopeRequested = true + showSuccessToast = true + onScopeRequested() + } + + override fun onScopeRequestFailed(message: String) { + scopeRequestStatus = ScopeRequestStatus.Error(message) + isScopeRequested = false + } + } + } + + val requestScope = { + if (xposedService != null && !isScopeRequested) { + scopeRequestStatus = ScopeRequestStatus.Loading + xposedService.requestScope(listOf(MODULE_SCOPE), scopeCallback) + } + } + + LaunchedEffect(showSuccessToast) { + if (showSuccessToast) { + snackbarHostState.showSnackbar( + message = context.getString( + R.string.scope_request_success, + (scopeRequestStatus as? ScopeRequestStatus.Success)?.approved?.joinToString() + ?: "" + ), + duration = SnackbarDuration.Short + ) + kotlinx.coroutines.delay(2000.milliseconds) + showSuccessToast = false + } + } + + Card( + shape = RoundedCornerShape(24.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp), + contentColor = MaterialTheme.colorScheme.onSurface + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = if (isHooked) Icons.Default.Done else Icons.Default.Warning, + contentDescription = null, + tint = if (isHooked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error + ) + Spacer(Modifier.width(12.dp)) + Text( + text = stringResource(R.string.module_info), + style = MaterialTheme.typography.titleMedium + ) + Spacer(Modifier.weight(1f)) + } + + ModuleStatus( + isHooked = isHooked, + launchedCount = launchedCount + ) + + when { + xposedService == null -> ModuleIsNotEnabled() + + !isScopeValid && !isScopeRequested -> ScopeStatusSection( + status = scopeRequestStatus, + onRequestScope = requestScope + ) + + !isHooked -> ModuleInitError() + + } + } + } +} + +@Composable +fun ModuleStatus(isHooked: Boolean, launchedCount: Int) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.module_status), + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = stringResource( + if (isHooked) R.string.module_status_active + else R.string.module_status_inactive + ), + color = if (isHooked) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Bold + ) + } + if (isHooked && launchedCount >= 0) { + LaunchCounter(launchedCount) + } +} + +@Composable +fun LaunchCounter(launchedCount: Int) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + stringResource(R.string.module_launch_count), + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = launchedCount.toString(), + fontWeight = FontWeight.Medium + ) + } +} + +@Composable +fun ModuleIsNotEnabled() { + Text( + text = buildAnnotatedString { + append(stringResource(id = R.string.module_is_not_enabled)) + } + ) +} + +@Composable +fun ModuleInitError() { + Text( + text = buildAnnotatedString { + append(stringResource(id = R.string.module_init_error)) + append("\n") + append("\n") + withLink( + LinkAnnotation.Url( + url = Constants.GITHUB_NEW_ISSUE_URL, + styles = TextLinkStyles( + style = SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline + ) + ) + ) + ) { + append(stringResource(id = R.string.open_an_issue)) + } + append(" ") + append(stringResource(id = R.string.if_the_problem_persists)) + } + ) +} + +@Composable +private fun ScopeStatusSection( + status: ScopeRequestStatus?, + onRequestScope: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .animateContentSize() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background( + MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), + RoundedCornerShape(12.dp) + ) + .padding(12.dp) + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp) + ) + Text( + text = stringResource(R.string.scope_not_selected_message), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + AnimatedContent( + targetState = status, + transitionSpec = { + fadeIn(animationSpec = tween(300)) togetherWith + fadeOut(animationSpec = tween(300)) using + SizeTransform(clip = false) + }, + label = "status_content" + ) { currentStatus -> + when (currentStatus) { + is ScopeRequestStatus.Loading -> { + ScopeRequestButton( + onRequestScope = onRequestScope, + isLoading = true + ) + } + + is ScopeRequestStatus.Error -> { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource( + R.string.scope_request_error, + currentStatus.message + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 8.dp) + ) + ScopeRequestButton(onRequestScope) + } + } + + else -> ScopeRequestButton(onRequestScope) + } + } + } +} + +@Composable +private fun ScopeRequestButton( + onRequestScope: () -> Unit, + isLoading: Boolean = false, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + val isPressed = remember { mutableStateOf(false) } + + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { interaction -> + isPressed.value = interaction is PressInteraction.Press + } + } + + val scale by animateFloatAsState( + targetValue = if (isPressed.value) 0.95f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ), + label = "button_scale" + ) + + val rotation by animateFloatAsState( + targetValue = if (isPressed.value) 360f else 0f, + animationSpec = tween(300, easing = FastOutSlowInEasing), + label = "icon_rotation" + ) + + Button( + onClick = onRequestScope, + enabled = !isLoading, + modifier = modifier + .fillMaxWidth() + .scale(scale) + .indication(interactionSource, ripple()), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ), + shape = RoundedCornerShape(16.dp) + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.requesting_scope)) + } else { + Icon( + Icons.Default.Settings, + contentDescription = null, + modifier = Modifier + .size(18.dp) + .rotate(rotation) + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.request_scope_button)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/NumberAlertDialog.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/NumberAlertDialog.kt index edbf895..df575db 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/NumberAlertDialog.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/NumberAlertDialog.kt @@ -18,7 +18,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType -import ru.hepolise.volumekeytrackcontrolmodule.R +import ru.hepolise.volumekeytrackcontrol.R @Composable fun NumberAlertDialog( diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/SwapButtonsSetting.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/SwapButtonsSetting.kt index 4ba39fb..fab7dcd 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/SwapButtonsSetting.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/SwapButtonsSetting.kt @@ -13,8 +13,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.content.edit +import ru.hepolise.volumekeytrackcontrol.R import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.IS_SWAP_BUTTONS -import ru.hepolise.volumekeytrackcontrolmodule.R @Composable fun SwapButtonsSetting( diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/VibrationEffectSetting.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/VibrationEffectSetting.kt index 1aef250..b5fe49e 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/VibrationEffectSetting.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/component/VibrationEffectSetting.kt @@ -2,7 +2,6 @@ package ru.hepolise.volumekeytrackcontrol.ui.component import android.content.SharedPreferences import android.os.Build -import android.os.Vibrator import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn @@ -34,15 +33,17 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.content.edit +import ru.hepolise.volumekeytrackcontrol.R import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.EFFECT import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.VIBRATION_AMPLITUDE import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.VIBRATION_LENGTH import ru.hepolise.volumekeytrackcontrol.util.VibrationType +import ru.hepolise.volumekeytrackcontrol.util.VibratorUtil.getVibrator import ru.hepolise.volumekeytrackcontrol.util.VibratorUtil.triggerVibration -import ru.hepolise.volumekeytrackcontrolmodule.R data class VibrationSettingData( val vibrationType: VibrationType, @@ -73,13 +74,15 @@ private val VibrationEffectTitles = VibrationType.values.associateWith { @Composable fun VibrationEffectSetting( value: VibrationSettingData, - vibrator: Vibrator?, sharedPreferences: SharedPreferences, onValueChange: (VibrationSettingData) -> Unit ) { + val context = LocalContext.current + val vibrator = context.getVibrator() + val (vibrationType, vibrationLength, vibrationAmplitude) = value - val isVibrationAvailable = vibrator != null && vibrator.hasVibrator() + val isVibrationAvailable = vibrator.hasVibrator() if (!isVibrationAvailable) { Card( diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/navigation/AppNavigation.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/navigation/AppNavigation.kt index c4fe7b7..7987fab 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/navigation/AppNavigation.kt @@ -1,7 +1,5 @@ package ru.hepolise.volumekeytrackcontrol.ui.navigation -import android.content.SharedPreferences -import android.os.Vibrator import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn @@ -12,13 +10,19 @@ import androidx.compose.runtime.Composable import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import ru.hepolise.volumekeytrackcontrol.ui.LocalXposedService import ru.hepolise.volumekeytrackcontrol.ui.screen.AppFilterScreen import ru.hepolise.volumekeytrackcontrol.ui.screen.SettingsScreen import ru.hepolise.volumekeytrackcontrol.util.AppFilterType +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getSettingsSharedPreferences @Composable -fun AppNavigation(settingsPrefs: SharedPreferences?, vibrator: Vibrator) { +fun AppNavigation( + isHooked: Boolean, + onRefresh: () -> Unit = {} +) { val navController = rememberNavController() + val xposedService = LocalXposedService.current NavHost( navController = navController, @@ -27,14 +31,10 @@ fun AppNavigation(settingsPrefs: SharedPreferences?, vibrator: Vibrator) { composable( route = "main", ) { - SettingsScreen( - settingsPrefs = settingsPrefs, - navController = navController, - vibrator = vibrator - ) + SettingsScreen(isHooked, navController, onRefresh) } - settingsPrefs?.also { sharedPreferences -> + xposedService?.getSettingsSharedPreferences()?.also { sharedPreferences -> composable( route = "appFilter/{filterType}", enterTransition = { diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/screen/AppFilterScreen.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/screen/AppFilterScreen.kt index d07c99d..d1037ce 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/screen/AppFilterScreen.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/screen/AppFilterScreen.kt @@ -100,6 +100,7 @@ import androidx.navigation.NavController import coil.compose.AsyncImage import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import ru.hepolise.volumekeytrackcontrol.R import ru.hepolise.volumekeytrackcontrol.ui.debounce import ru.hepolise.volumekeytrackcontrol.ui.model.AppInfo import ru.hepolise.volumekeytrackcontrol.util.AppFilterType @@ -109,7 +110,6 @@ import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.WHITE_LIST_A import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getApps import ru.hepolise.volumekeytrackcontrol.viewmodel.AppFilterViewModel import ru.hepolise.volumekeytrackcontrol.viewmodel.AppIconViewModel -import ru.hepolise.volumekeytrackcontrolmodule.R private const val MAX_APPS = 100 diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/screen/SettingsScreen.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/screen/SettingsScreen.kt index 63ea4b9..1443a3e 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/screen/SettingsScreen.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/ui/screen/SettingsScreen.kt @@ -1,10 +1,8 @@ package ru.hepolise.volumekeytrackcontrol.ui.screen -import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.content.res.Configuration -import android.os.Vibrator import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState @@ -32,7 +30,6 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.FastForward import androidx.compose.material.icons.filled.Info @@ -41,7 +38,6 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.SkipNext import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Vibration -import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card @@ -53,20 +49,19 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -78,29 +73,24 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.AnnotatedString.Builder -import androidx.compose.ui.text.LinkAnnotation -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextLinkStyles -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.withLink import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.content.edit import androidx.core.net.toUri -import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController +import io.github.libxposed.service.HotReloadResult +import ru.hepolise.volumekeytrackcontrol.R +import ru.hepolise.volumekeytrackcontrol.ui.LocalHotReloadResult +import ru.hepolise.volumekeytrackcontrol.ui.LocalXposedService import ru.hepolise.volumekeytrackcontrol.ui.component.AppFilterSetting import ru.hepolise.volumekeytrackcontrol.ui.component.LongPressActionSetting import ru.hepolise.volumekeytrackcontrol.ui.component.LongPressSetting +import ru.hepolise.volumekeytrackcontrol.ui.component.ModuleInfoCard import ru.hepolise.volumekeytrackcontrol.ui.component.RewindSettingData import ru.hepolise.volumekeytrackcontrol.ui.component.SwapButtonsSetting import ru.hepolise.volumekeytrackcontrol.ui.component.VibrationEffectSetting import ru.hepolise.volumekeytrackcontrol.ui.component.VibrationSettingData -import ru.hepolise.volumekeytrackcontrol.ui.isInstalledAfterReboot import ru.hepolise.volumekeytrackcontrol.util.AppFilterType import ru.hepolise.volumekeytrackcontrol.util.Constants import ru.hepolise.volumekeytrackcontrol.util.RewindActionType @@ -112,7 +102,6 @@ import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.LONG_PRESS_D import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.REWIND_ACTION_TYPE import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.REWIND_ACTION_TYPE_DEFAULT_VALUE import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.REWIND_DURATION_DEFAULT_VALUE -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.SETTINGS_PREFS import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.VIBRATION_AMPLITUDE_DEFAULT_VALUE import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.VIBRATION_LENGTH_DEFAULT_VALUE import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getAppFilterType @@ -120,36 +109,30 @@ import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getLaunchedC import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getLongPressDuration import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getRewindActionType import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getRewindDuration +import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getSettingsSharedPreferences import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getStatusSharedPreferences import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getVibrationAmplitude import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getVibrationLength import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.getVibrationType import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.isAddSecondaryAction -import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.isHooked import ru.hepolise.volumekeytrackcontrol.util.SharedPreferencesUtil.isSwapButtons -import ru.hepolise.volumekeytrackcontrol.util.StatusSysPropsHelper import ru.hepolise.volumekeytrackcontrol.util.VibrationType -import ru.hepolise.volumekeytrackcontrol.viewmodel.BootViewModel -import ru.hepolise.volumekeytrackcontrol.viewmodel.BootViewModelFactory -import ru.hepolise.volumekeytrackcontrolmodule.R @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( + isHooked: Boolean, navController: NavController?, - settingsPrefs: SharedPreferences?, - vibrator: Vibrator? + onRefresh: () -> Unit = {} ) { val context = LocalContext.current + val xposedService = LocalXposedService.current + val hotReloadResult = LocalHotReloadResult.current - val statusPrefs = context.getStatusSharedPreferences() - - val bootViewModel: BootViewModel = viewModel( - factory = BootViewModelFactory(context.applicationContext) - ) + val snackbarHostState = remember { SnackbarHostState() } - val isBootCompleted by bootViewModel.isBootCompleted.collectAsState() - val isLoading by bootViewModel.isLoading.collectAsState() + val statusPrefs = context.getStatusSharedPreferences() + val settingsPrefs = xposedService?.getSettingsSharedPreferences() var longPressDuration by remember { mutableIntStateOf(settingsPrefs.getLongPressDuration()) } var rewindActionType by remember { mutableStateOf(settingsPrefs.getRewindActionType()) } @@ -163,13 +146,9 @@ fun SettingsScreen( var appFilterType by remember { mutableStateOf(settingsPrefs.getAppFilterType()) } var showResetSettingsDialog by remember { mutableStateOf(false) } - val isHooked by produceState(initialValue = false) { - value = statusPrefs.isHooked().takeIf { settingsPrefs != null } ?: false + val isLoading = + hotReloadResult == null || hotReloadResult == HotReloadResult.Status.IN_PROGRESS - snapshotFlow { isLoading }.collect { - value = statusPrefs.isHooked().takeIf { settingsPrefs != null } ?: false - } - } var launchedCount by remember { mutableIntStateOf(statusPrefs.getLaunchedCount()) } DisposableEffect(Unit) { @@ -199,6 +178,7 @@ fun SettingsScreen( val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { LargeTopAppBar( title = { Text(stringResource(R.string.app_name)) }, @@ -235,36 +215,19 @@ fun SettingsScreen( .padding(12.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - SettingsCard( - icon = if (isHooked) Icons.Default.Done else Icons.Default.Warning, - title = stringResource(R.string.module_info), - ) { - when { - context.isInstalledAfterReboot() -> { - ModuleStatus(false) - ModuleInitError() - } - - isLoading && !isHooked && settingsPrefs != null -> { - LoadingAnimation() - } - - else -> { - ModuleStatus(isHooked) - when { - isHooked && !StatusSysPropsHelper.isHooked -> LaunchCounter( - launchedCount - ) - - settingsPrefs == null -> ModuleIsNotEnabled() - isBootCompleted && !isHooked -> ModuleInitError() - } - } - } + if (isLoading) { + LoadingAnimation() + } else { + ModuleInfoCard( + isHooked = isHooked, + launchedCount = launchedCount, + snackbarHostState = snackbarHostState, + onScopeRequested = onRefresh + ) } - if (settingsPrefs != null && isHooked && !context.isInstalledAfterReboot()) { + if (settingsPrefs != null && isHooked) { SettingsCard( icon = Icons.Default.Settings, title = stringResource(R.string.long_press_settings) @@ -313,7 +276,6 @@ fun SettingsScreen( vibrationLength, vibrationAmplitude ), - vibrator = vibrator, sharedPreferences = settingsPrefs ) { vibrationType = it.vibrationType @@ -482,23 +444,6 @@ fun ActionIconButton( } } -@Composable -fun LaunchCounter(launchedCount: Int) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - stringResource(R.string.module_launch_count), - style = MaterialTheme.typography.bodyMedium - ) - Text( - text = launchedCount.toString(), - fontWeight = FontWeight.Medium - ) - } -} - @Composable fun LoadingAnimation() { Column( @@ -516,95 +461,14 @@ fun LoadingAnimation() { } } -@Composable -fun ModuleStatus(isHooked: Boolean) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = stringResource(R.string.module_status), - style = MaterialTheme.typography.bodyMedium - ) - Text( - text = stringResource( - if (isHooked) R.string.module_status_active - else R.string.module_status_inactive - ), - color = if (isHooked) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.error, - fontWeight = FontWeight.Bold - ) - } -} - -@Composable -fun ModuleIsNotEnabled() { - Text( - text = buildAnnotatedString { - append(stringResource(id = R.string.module_is_not_enabled)) - RecommendedLsposedVersion() - } - ) -} - -@Composable -fun ModuleInitError() { - Text( - text = buildAnnotatedString { - append(stringResource(id = R.string.module_init_error)) - append("\n") - append("\n") - withLink( - LinkAnnotation.Url( - url = Constants.GITHUB_NEW_ISSUE_URL, - styles = TextLinkStyles( - style = SpanStyle( - color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline - ) - ) - ) - ) { - append(stringResource(id = R.string.open_an_issue)) - } - append(" ") - append(stringResource(id = R.string.if_the_problem_persists)) - RecommendedLsposedVersion() - } - ) -} - -@Composable -private fun Builder.RecommendedLsposedVersion() { - append("\n") - append("\n") - append(stringResource(id = R.string.recommended_lsposed_version)) - append(" ") - withLink( - LinkAnnotation.Url( - url = Constants.LSPOSED_GITHUB_URL, - styles = TextLinkStyles( - style = SpanStyle( - color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline - ) - ) - ) - ) { - append(stringResource(id = R.string.recommended_lsposed_version_url)) - } +sealed class ScopeRequestStatus { + object Loading : ScopeRequestStatus() + data class Success(val approved: List) : ScopeRequestStatus() + data class Error(val message: String) : ScopeRequestStatus() } @Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun PreviewSettingsScreen() { - SettingsScreen( - navController = null, - settingsPrefs = LocalContext.current.getSharedPreferences( - SETTINGS_PREFS, - Context.MODE_PRIVATE - ), - vibrator = null - ) + SettingsScreen(isHooked = false, navController = null) } diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/AppFilterType.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/AppFilterType.kt index 215f0d4..c42216d 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/AppFilterType.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/AppFilterType.kt @@ -1,6 +1,6 @@ package ru.hepolise.volumekeytrackcontrol.util -import ru.hepolise.volumekeytrackcontrolmodule.R +import ru.hepolise.volumekeytrackcontrol.R enum class AppFilterType( val key: String, diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/Constants.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/Constants.kt index 29d73b5..bad187d 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/Constants.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/Constants.kt @@ -7,6 +7,5 @@ object Constants { const val LSPOSED_GITHUB_URL = "https://github.com/JingMatrix/LSPosed/actions/workflows/core.yml" - const val SET_HOOKED = "set_hooked" const val INCREMENT_LAUNCH_COUNT = "increment_launch_count" } \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/LSPosedLogger.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/LSPosedLogger.kt deleted file mode 100644 index ab3e42d..0000000 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/LSPosedLogger.kt +++ /dev/null @@ -1,10 +0,0 @@ -package ru.hepolise.volumekeytrackcontrol.util - -import android.util.Log -import ru.hepolise.volumekeytrackcontrolmodule.BuildConfig - -object LSPosedLogger { - fun log(text: String) { - if (BuildConfig.DEBUG) Log.d("LSPosed-Bridge", text) - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/SharedPreferencesUtil.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/SharedPreferencesUtil.kt index 09fa2ef..c432342 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/SharedPreferencesUtil.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/SharedPreferencesUtil.kt @@ -1,15 +1,11 @@ package ru.hepolise.volumekeytrackcontrol.util -import android.annotation.SuppressLint import android.content.Context import android.content.Context.MODE_PRIVATE -import android.content.Context.MODE_WORLD_READABLE import android.content.SharedPreferences import android.os.Build -import android.os.SystemClock import android.view.ViewConfiguration -import de.robv.android.xposed.XSharedPreferences -import ru.hepolise.volumekeytrackcontrolmodule.BuildConfig +import io.github.libxposed.service.XposedService object SharedPreferencesUtil { const val SETTINGS_PREFS = "settings_prefs" @@ -27,9 +23,7 @@ object SharedPreferencesUtil { const val WHITE_LIST_APPS = "whiteListApps" const val BLACK_LIST_APPS = "blackListApps" - const val LAST_INIT_HOOK_TIME = "lastInitHookTime" const val LAUNCHED_COUNT = "launchedCount" - const val LAST_BOOT_COMPLETED_TIME = "lastBootCompletedTime" val EFFECT_DEFAULT_VALUE = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) VibrationType.Click.key else VibrationType.Manual.key @@ -42,7 +36,7 @@ object SharedPreferencesUtil { const val IS_ADD_SECONDARY_ACTION_DEFAULT_VALUE = false val APP_FILTER_TYPE_DEFAULT_VALUE = AppFilterType.DISABLED.key - const val LAUNCHED_COUNT_DEFAULT_VALUE = 0 + const val LAUNCHED_COUNT_DEFAULT_VALUE = -1 fun SharedPreferences?.getVibrationType(): VibrationType { val defaultValue = EFFECT_DEFAULT_VALUE @@ -102,35 +96,11 @@ object SharedPreferencesUtil { } } - fun SharedPreferences.isHooked(): Boolean { - val hookTime by lazy { - getLong(LAST_INIT_HOOK_TIME, 0L) - } - return StatusSysPropsHelper.isHooked - || hookTime >= (System.currentTimeMillis() - SystemClock.elapsedRealtime()) - } - - fun SharedPreferences.isBootCompleted() = - getLong( - LAST_BOOT_COMPLETED_TIME, - 0L - ) >= (System.currentTimeMillis() - SystemClock.elapsedRealtime()) - fun SharedPreferences.getLaunchedCount(): Int = this.getInt(LAUNCHED_COUNT, LAUNCHED_COUNT_DEFAULT_VALUE) - private var _prefs: SharedPreferences? = null - - fun prefs(): SharedPreferences? = - XSharedPreferences(BuildConfig.APPLICATION_ID, SETTINGS_PREFS) - .takeIf { it.file.canRead() } - ?.also { _prefs = it } ?: _prefs - - fun Context.getSettingsSharedPreferences(): SharedPreferences? = runCatching { - @SuppressLint("WorldReadableFiles") - @Suppress("DEPRECATION") - return getSharedPreferences(SETTINGS_PREFS, MODE_WORLD_READABLE) - }.getOrNull() + fun XposedService.getSettingsSharedPreferences(): SharedPreferences = + getRemotePreferences(SETTINGS_PREFS) fun Context.getStatusSharedPreferences(): SharedPreferences = getSharedPreferences(STATUS_PREFS, MODE_PRIVATE) diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/StatusSysPropsHelper.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/StatusSysPropsHelper.kt deleted file mode 100644 index 52203a8..0000000 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/StatusSysPropsHelper.kt +++ /dev/null @@ -1,44 +0,0 @@ -package ru.hepolise.volumekeytrackcontrol.util - -import ru.hepolise.volumekeytrackcontrolmodule.BuildConfig -import java.io.File -import java.security.MessageDigest -import java.util.Locale - -object StatusSysPropsHelper { - - private fun readBootIdShort(): String? { - try { - val f = File("/proc/sys/kernel/random/boot_id") - if (f.exists()) { - val id = f.readText().trim() - if (id.isNotEmpty()) { - return sha256(id).substring(0, 8).lowercase(Locale.US) - } - } - } catch (_: Throwable) { /* ignore */ - } - return null - } - - private fun sha256(input: String): String { - val md = MessageDigest.getInstance("SHA-256") - val digest = md.digest("$input:${BuildConfig.APPLICATION_ID}".toByteArray(Charsets.UTF_8)) - return digest.joinToString("") { "%02x".format(it) } - } - - fun dynamicKey(): String { - val suffix = readBootIdShort() ?: "noboot" - return "sys.$suffix" - } - - private var _isHooked: Boolean? = null - val isHooked: Boolean - get() = _isHooked ?: run { - (SystemProps.get(dynamicKey()) == "1").also { _isHooked = it } - } - - fun refreshIsHooked() { - _isHooked = null - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/SystemProps.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/SystemProps.kt deleted file mode 100644 index 73b064c..0000000 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/SystemProps.kt +++ /dev/null @@ -1,30 +0,0 @@ -package ru.hepolise.volumekeytrackcontrol.util - -import ru.hepolise.volumekeytrackcontrol.module.util.LogHelper - -@Suppress("PrivateApi") -object SystemProps { - private fun log(text: String) = - LogHelper.log(SystemProps::class.java.simpleName, text) - - private val clazz by lazy { Class.forName("android.os.SystemProperties") } - private val getString by lazy { clazz.getMethod("get", String::class.java, String::class.java) } - private val setString by lazy { clazz.getMethod("set", String::class.java, String::class.java) } - - fun get(key: String, def: String = ""): String { - return try { - getString.invoke(null, key, def) as String - } catch (_: Throwable) { - def - } - } - - fun set(key: String, value: String) { - try { - setString.invoke(null, key, value) - log("set $key to $value") - } catch (t: Throwable) { - log("set $key to $value failed: ${t.message}") - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/VibratorUtil.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/VibratorUtil.kt index f842cca..9b2b932 100644 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/VibratorUtil.kt +++ b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/util/VibratorUtil.kt @@ -25,7 +25,7 @@ object VibratorUtil { } } - fun Vibrator.triggerVibration(prefs: SharedPreferences? = SharedPreferencesUtil.prefs()) { + fun Vibrator.triggerVibration(prefs: SharedPreferences?) { val vibrationType = prefs.getVibrationType() if (vibrationType == VibrationType.Disabled) return diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/viewmodel/BootViewModel.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/viewmodel/BootViewModel.kt deleted file mode 100644 index 5eed922..0000000 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/viewmodel/BootViewModel.kt +++ /dev/null @@ -1,70 +0,0 @@ -package ru.hepolise.volumekeytrackcontrol.viewmodel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import ru.hepolise.volumekeytrackcontrol.repository.BootRepository -import ru.hepolise.volumekeytrackcontrol.util.LSPosedLogger -import ru.hepolise.volumekeytrackcontrol.util.StatusSysPropsHelper - -class BootViewModel( - private val bootRepository: BootRepository -) : ViewModel() { - - private val _isBootCompleted = MutableStateFlow(false) - val isBootCompleted = _isBootCompleted.asStateFlow() - - private val _isLoading = MutableStateFlow(true) - val isLoading = _isLoading.asStateFlow() - - private var observationJob: Job? = null - - init { - LSPosedLogger.log("Init view model") - observationJob = viewModelScope.launch { - bootRepository.observeBootCompleted().collect { completed -> - _isBootCompleted.value = completed - _isLoading.value = false - observationJob?.cancel() - } - } - - viewModelScope.launch { - if (!bootRepository.isBootCompleted()) { - checkBootStatus() - } - } - } - - - private suspend fun checkBootStatus() { - val maxAttempts = 60 - var attempts = 0 - - while (attempts < maxAttempts) { - delay(1_000) - StatusSysPropsHelper.refreshIsHooked() - - if (bootRepository.isHooked()) { - LSPosedLogger.log("Boot completed - hook detected") - bootRepository.setBootCompleted() - return - } - - attempts++ - LSPosedLogger.log("Attempt $attempts: hook not detected yet") - } - - LSPosedLogger.log("By timer, boot is not still completed after $maxAttempts attempts") - _isLoading.value = false - } - - override fun onCleared() { - observationJob?.cancel() - super.onCleared() - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/viewmodel/BootViewModelFactory.kt b/app/src/main/java/ru/hepolise/volumekeytrackcontrol/viewmodel/BootViewModelFactory.kt deleted file mode 100644 index 3502c4b..0000000 --- a/app/src/main/java/ru/hepolise/volumekeytrackcontrol/viewmodel/BootViewModelFactory.kt +++ /dev/null @@ -1,17 +0,0 @@ -package ru.hepolise.volumekeytrackcontrol.viewmodel - -import android.content.Context -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import ru.hepolise.volumekeytrackcontrol.repository.BootRepository - -class BootViewModelFactory(private val context: Context) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(BootViewModel::class.java)) { - val repository = BootRepository.getBootRepository(context) - return BootViewModel(repository) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} \ No newline at end of file diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index c7bb551..58deb1e 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -1,4 +1,5 @@ + Управляйте воспроизведением музыки с помощью долгого нажатия кнопок громкости: пропускайте треки, перематывайте, ставьте на паузу/воспроизведение, настраивайте фильтр приложений и вибрацию. Информация о модуле Статус: Активен @@ -84,4 +85,12 @@ Изменить Значение в диапазоне %1$d..%2$d + + Для работы модуля необходимо выбрать system в разделе Scope (область действия) в LSPosed. + Текущая область действия (Scope): + Запросить область действия + Ошибка запроса области действия: %s + Область действия запрошена: %s + Ошибка запроса: %s + ⏳ Запрос области действия… \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 214d724..d3128dc 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1,5 +1,6 @@ + 通过长按音量键控制音乐播放:切换曲目、快退、播放/暂停,支持应用过滤和振动设置。 模块信息 状态: 已启用 @@ -79,4 +80,12 @@ 编辑 取值范围:%1$d~%2$d + + 要使用模块,您需要在 LSPosed 的 Scope(作用域)中选择“system” + 当前作用域: + 请求作用域 + 请求作用域失败:%s + 已请求作用域:%s + 请求错误:%s + ⏳ 正在请求作用域… diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index ea7e29e..0092bc4 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1,5 +1,6 @@ + 通過長按音量鍵控制音樂播放:切換曲目、倒轉、播放/暫停,支援應用過濾和震動設定。 模組資訊 狀態: 已啟用 @@ -79,4 +80,12 @@ 編輯 取值範圍:%1$d~%2$d + + 要使用模組,您需要在 LSPosed 的 Scope(作用域)中選擇「system」 + 目前作用域: + 請求作用域 + 請求作用域失敗:%s + 已請求作用域:%s + 請求錯誤:%s + ⏳ 正在請求作用域… diff --git a/app/src/main/res/values/module_scope.xml b/app/src/main/res/values/module_scope.xml deleted file mode 100644 index 1974b85..0000000 --- a/app/src/main/res/values/module_scope.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - android - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 62a99cd..d7bd4fd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,6 @@ Volume Key Track Control + Control music playback with long press volume keys: skip tracks, rewind, play/pause, with app filter and vibration settings. Module Info Status: @@ -82,4 +83,12 @@ Edit Value in range %1$d..%2$d + + To use the module, you need to select \'system\' in Scope (LSPosed) + Current Scope: + Request Scope + Scope request failed: %s + Scope requested: %s + Request error: %s + ⏳ Requesting Scope… diff --git a/app/src/main/assets/xposed_init b/app/src/main/resources/META-INF/xposed/java_init.list similarity index 100% rename from app/src/main/assets/xposed_init rename to app/src/main/resources/META-INF/xposed/java_init.list diff --git a/app/src/main/resources/META-INF/xposed/module.prop b/app/src/main/resources/META-INF/xposed/module.prop new file mode 100644 index 0000000..c03ea48 --- /dev/null +++ b/app/src/main/resources/META-INF/xposed/module.prop @@ -0,0 +1,4 @@ +minApiVersion=102 +targetApiVersion=102 +staticScope=true +autoHotReload=true diff --git a/app/src/main/resources/META-INF/xposed/scope.list b/app/src/main/resources/META-INF/xposed/scope.list new file mode 100644 index 0000000..5b82011 --- /dev/null +++ b/app/src/main/resources/META-INF/xposed/scope.list @@ -0,0 +1 @@ +system \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 77de79e..1a960bf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,8 +5,8 @@ plugins { id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false } -val versionName = "1.16.3" -val versionCode = 18 +val versionName = "2.17.0.RC" +val versionCode = 19 rootProject.ext.set("appVersionName", versionName) rootProject.ext.set("appVersionCode", versionCode)